summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore26
-rw-r--r--.markdownlint-cli2.jsonc16
-rw-r--r--CHANGELOG.md16
-rw-r--r--Makefile201
-rwxr-xr-xdocker/scripts/ciq-env.sh49
-rwxr-xr-xdocker/scripts/gen-key.sh39
-rwxr-xr-xdocker/scripts/sdk-setup.sh88
-rw-r--r--manifest.xml96
-rw-r--r--pyproject.toml27
9 files changed, 558 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..8da1c0d
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,26 @@
+# Local tool state: Garmin SDK, device files, SDK-manager login token and the
+# developer key. Never commit any of it (Garmin's licence, and the key signs
+# every release).
+/.cache/
+
+# Build outputs.
+/build/
+/bin/
+
+# Signing keys and signed artifacts, wherever they end up.
+*.der
+*.pem
+*.prg
+*.iq
+*.prg.debug.xml
+
+# Python tooling caches.
+__pycache__/
+*.py[cod]
+.pytest_cache/
+.mypy_cache/
+.ruff_cache/
+.hypothesis/
+
+# Node modules are installed only inside the docs image.
+node_modules/
diff --git a/.markdownlint-cli2.jsonc b/.markdownlint-cli2.jsonc
new file mode 100644
index 0000000..bd36a04
--- /dev/null
+++ b/.markdownlint-cli2.jsonc
@@ -0,0 +1,16 @@
+// Markdown lint settings for `make docs` (markdownlint-cli2 in the docs image).
+// Default rules, with these deliberate exceptions:
+{
+ "config": {
+ "default": true,
+ // Prose lines are not hard-wrapped and tables can't wrap.
+ "MD013": false,
+ // The same subheading (e.g. "Tests") may repeat under different parents.
+ "MD024": { "siblings_only": true },
+ // Local URLs such as http://127.0.0.1:6080/vnc.html appear as plain text.
+ "MD034": false
+ },
+ "globs": ["**/*.md"],
+ // Generated or third-party files, and your original prompt (kept verbatim).
+ "ignores": ["node_modules/**", ".cache/**", "build/**", "Garmin OTP Prompt.md"]
+}
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..6d7f149
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,16 @@
+# Changelog
+
+All notable changes to this project are documented here. The format follows
+[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project uses
+[Semantic Versioning](https://semver.org/spec/v2.0.0.html). Versions stay at
+0.x until milestone M8.
+
+## [Unreleased]
+
+### Added
+
+- Project plan (`README.md`) and Phase 0 feasibility findings (`docs/FEASIBILITY.md`).
+- M0 scaffolding: Docker development and documentation images with pinned
+ versions, a host `Makefile` that runs every step in Docker, interactive
+ Connect IQ SDK setup, developer key generation, headless simulator test
+ runner, and a minimal watch app with a smoke test.
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..29b7702
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,201 @@
+# OTP Wallet for Connect IQ: host Makefile.
+#
+# Every target runs inside Docker; the host needs only Docker and make
+# (README section 15). Run `make help` to list the targets.
+#
+# The project path may contain spaces (".../Garmin OTP"), so paths are always
+# quoted in recipes and never used as make prerequisites.
+
+SHELL := /bin/bash
+.SHELLFLAGS := -eu -o pipefail -c
+.DEFAULT_GOAL := help
+MAKEFLAGS += --no-builtin-rules
+# Targets run one after another even with `make -j` (a common shell alias):
+# `make ci` must stop at the first failing step, and two image builds or two
+# simulators must never race.
+.NOTPARALLEL:
+
+PROJECT := garmin-otp
+DEV_IMAGE := $(PROJECT)/dev:local
+DOCS_IMAGE := $(PROJECT)/docs:local
+BUILDER := $(PROJECT)
+# Pinned helper images: the BuildKit daemon behind the project's own builder,
+# the base images, and the throwaway images `make lock` resolves locks with.
+BUILDKIT_IMAGE := moby/buildkit@sha256:cec9f139f45e93c5c69c60f8b07cfad9f43f4ef6b6a6cd917527fea5ff2e3dea
+UBUNTU_IMAGE := ubuntu@sha256:0a9887931b0e38af2e3487ed39e29436b0535c4f3f52e57745baaf79d1898956
+NODE_IMAGE := node@sha256:c3de60bf2f9dd0ac6370e6117950ff62d6e339527e7472301c9c78a017978392
+LOCK_PY_IMAGE := python@sha256:5be5aaecb962a41567bc8041f1e9e3432cb085b037fe057f9a562630b2e4b370
+UV_VERSION := 0.13.1
+
+# Connect IQ SDK installed by `make sdk`.
+SDK_VERSION ?= 9.2.0
+# Device for `make build` and `make sim`.
+DEVICE ?= fr255s
+# Simulator test devices: weakest round MIP (buttons only), rectangular AMOLED,
+# your Forerunner 70, and a large round AMOLED.
+TEST_DEVICES ?= fr255s venusq2 fr70 fr965
+# `make lock` only uses package releases published before this moment.
+LOCK_CUTOFF ?= 2026-10-01T00:00:00Z
+# Set to `nuke` to skip the confirmation prompt of `make nuke`.
+CONFIRM ?=
+
+HOST_UID := $(shell id -u)
+# Your primary group from the user database: `id -g` would report another
+# group inside `newgrp docker`, a common way to use a freshly joined group.
+HOST_GID := $(shell getent passwd "$$(id -u)" | cut -d: -f4 || id -g)
+ROOT := $(CURDIR)
+DOCKER ?= docker
+
+# Flags shared by every container: removed on exit, labelled for `make nuke`,
+# your UID/GID, no Linux capabilities, no privilege escalation, read-only root
+# filesystem, and only the project folder mounted. Its .cache/home folder
+# becomes $HOME, so the SDK, device files and login token stay in the project.
+RUN_FLAGS = --rm --init --label project=$(PROJECT) \
+ --user $(HOST_UID):$(HOST_GID) --cap-drop ALL --security-opt no-new-privileges \
+ --read-only --tmpfs /tmp:rw,exec,nosuid,size=2g --shm-size 512m \
+ -v "$(ROOT)":/work -v "$(ROOT)/.cache/home":/home/dev -w /work \
+ -e PYTHONDONTWRITEBYTECODE=1 -e HYPOTHESIS_STORAGE_DIRECTORY=/work/build/.hypothesis \
+ -e SDK_VERSION=$(SDK_VERSION) -e DEVICE=$(DEVICE) -e TEST_DEVICES="$(TEST_DEVICES)"
+
+# Most targets run with no network at all.
+DEV_OFFLINE = $(DOCKER) run $(RUN_FLAGS) --network none $(DEV_IMAGE)
+
+# The docs image needs no project cache; Chrome wants a larger /dev/shm.
+DOCS_RUN = $(DOCKER) run --rm --init --label project=$(PROJECT) \
+ --user $(HOST_UID):$(HOST_GID) --cap-drop ALL --security-opt no-new-privileges \
+ --read-only --tmpfs /tmp:rw,exec,nosuid,size=1g --shm-size 512m \
+ -v "$(ROOT)":/work -w /work --network none $(DOCS_IMAGE)
+
+.PHONY: help image docs-image sdk key build test test-tools sim screenshots perf memtest \
+ lint package docs ci shell clean nuke lock products \
+ check-docker dirs need-image need-docs-image
+
+help: ## List the targets
+ @echo "OTP Wallet: every target runs in Docker. Variables: DEVICE=$(DEVICE) TEST_DEVICES=\"$(TEST_DEVICES)\""
+ @grep -E '^[a-z-]+:.*## ' Makefile | sed -E 's/:.*## /\t/' | awk -F'\t' '{printf " %-12s %s\n", $$1, $$2}'
+
+# ---------------------------------------------------------------- setup
+
+image: check-docker ## Build the Docker images (network: base images, Ubuntu snapshot, PyPI, GitHub)
+ @$(DOCKER) buildx inspect $(BUILDER) >/dev/null 2>&1 || \
+ $(DOCKER) buildx create --name $(BUILDER) --driver docker-container \
+ --driver-opt image=$(BUILDKIT_IMAGE) --bootstrap >/dev/null
+ $(DOCKER) buildx build --builder $(BUILDER) --load --label project=$(PROJECT) \
+ --build-arg UID=$(HOST_UID) --build-arg GID=$(HOST_GID) \
+ -t $(DEV_IMAGE) -f docker/Dockerfile docker
+
+docs-image: check-docker
+ @$(DOCKER) buildx inspect $(BUILDER) >/dev/null 2>&1 || \
+ $(DOCKER) buildx create --name $(BUILDER) --driver docker-container \
+ --driver-opt image=$(BUILDKIT_IMAGE) --bootstrap >/dev/null
+ $(DOCKER) buildx build --builder $(BUILDER) --load --label project=$(PROJECT) \
+ -t $(DOCS_IMAGE) -f docker/docs.Dockerfile docker
+
+sdk: dirs need-image ## Accept Garmin's licence, log in, download SDK + device files (interactive, network)
+ $(DOCKER) run -it $(RUN_FLAGS) $(DEV_IMAGE) sdk-setup.sh
+
+key: dirs need-image ## Generate your developer key into .cache/keys (never overwrites one)
+ $(DEV_OFFLINE) gen-key.sh
+
+lock: check-docker ## Regenerate the Python and npm lock files (network)
+ $(DOCKER) run --rm --label project=$(PROJECT) --user $(HOST_UID):$(HOST_GID) -e HOME=/tmp \
+ -v "$(ROOT)/docker":/w -w /w $(LOCK_PY_IMAGE) sh -c '\
+ pip install --quiet --user uv==$(UV_VERSION) && \
+ /tmp/.local/bin/uv pip compile requirements.in --generate-hashes --no-build \
+ --python-version 3.10 --python-platform x86_64-manylinux_2_35 \
+ --exclude-newer $(LOCK_CUTOFF) --no-header --quiet -o requirements.txt'
+ $(DOCKER) run --rm --label project=$(PROJECT) --user $(HOST_UID):$(HOST_GID) -e HOME=/tmp \
+ -e npm_config_cache=/tmp/.npm -v "$(ROOT)/docker/docs":/w -w /w $(NODE_IMAGE) sh -c '\
+ npm install --package-lock-only --ignore-scripts --no-audit --no-fund \
+ --before=$(LOCK_CUTOFF) --loglevel=error'
+ @echo "Locks regenerated. Review the diff, then run 'make image docs-image'."
+
+# ---------------------------------------------------------------- build and run
+
+build: dirs need-image ## Debug build for DEVICE (default fr255s) into build/debug
+ $(DEV_OFFLINE) build-app.sh debug $(DEVICE)
+
+sim: dirs need-image ## Run the app in the simulator; watch at http://127.0.0.1:6080/vnc.html
+ $(DOCKER) run -it $(RUN_FLAGS) -p 127.0.0.1:6080:6080 $(DEV_IMAGE) sim-vnc.sh $(DEVICE)
+
+package: dirs need-image ## Signed .iq for the Store beta + per-device release .prg (build/package)
+ $(DEV_OFFLINE) package.sh
+
+products: dirs need-image ## Check manifest products against the downloaded device files
+ $(DEV_OFFLINE) python3 tools/gen_products.py --check
+
+shell: dirs need-image ## Interactive shell in the dev container (no network)
+ $(DOCKER) run -it $(RUN_FLAGS) --network none $(DEV_IMAGE) bash
+
+# ---------------------------------------------------------------- checks
+
+test: test-tools ## Python tool tests + Monkey C unit tests on TEST_DEVICES (headless simulator)
+ $(DEV_OFFLINE) run-unit-tests.sh $(TEST_DEVICES)
+
+test-tools: dirs need-image
+ $(DEV_OFFLINE) python3 -m pytest -q tools/tests
+
+screenshots: dirs need-image ## Screenshot the app on TEST_DEVICES into build/screenshots
+ $(DEV_OFFLINE) screenshots.sh $(TEST_DEVICES)
+
+perf: dirs need-image ## Start-up time on TEST_DEVICES against tools/budgets.json
+ $(DEV_OFFLINE) app-diag.sh perf $(TEST_DEVICES)
+
+memtest: dirs need-image ## Memory use on TEST_DEVICES against tools/budgets.json
+ $(DEV_OFFLINE) app-diag.sh mem $(TEST_DEVICES)
+
+lint: dirs need-image ## Strict type check (warnings fail), project rules, Python lint and types
+ $(DEV_OFFLINE) lint.sh $(TEST_DEVICES)
+
+docs: dirs need-image need-docs-image ## Markdown lint, link check, Mermaid rendering into build/docs
+ $(DEV_OFFLINE) python3 tools/check_docs.py
+ $(DOCS_RUN) sh -c 'markdownlint-cli2 && mkdir -p build/docs && \
+ for f in README.md docs/*.md; do \
+ if grep -q "^\`\`\`mermaid" "$$f"; then \
+ out="build/docs/$$(basename "$$f")"; \
+ mmdc --quiet -p /opt/docs-tools/puppeteer.json -i "$$f" -o "$$out" || exit 1; \
+ echo "rendered Mermaid diagrams of $$f -> $$out"; \
+ fi; \
+ done'
+
+ci: lint test screenshots perf memtest package docs ## Everything above, in order; stops at the first failure
+ @echo "make ci: all checks passed"
+
+# ---------------------------------------------------------------- cleanup
+
+clean: ## Delete build outputs
+ rm -rf -- "$(ROOT)/build"
+
+nuke: ## Delete everything this project created, including .cache (SDK, login, DEVELOPER KEY)
+ @echo "This deletes:"
+ @echo " - containers and images labelled project=$(PROJECT), and the '$(BUILDER)' builder with its cache"
+ @echo " - the pinned base and helper images (skipped if anything else still uses them)"
+ @echo " - .cache/ (Garmin SDK, device files, login token, YOUR DEVELOPER KEY) and build/"
+ @echo "Back up .cache/keys/ first if you want to publish updates to the app later."
+ @answer="$(CONFIRM)"; \
+ if [[ "$$answer" != nuke ]]; then read -r -p "Type 'nuke' to continue: " answer; fi; \
+ [[ "$$answer" == nuke ]] || { echo "Aborted."; exit 1; }
+ -$(DOCKER) ps -aq --filter label=project=$(PROJECT) | xargs -r $(DOCKER) rm -f
+ -$(DOCKER) buildx rm $(BUILDER) 2>/dev/null
+ -$(DOCKER) image rm $(DEV_IMAGE) $(DOCS_IMAGE) 2>/dev/null
+ -$(DOCKER) image ls -q --filter label=project=$(PROJECT) | sort -u | xargs -r $(DOCKER) image rm 2>/dev/null
+ -$(DOCKER) image rm $(BUILDKIT_IMAGE) $(UBUNTU_IMAGE) $(NODE_IMAGE) $(LOCK_PY_IMAGE) 2>/dev/null
+ rm -rf -- "$(ROOT)/.cache" "$(ROOT)/build"
+ @echo "Done. Delete the project folder to remove the rest."
+
+# ---------------------------------------------------------------- helpers
+
+check-docker:
+ @$(DOCKER) info >/dev/null 2>&1 || { \
+ echo "Docker isn't reachable. If you were just added to the 'docker' group, log out and" >&2; \
+ echo "back in (or run 'newgrp docker' in this terminal), then try again." >&2; exit 1; }
+
+dirs:
+ @mkdir -p "$(ROOT)/.cache/home" "$(ROOT)/.cache/keys" "$(ROOT)/build"
+ @chmod 700 "$(ROOT)/.cache/keys"
+
+need-image: check-docker
+ @$(DOCKER) image inspect $(DEV_IMAGE) >/dev/null 2>&1 || $(MAKE) --no-print-directory image
+
+need-docs-image: check-docker
+ @$(DOCKER) image inspect $(DOCS_IMAGE) >/dev/null 2>&1 || $(MAKE) --no-print-directory docs-image
diff --git a/docker/scripts/ciq-env.sh b/docker/scripts/ciq-env.sh
new file mode 100755
index 0000000..945d59a
--- /dev/null
+++ b/docker/scripts/ciq-env.sh
@@ -0,0 +1,49 @@
+#!/usr/bin/env bash
+# Shared helpers for the container scripts. Source this file; don't run it.
+#
+# Paths, as seen inside the container:
+# /work the project folder (bind mount)
+# /home/dev the project's .cache/home folder (bind mount); the Garmin
+# tools keep the SDK and device files under ~/.Garmin
+# /work/.cache/keys the developer key (never baked into an image)
+
+CIQ_HOME="${HOME}/.Garmin/ConnectIQ"
+KEY_DIR="/work/.cache/keys"
+DEV_KEY="${KEY_DIR}/developer_key.der"
+BUILD_DIR="/work/build"
+
+# Print an error and stop. Used for every user-facing failure, so the message
+# says what to do next instead of leaving a bare stack trace.
+die() {
+ echo "error: $*" >&2
+ exit 1
+}
+
+# Progress line on stderr, so stdout stays clean for machine-readable output.
+log() {
+ echo "==> $*" >&2
+}
+
+# Find the SDK selected by `make sdk` and put its bin/ folder on PATH.
+# Sets CIQ_SDK to the SDK root folder.
+ciq_require_sdk() {
+ local cfg="${CIQ_HOME}/current-sdk.cfg"
+ [[ -f "${cfg}" ]] || die "Connect IQ SDK not installed. Run 'make sdk' first (you accept Garmin's licence and log in there)."
+ CIQ_SDK="$(tr -d '\r\n' < "${cfg}")"
+ CIQ_SDK="${CIQ_SDK%/}"
+ [[ -x "${CIQ_SDK}/bin/monkeyc" ]] || die "SDK folder '${CIQ_SDK}' has no bin/monkeyc. Run 'make sdk' again."
+ export CIQ_SDK
+ export PATH="${CIQ_SDK}/bin:${PATH}"
+}
+
+# The developer key signs every build, including test builds.
+ciq_require_key() {
+ [[ -f "${DEV_KEY}" ]] || die "No developer key at .cache/keys/developer_key.der. Run 'make key' first."
+}
+
+# Fail early, with a pointer to `make sdk`, when a device's files are missing.
+ciq_require_device() {
+ local device="$1"
+ [[ -f "${CIQ_HOME}/Devices/${device}/compiler.json" ]] \
+ || die "No device files for '${device}'. Is it in manifest.xml? Then run 'make sdk' to download them."
+}
diff --git a/docker/scripts/gen-key.sh b/docker/scripts/gen-key.sh
new file mode 100755
index 0000000..7b99863
--- /dev/null
+++ b/docker/scripts/gen-key.sh
@@ -0,0 +1,39 @@
+#!/usr/bin/env bash
+# Generate the developer key that signs every build (README section 15.6).
+#
+# Output: .cache/keys/developer_key.der (PKCS#8 DER, what monkeyc wants) and
+# developer_key.pem, both mode 600 and git-ignored. An existing key is never
+# overwritten: the Connect IQ Store rejects updates signed with a different
+# key, so replacing it by accident would strand the published app.
+set -euo pipefail
+# shellcheck source=ciq-env.sh
+source "$(dirname "${BASH_SOURCE[0]}")/ciq-env.sh"
+
+umask 077
+mkdir -p "${KEY_DIR}"
+
+if [[ -f "${DEV_KEY}" ]]; then
+ log "Developer key already exists at .cache/keys/developer_key.der; leaving it untouched."
+ exit 0
+fi
+
+log "Generating a 4096-bit RSA developer key (this takes a few seconds)..."
+openssl genrsa -out "${KEY_DIR}/developer_key.pem" 4096 2>/dev/null
+openssl pkcs8 -topk8 -inform PEM -outform DER -nocrypt \
+ -in "${KEY_DIR}/developer_key.pem" -out "${DEV_KEY}"
+chmod 600 "${KEY_DIR}/developer_key.pem" "${DEV_KEY}"
+
+# A short fingerprint of the public key, so a backup can be recognised later.
+fingerprint="$(openssl pkey -in "${KEY_DIR}/developer_key.pem" -pubout -outform DER \
+ | sha256sum | cut -c1-16)"
+
+cat >&2 <<EOF
+
+Developer key created: .cache/keys/developer_key.der
+Public key fingerprint (SHA-256, first 16 hex digits): ${fingerprint}
+
+BACK IT UP NOW. Copy the folder .cache/keys/ to an encrypted place (a password
+manager attachment or an encrypted USB drive). Garmin rejects app updates that
+are signed with a different key; without this key you would have to publish a
+new app and re-add every entry on the watch. 'make nuke' deletes this folder.
+EOF
diff --git a/docker/scripts/sdk-setup.sh b/docker/scripts/sdk-setup.sh
new file mode 100755
index 0000000..278b060
--- /dev/null
+++ b/docker/scripts/sdk-setup.sh
@@ -0,0 +1,88 @@
+#!/usr/bin/env bash
+# Interactive first-time setup behind `make sdk` (README section 15.3).
+#
+# 1. Garmin's agreements: you read them in your browser and accept them here.
+# 2. Login with your Garmin account. Email and password are typed here and
+# passed only to the SDK manager CLI, which sends them to Garmin's sign-in
+# page. Neither is written to disk; the CLI stores an access token.
+# 3. Download Connect IQ SDK ${SDK_VERSION} and make it the current SDK.
+# 4. Download the device files and simulator fonts for every product listed
+# in manifest.xml.
+# 5. Log out: delete the access token (set KEEP_LOGIN=1 to keep it).
+#
+# Why email and password instead of the CLI's browser login: that flow waits
+# for a redirect to a random localhost port inside the container, which your
+# browser can't reach without host networking (not allowed by the sandbox
+# rules). Password login doesn't support Garmin two-step verification; README
+# section 15.3 describes the fallback for such accounts.
+set -euo pipefail
+# shellcheck source=ciq-env.sh
+source "$(dirname "${BASH_SOURCE[0]}")/ciq-env.sh"
+
+readonly SDKMGR=connect-iq-sdk-manager
+readonly MANIFEST=/work/manifest.xml
+readonly TOKEN="${CIQ_HOME}/token.json"
+
+[[ -t 0 ]] || die "'make sdk' is interactive; run it from a terminal."
+[[ -f "${MANIFEST}" ]] || die "manifest.xml not found in the project folder."
+: "${SDK_VERSION:?SDK_VERSION is not set (the Makefile passes it)}"
+
+echo
+echo "Step 1/5: Garmin's agreements"
+view="$(${SDKMGR} agreement view 2>&1)" || die "Could not fetch the agreement from Garmin: ${view}"
+url="$(sed -n 's/^Agreement: //p' <<<"${view}")"
+hash="$(sed -n 's/^Current Hash: //p' <<<"${view}")"
+[[ -n "${url}" && -n "${hash}" ]] || die "Unexpected output from 'agreement view': ${view}"
+cat <<EOF
+ Please read the Garmin CONNECT IQ SDK License Agreement and the
+ CONNECT IQ Application Developer Agreement in your browser:
+ ${url}
+ Agreement version (hash): ${hash}
+EOF
+read -r -p " Type 'accept' to accept them, or anything else to stop: " answer
+[[ "${answer}" == accept ]] || die "Agreements not accepted; nothing was downloaded."
+# Passing the hash makes the CLI refuse if Garmin changed the text since you read it.
+${SDKMGR} agreement accept --agreement-hash "${hash}" >/dev/null
+echo " Accepted (version ${hash})."
+
+echo
+echo "Step 2/5: Garmin login"
+if [[ -f "${TOKEN}" ]]; then
+ echo " Reusing the login token kept from an earlier run (KEEP_LOGIN=1)."
+else
+ echo " Email and password go only to Garmin's sign-in page and are not stored."
+ echo " Accounts with two-step verification can't log in this way (see README 15.3)."
+ read -r -p " Garmin account email: " GARMIN_USERNAME
+ read -r -s -p " Password (not shown): " GARMIN_PASSWORD
+ echo
+ export GARMIN_USERNAME GARMIN_PASSWORD
+ if ! ${SDKMGR} login; then
+ unset GARMIN_USERNAME GARMIN_PASSWORD
+ die "Login failed. Check email and password. Two-step verification isn't supported here; see README 15.3."
+ fi
+ unset GARMIN_USERNAME GARMIN_PASSWORD
+ echo " Logged in."
+fi
+
+echo
+echo "Step 3/5: Connect IQ SDK ${SDK_VERSION}"
+${SDKMGR} sdk set "${SDK_VERSION}"
+echo " Current SDK: $(${SDKMGR} sdk current-path)"
+
+echo
+echo "Step 4/5: device files and fonts for the products in manifest.xml"
+${SDKMGR} device download --manifest "${MANIFEST}" --include-fonts
+
+echo
+echo "Step 5/5: log out"
+if [[ "${KEEP_LOGIN:-0}" == 1 ]]; then
+ echo " KEEP_LOGIN=1: keeping the token at .cache/home/.Garmin/ConnectIQ/token.json"
+else
+ rm -f "${TOKEN}"
+ echo " Login token deleted. Run 'make sdk' again whenever new device files are needed."
+fi
+
+device_count="$(find "${CIQ_HOME}/Devices" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | wc -l)"
+echo
+echo "Done: SDK ${SDK_VERSION}, ${device_count} device folders, $(du -sh "${CIQ_HOME}" | cut -f1) in .cache/home."
+echo "Next: 'make key' (if you have no developer key yet), then 'make test'."
diff --git a/manifest.xml b/manifest.xml
new file mode 100644
index 0000000..cffbe88
--- /dev/null
+++ b/manifest.xml
@@ -0,0 +1,96 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+ Connect IQ manifest for OTP Wallet (README sections 4 and 14).
+
+ * type watch-app with minApiLevel 5.0.0: device app, so it never times out
+ like a widget, and it can provide a glance on API 4.0+ watches.
+ * No permissions at all, so the app cannot use Toybox.Communications or any
+ other network API. Keep it that way: it is part of the security design.
+ * Products: every watch with API level 5.0 or newer and at least 512 KB of
+ memory for watch apps (72 devices). tools/gen_products.py checks
+ this list against the downloaded device files (`make products`).
+-->
+<iq:manifest version="3" xmlns:iq="http://www.garmin.com/xml/connectiq">
+ <iq:application id="7e21a3c58041450f913f86cf584c5945" type="watch-app" name="@Strings.AppName"
+ entry="OtpWalletApp" launcherIcon="@Drawables.LauncherIcon" minApiLevel="5.0.0">
+ <iq:products>
+ <iq:product id="approachs50"/>
+ <iq:product id="approachs7042mm"/>
+ <iq:product id="approachs7047mm"/>
+ <iq:product id="approachs7243mm"/>
+ <iq:product id="approachs7247mm"/>
+ <iq:product id="d2airx10"/>
+ <iq:product id="d2mach1"/>
+ <iq:product id="d2mach2"/>
+ <iq:product id="d2mach2pro"/>
+ <iq:product id="descentg2"/>
+ <iq:product id="descentmk343mm"/>
+ <iq:product id="descentmk351mm"/>
+ <iq:product id="enduro3"/>
+ <iq:product id="enduro4"/>
+ <iq:product id="epix2"/>
+ <iq:product id="epix2pro42mm"/>
+ <iq:product id="epix2pro47mm"/>
+ <iq:product id="epix2pro51mm"/>
+ <iq:product id="fenix7"/>
+ <iq:product id="fenix7pro"/>
+ <iq:product id="fenix7pronowifi"/>
+ <iq:product id="fenix7s"/>
+ <iq:product id="fenix7spro"/>
+ <iq:product id="fenix7x"/>
+ <iq:product id="fenix7xpro"/>
+ <iq:product id="fenix7xpronowifi"/>
+ <iq:product id="fenix843mm"/>
+ <iq:product id="fenix847mm"/>
+ <iq:product id="fenix8pro47mm"/>
+ <iq:product id="fenix8solar47mm"/>
+ <iq:product id="fenix8solar51mm"/>
+ <iq:product id="fenix943mm"/>
+ <iq:product id="fenix947mm"/>
+ <iq:product id="fenix9pro43mm"/>
+ <iq:product id="fenix9pro47mm"/>
+ <iq:product id="fenix9pro51mm"/>
+ <iq:product id="fenix9prosolar47mm"/>
+ <iq:product id="fenix9prosolar51mm"/>
+ <iq:product id="fenixe"/>
+ <iq:product id="fr165"/>
+ <iq:product id="fr165m"/>
+ <iq:product id="fr170"/>
+ <iq:product id="fr170m"/>
+ <iq:product id="fr255"/>
+ <iq:product id="fr255m"/>
+ <iq:product id="fr255s"/>
+ <iq:product id="fr255sm"/>
+ <iq:product id="fr265"/>
+ <iq:product id="fr265s"/>
+ <iq:product id="fr57042mm"/>
+ <iq:product id="fr57047mm"/>
+ <iq:product id="fr70"/>
+ <iq:product id="fr955"/>
+ <iq:product id="fr965"/>
+ <iq:product id="fr970"/>
+ <iq:product id="instinct3amoled45mm"/>
+ <iq:product id="instinct3amoled50mm"/>
+ <iq:product id="instinctcrossoveramoled"/>
+ <iq:product id="marq2"/>
+ <iq:product id="marq2aviator"/>
+ <iq:product id="venu2"/>
+ <iq:product id="venu2plus"/>
+ <iq:product id="venu2s"/>
+ <iq:product id="venu3"/>
+ <iq:product id="venu3s"/>
+ <iq:product id="venu441mm"/>
+ <iq:product id="venu445mm"/>
+ <iq:product id="venusq2"/>
+ <iq:product id="venusq2m"/>
+ <iq:product id="venux1"/>
+ <iq:product id="vivoactive5"/>
+ <iq:product id="vivoactive6"/>
+ </iq:products>
+ <iq:permissions/>
+ <iq:languages>
+ <iq:language>eng</iq:language>
+ </iq:languages>
+ <iq:barrels/>
+ </iq:application>
+</iq:manifest>
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..c92bed4
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,27 @@
+# Configuration for the Python tools in tools/ (they run inside the dev image).
+# This is not a Python package; the file only configures pytest, ruff and mypy.
+# Caches go to build/ so the source tree stays clean.
+
+[tool.pytest.ini_options]
+testpaths = ["tools/tests"]
+cache_dir = "build/.pytest_cache"
+addopts = "-p no:cacheprovider"
+
+[tool.ruff]
+line-length = 120
+target-version = "py310"
+cache-dir = "build/.ruff_cache"
+include = ["tools/**/*.py"]
+
+[tool.ruff.lint]
+select = ["E", "F", "W", "I", "B", "UP", "SIM", "RUF", "PL"]
+ignore = [
+ "PLR2004", # magic values are fine in tests and small CLI tools
+ "PLR0912", # branchy parsers read better as one function
+]
+
+[tool.mypy]
+python_version = "3.10"
+strict = true
+files = ["tools"]
+cache_dir = "build/.mypy_cache"