diff options
| author | cirakg <cirakg@cirakg.xyz> | 2026-10-28 00:03:01 +0100 |
|---|---|---|
| committer | cirakg <cirakg@cirakg.xyz> | 2026-10-28 00:03:01 +0100 |
| commit | 9e5a85fe01e94489198a64a458f9b09584594f80 (patch) | |
| tree | 9b3668b4c42dc30dbfe65db734987663de2bdfbb | |
| parent | e5172350151043050098d37b728054eb2e2be9f8 (diff) | |
chore(release): 1.0.0, with the Connect IQ Store listingv1.0.0
The second real-watch round on the Forerunner 70 passed: codes from
otpauth:// links, the 4-digit PIN, touch scrolling and Aegis JSON import.
Version 1.0.0 ships the fixes of the 0.9.0 review.
- docs/STORE_LISTING.md: title, description and What's New for the
Garmin developer page, with measured character counts.
- docs/store/: hero (1440x720), cover (500x500) and seven round screen
images, built by tools/store_assets.py from the fr70 goldens.
- CHANGELOG 1.0.0, README status, TESTING round 2 results, YOUR_STEPS
marked done, fr70 about.png golden at 1.0.0.
make ci green on fr70.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
| -rw-r--r-- | CHANGELOG.md | 23 | ||||
| -rw-r--r-- | README.md | 9 | ||||
| -rw-r--r-- | docs/STORE_LISTING.md | 84 | ||||
| -rw-r--r-- | docs/TESTING.md | 4 | ||||
| -rw-r--r-- | docs/YOUR_STEPS.md | 2 | ||||
| -rw-r--r-- | docs/store/cover-500.png | bin | 0 -> 21700 bytes | |||
| -rw-r--r-- | docs/store/hero-1440x720.png | bin | 0 -> 204975 bytes | |||
| -rw-r--r-- | docs/store/screen-1-list.png | bin | 0 -> 30257 bytes | |||
| -rw-r--r-- | docs/store/screen-2-otp.png | bin | 0 -> 26341 bytes | |||
| -rw-r--r-- | docs/store/screen-3-qr.png | bin | 0 -> 19145 bytes | |||
| -rw-r--r-- | docs/store/screen-4-glance.png | bin | 0 -> 35695 bytes | |||
| -rw-r--r-- | docs/store/screen-5-pin.png | bin | 0 -> 16904 bytes | |||
| -rw-r--r-- | docs/store/screen-6-pdf417.png | bin | 0 -> 16733 bytes | |||
| -rw-r--r-- | docs/store/screen-7-import.png | bin | 0 -> 23676 bytes | |||
| -rw-r--r-- | source/app/AppInfo.mc | 2 | ||||
| -rw-r--r-- | test/golden/fr70/about.png | bin | 15278 -> 15115 bytes | |||
| -rw-r--r-- | tools/store_assets.py | 177 |
17 files changed, 292 insertions, 9 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index 731ab87..8cfa9d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,11 +6,23 @@ 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; each milestone raises the minor version. +[Semantic Versioning](https://semver.org/spec/v2.0.0.html). Versions stayed +at 0.x until milestone M8; each milestone raised the minor version. ## [Unreleased] +## [1.0.0] - 2026-10-28 + +First public release, after the second real-watch round on the Forerunner +70 (2026-10-27): one-time codes, the 4-digit PIN, touch scrolling, +`otpauth://` import and Aegis JSON import work on the watch. + +### Added + +- Connect IQ Store listing: text in `docs/STORE_LISTING.md`, and the hero, + cover and screen images in `docs/store/`, built by + `tools/store_assets.py` from the Forerunner 70 goldens. + ### Fixed - On touch screens, START no longer submits a PIN set by 0.8.0 or earlier: @@ -49,9 +61,10 @@ All notable changes to this project are documented here. The format follows ### Documentation -- Settings spike test C, split (`docs/SETTINGS_SPIKE.md`): every character - group and a complete unencrypted Aegis export save in Garmin Connect, so - Aegis JSON import works as designed. +- Settings spike test C, split (`docs/SETTINGS_SPIKE.md`): Garmin Connect + refuses only the backslash `\`. Aegis JSON without backslashes saves, and + a real Aegis export was imported on the Forerunner 70. The user guide + explains the `\/` workaround for Aegis exports with icons. ## [0.9.0] - 2026-10-25 @@ -6,7 +6,9 @@ A Garmin Connect IQ watch app that shows one-time passwords (TOTP, HOTP, and optional Steam Guard codes) and stores barcodes and QR codes (loyalty cards, tickets) to show at a scanner. Everything lives encrypted on the watch. There is no cloud, no backend and no analytics, and the app requests no permissions, so it cannot use the network. -> **Status: M8 (polish, performance, release candidate) done in Docker as version 0.8.0; 1.0.0 is tagged after your on-watch checklist.** The plan below was approved on 2026-10-18. M0 built the Docker toolchain and pipeline, M2 the crypto and OTP core, M3 the encrypted vault ([docs/SECURITY.md](docs/SECURITY.md)), M4 the screens, M5 import (`otpauth://` and Aegis JSON via the phone settings), M6 all 12 barcode symbologies and M7 the phone settings forms. M8 added: +> **Status: 1.0.0 released (2026-10-28)** after two real-watch rounds on the Forerunner 70 ([docs/TESTING.md](docs/TESTING.md#results)). The Store listing text and images are in [docs/STORE_LISTING.md](docs/STORE_LISTING.md). The history below is kept as written at M8. +> +> **M8 (polish, performance, release candidate) done in Docker as version 0.8.0.** The plan below was approved on 2026-10-18. M0 built the Docker toolchain and pipeline, M2 the crypto and OTP core, M3 the encrypted vault ([docs/SECURITY.md](docs/SECURITY.md)), M4 the screens, M5 import (`otpauth://` and Aegis JSON via the phone settings), M6 all 12 barcode symbologies and M7 the phone settings forms. M8 added: > > - **The favourite code in the glance** (opt-in), and favourite marks in Manage entry. > - **Brightness** for barcodes: full brightness on AMOLED watches, the backlight on MIP watches, for about a minute. @@ -521,7 +523,7 @@ The full files are in `resources/settings/` (written in M7). `make lint` validat - **Fields**: the eight import fields are joined in order. If the text is longer than one field accepts, continue in the next one. - **Several syncs**: if the JSON is still incomplete after joining, the watch keeps what it got (encrypted under the device key) and reports: "Waiting for more: received 3,214 characters. Paste the next part into the import fields and turn 'Import now' on again." "Cancel pending import" discards it. The buffer is capped at 32 KB and expires after 24 hours. M5 measured an Aegis export at about 190 bytes per entry compact, and 400 to 500 pretty-printed. Exports larger than the cap need to be split into several imports. - **Size reality**: pretty-printed Aegis JSON takes about 400 to 500 characters per entry without icons. Icons add several KB each and are the main reason a paste might not fit. An `otpauth://` URI takes about 80 to 200 characters. -- **Spike results (M1, Android, 2026-10-21)**: one field carried 8000 characters and the eight together 64,000, so the 32 KB buffer, not Garmin Connect, limits one sync. `otpauth://` URIs with `&` save as they are. A test string mixing `< > \ " { } %`, accented letters and an emoji was refused by the phone. **Follow-up (round 2, SPIKE_DATE):** each character group saved on its own, and so did a complete unencrypted Aegis export, so Aegis JSON can be pasted into the import fields (docs/SETTINGS_SPIKE.md). +- **Spike results (M1, Android, 2026-10-21)**: one field carried 8000 characters and the eight together 64,000, so the 32 KB buffer, not Garmin Connect, limits one sync. `otpauth://` URIs with `&` save as they are. A test string mixing `< > \ " { } %`, accented letters and an emoji was refused by the phone. **Follow-up (round 2, 2026-10-27):** only the backslash `\` is refused; every other character group and a complete unencrypted Aegis export without backslashes saved, and a real Aegis export was imported on the Forerunner 70. Aegis JSON can be pasted when it has no `\` (Android Aegis writes `/` as `\/` in icons): replace `\/` with `/` or remove icons first (docs/SETTINGS_SPIKE.md). - **iPhone**: Garmin Connect on iOS reportedly rejects `&` (present in every URI with parameters) and `\` (used in JSON escapes). The spike ran on Android only. If it's confirmed on iOS, the parser also accepts `;` in place of `&`, and the user guide explains the workaround. ### 11.3 Parsing rules @@ -896,7 +898,7 @@ Done when: each symbology is either marked implemented with every check green, o - Final `docs/` and the manual test checklist. - `make ci` green; signed `.iq` and per-device `.prg` files; `CHANGELOG` 1.0.0; the final list of known limitations. - **Done when**: `make ci` is green from a clean checkout, you've completed the on-watch checklist, and 1.0.0 is tagged. -- **Status (2026-10-21)**: done in Docker as 0.8.0, the release candidate: `make ci` is green, also from a clean checkout; the signed `.iq` and the release `.prg` files are built by `make package`; the known limitations are in [docs/USER_GUIDE.md](docs/USER_GUIDE.md#known-limitations). Your on-watch checklist ([docs/TESTING.md](docs/TESTING.md#real-watch-checklist)) comes before the 1.0.0 tag. +- **Status (2026-10-21)**: done in Docker as 0.8.0, the release candidate: `make ci` is green, also from a clean checkout; the signed `.iq` and the release `.prg` files are built by `make package`; the known limitations are in [docs/USER_GUIDE.md](docs/USER_GUIDE.md#known-limitations). Your on-watch checklist ([docs/TESTING.md](docs/TESTING.md#real-watch-checklist)) came before the 1.0.0 tag. **Status (2026-10-28)**: 1.0.0 tagged after the second real-watch round (0.9.0 on the Forerunner 70) and the settings spike follow-up. ## 17. Test plan @@ -1030,6 +1032,7 @@ The simulator runs on your PC's CPU, so its timings don't represent a watch. In | `README.md` | This plan, kept up to date as the project's overview | | [`docs/FEASIBILITY.md`](docs/FEASIBILITY.md) | Phase 0 findings with sources | | `docs/YOUR_STEPS.md` | Your steps before 1.0.0: the settings spike, the on-watch tests and the results to send back | +| `docs/STORE_LISTING.md`, `docs/store/` | Connect IQ Store listing: title, description, What's New, and the hero, cover and screen images (`python3 tools/store_assets.py`) | | `docs/USER_GUIDE.md` | Install the beta, import from Aegis, add codes, PIN and locking, daily use, moving to a new watch, troubleshooting | | `docs/DEVELOPER_GUIDE.md` | Repository layout, build, test and debug in Docker, adding a symbology or a device, coding conventions | | `docs/SECURITY.md` | Threat model, crypto choices and parameters, record formats, known limitations, how to report issues | diff --git a/docs/STORE_LISTING.md b/docs/STORE_LISTING.md new file mode 100644 index 0000000..8317138 --- /dev/null +++ b/docs/STORE_LISTING.md @@ -0,0 +1,84 @@ +<!-- + Author: cirakg & claude ai + Summary: Text and images for the Connect IQ Store listing, ready to paste into the Garmin developer page. +--> +# Connect IQ Store listing + +Copy each block into the matching field of the app page on the Garmin developer site. The text inside each block is plain text: the Store shows line breaks but no Markdown. The character counts were measured when the text was written: title 32, description 2385, What's New 755. + +## Title (max. 50 characters) + +```text +OTP Wallet: 2FA Codes & Barcodes +``` + +## Description (max. 4000 characters) + +```text +Your two-factor codes and your loyalty cards on your wrist, with no phone and no network. + +OTP Wallet shows one-time passwords (TOTP, HOTP and Steam Guard) and stores barcodes and QR codes to show at a scanner. Everything is encrypted on the watch. There is no cloud, no account and no analytics, and the app requests no permissions, so it cannot use the network. + +ONE-TIME CODES +- TOTP (SHA-1, SHA-256, SHA-512; 6 to 8 digits; any period), HOTP and Steam Guard. +- A ring shows the seconds left; the next code is shown before the current one expires. +- Optional glance with your favourite code. +- Codes use the watch's clock. Keep the watch synced with your phone for the right time. + +BARCODES AND QR CODES +- QR, Aztec, Data Matrix, PDF417, Code 128, Code 39, EAN-13, EAN-8, UPC-A, UPC-E, ITF and Codabar, all drawn on the watch. +- Black on white with full brightness on AMOLED watches (backlight on MIP watches) for easy scanning. +- The watch tells you if a code is too large for its screen. + +SECURITY +- Secrets and codes are stored encrypted on the watch. +- Optional 4-digit app PIN: entries marked "Require PIN" are encrypted with a key only the PIN opens. +- Auto-lock, a growing wait after wrong PINs, and an option to erase the locked entries after 10 wrong PINs. +- A 4-digit PIN stops someone using your watch, not someone who copies its storage to a computer. +- Secrets only flow into the watch. The app never exports them. + +ADDING ENTRIES +Open the app settings in Garmin Connect on your phone: +- Paste otpauth:// links (from most authenticator apps' export or "show secret" screens). +- Or paste an unencrypted Aegis export (JSON). +- Or fill in the "Add OTP" and "Add code" forms by hand. +The watch parses the text itself and shows an import report. Changes to entries marked "Require PIN" wait on the watch until you confirm them with your PIN. + +GOOD TO KNOW +- App settings work with the Store version only. +- Garmin Connect refuses text that contains a backslash. If an Aegis export is refused, replace every \/ with / in a text editor, or use otpauth:// links. +- Encrypted Aegis exports and Google Authenticator export links are not supported. +- Keep a backup of your secrets in your phone authenticator. The watch is not a backup. + +Needs a watch with Connect IQ API 5.0 or newer and enough app memory (72 models, including Forerunner, fenix, epix, Venu and Instinct models). +``` + +## What's New (max. 4000 characters) + +```text +1.0.0: first public release. + +Tested on a Forerunner 70: one-time codes, the PIN, touch scrolling, otpauth:// import and Aegis JSON import. + +Since the beta (0.9.0): +- On touch screens, START no longer submits an old-style PIN by accident. +- "Change PIN" after an old-style PIN asks for the new PIN once, not twice. +- Entering the PIN with many waiting phone changes no longer stops the app; the changes are applied in small steps. +- Large waiting changes no longer stop the app at start. +- A replacement secret or code is checked as soon as it arrives. +- "Confirm all" and "Reject all" act only on the changes shown. +- A full storage reports "Storage full" instead of stopping the app. +- Pasting about 100 otpauth:// links at once no longer stops the app. +``` + +## Images + +Built by `python3 tools/store_assets.py` from the Forerunner 70 goldens (`test/golden/fr70/`) into [docs/store/](store/). Re-run it after the goldens change. + +| Store field | File | Size limit | +| --- | --- | --- | +| Hero image (1440 × 720) | [hero-1440x720.png](store/hero-1440x720.png) | 2048 KB | +| Cover image (500 × 500) | [cover-500.png](store/cover-500.png) | 300 KB | +| Screen images (390 × 390, round, transparent corners) | [screen-1-list.png](store/screen-1-list.png), [screen-2-otp.png](store/screen-2-otp.png), [screen-3-qr.png](store/screen-3-qr.png), [screen-4-glance.png](store/screen-4-glance.png), [screen-5-pin.png](store/screen-5-pin.png), [screen-6-pdf417.png](store/screen-6-pdf417.png), [screen-7-import.png](store/screen-7-import.png) | 150 KB each | + +All images show fake data only. diff --git a/docs/TESTING.md b/docs/TESTING.md index 6063740..14cd173 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -26,6 +26,10 @@ Test devices: the Forerunner 70 by default; with `ALL=1` (`TEST_DEVICES`): Forer ## Results +### Real-watch round 2, 2026-10-27 (version 1.0.0) + +The owner tested 0.9.0 on the Forerunner 70: `otpauth://` entries added from the phone show the right codes, the 4-digit PIN works, touch scrolling works, and an unencrypted Aegis export imported on the watch. Settings spike test C, split: only `C3` (the backslash) is refused by Garmin Connect; the rest saved ([SETTINGS_SPIKE.md](SETTINGS_SPIKE.md#follow-up-for-test-c)). The refusal is accepted as a known limitation. No new failures, so 1.0.0 is released with the fixes of the 0.9.0 review below. `make ci` is green on the Forerunner 70; the Forerunner 70 `about.png` golden shows 1.0.0. The other devices' `about.png` goldens still show 0.8.0 and are re-approved at the next `ALL=1` run. One `make ci` run failed once on `entry_ids_next` (the list scrolled one line further than the golden) and passed unchanged on the next run: a timing flake in the scripted scroll, to watch. + ### Review of 0.9.0, 2026-10-26 (unreleased) An independent review of 0.9.0's changes (b8addc8, 096b5a4, 404b505, 140a666) and a watchdog test of the PIN unlock found and fixed: diff --git a/docs/YOUR_STEPS.md b/docs/YOUR_STEPS.md index 786515d..98da404 100644 --- a/docs/YOUR_STEPS.md +++ b/docs/YOUR_STEPS.md @@ -21,6 +21,8 @@ Total time: about 2.5 to 3 hours, which can be split across days at any part bou | 7 | Uninstall check and clean-up | 10 min | | 8 | Send me the results | 5 min | +> **Done (2026-10-28).** Round 2 is recorded in [TESTING.md](TESTING.md#real-watch-round-2-2026-10-27-version-100) and 1.0.0 is released. What's left is the Store listing: [STORE_LISTING.md](STORE_LISTING.md) has the text and images to paste into the Garmin developer page, and `make package` builds `build/package/otp-wallet-1.0.0.iq` to upload. This page stays as the record of what was tested. + ## Round 2: version 0.9.0 (after your results of 2026-10-21) Your first round (parts 3 and 4, and 5.2) is recorded: the spike in [SETTINGS_SPIKE.md](SETTINGS_SPIKE.md#results), the benchmark in [PERFORMANCE.md](PERFORMANCE.md), and the fixes in the [changelog](../CHANGELOG.md) (0.9.0). Do this round next; then continue with the parts of round 1 you haven't done (5.3 onwards), on 0.9.0 instead of 0.8.0. diff --git a/docs/store/cover-500.png b/docs/store/cover-500.png Binary files differnew file mode 100644 index 0000000..4b3de22 --- /dev/null +++ b/docs/store/cover-500.png diff --git a/docs/store/hero-1440x720.png b/docs/store/hero-1440x720.png Binary files differnew file mode 100644 index 0000000..dcbf034 --- /dev/null +++ b/docs/store/hero-1440x720.png diff --git a/docs/store/screen-1-list.png b/docs/store/screen-1-list.png Binary files differnew file mode 100644 index 0000000..ad77a2f --- /dev/null +++ b/docs/store/screen-1-list.png diff --git a/docs/store/screen-2-otp.png b/docs/store/screen-2-otp.png Binary files differnew file mode 100644 index 0000000..9c65987 --- /dev/null +++ b/docs/store/screen-2-otp.png diff --git a/docs/store/screen-3-qr.png b/docs/store/screen-3-qr.png Binary files differnew file mode 100644 index 0000000..472d714 --- /dev/null +++ b/docs/store/screen-3-qr.png diff --git a/docs/store/screen-4-glance.png b/docs/store/screen-4-glance.png Binary files differnew file mode 100644 index 0000000..c81a534 --- /dev/null +++ b/docs/store/screen-4-glance.png diff --git a/docs/store/screen-5-pin.png b/docs/store/screen-5-pin.png Binary files differnew file mode 100644 index 0000000..7d6ff00 --- /dev/null +++ b/docs/store/screen-5-pin.png diff --git a/docs/store/screen-6-pdf417.png b/docs/store/screen-6-pdf417.png Binary files differnew file mode 100644 index 0000000..e11db60 --- /dev/null +++ b/docs/store/screen-6-pdf417.png diff --git a/docs/store/screen-7-import.png b/docs/store/screen-7-import.png Binary files differnew file mode 100644 index 0000000..b5f81fb --- /dev/null +++ b/docs/store/screen-7-import.png diff --git a/source/app/AppInfo.mc b/source/app/AppInfo.mc index 1e6fe3c..136160f 100644 --- a/source/app/AppInfo.mc +++ b/source/app/AppInfo.mc @@ -12,5 +12,5 @@ import Toybox.Lang; module AppInfo { // Semantic version of the app: MAJOR.MINOR.PATCH. - const VERSION = "0.9.0"; + const VERSION = "1.0.0"; } diff --git a/test/golden/fr70/about.png b/test/golden/fr70/about.png Binary files differindex 7636e45..e163b67 100644 --- a/test/golden/fr70/about.png +++ b/test/golden/fr70/about.png diff --git a/tools/store_assets.py b/tools/store_assets.py new file mode 100644 index 0000000..be7ae9a --- /dev/null +++ b/tools/store_assets.py @@ -0,0 +1,177 @@ +#!/usr/bin/env python3 +# Author: cirakg & claude ai +# Summary: Builds the Connect IQ Store listing images (round screens, cover, hero banner) into docs/store/. +"""Store listing assets from the fr70 golden screenshots and the launcher icon. + +Run from anywhere: python3 tools/store_assets.py. Needs Pillow only. +The launcher icon is 70 px at most, so the lock motif is redrawn here. +""" + +from __future__ import annotations + +from pathlib import Path + +from PIL import Image, ImageDraw, ImageFont +from PIL.PngImagePlugin import PngInfo + +ROOT = Path(__file__).resolve().parent.parent +GOLDEN = ROOT / "test" / "golden" / "fr70" +OUT = ROOT / "docs" / "store" + +TEAL = (0, 160, 160) +ICON_TEAL = (20, 130, 140) +WHITE = (255, 255, 255) +SS = 4 # supersampling factor + +SCREENS = [ + ("1-list", "list"), + ("2-otp", "otp"), + ("3-qr", "code_qr"), + ("4-glance", "glance_code"), + ("5-pin", "pin"), + ("6-pdf417", "code_pdf417"), + ("7-import", "import_results"), +] + +FONT_BOLD = [ + "/usr/share/fonts/TTF/Roboto-Bold.ttf", + "/usr/share/fonts/roboto/Roboto-Bold.ttf", + "/usr/share/fonts/TTF/DejaVuSans-Bold.ttf", + "/usr/share/fonts/dejavu/DejaVuSans-Bold.ttf", +] +FONT_REG = [ + "/usr/share/fonts/TTF/Roboto-Regular.ttf", + "/usr/share/fonts/roboto/Roboto-Regular.ttf", + "/usr/share/fonts/TTF/DejaVuSans.ttf", + "/usr/share/fonts/dejavu/DejaVuSans.ttf", +] + + +def font(paths: list[str], size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont: + for p in paths: + if Path(p).exists(): + return ImageFont.truetype(p, size) + return ImageFont.load_default(size) + + +def round_mask(size: int) -> Image.Image: + m = Image.new("L", (size * SS, size * SS), 0) + ImageDraw.Draw(m).ellipse((0, 0, size * SS - 1, size * SS - 1), fill=255) + return m.resize((size, size), Image.Resampling.LANCZOS) + + +def round_screen(name: str) -> Image.Image: + im = Image.open(GOLDEN / f"{name}.png").convert("RGBA") + im.putalpha(round_mask(im.width)) + return im + + +def lock_icon(size: int) -> Image.Image: + """The launcher motif (teal disc, white padlock with three dots), redrawn.""" + s = size * SS + im = Image.new("RGBA", (s, s), (0, 0, 0, 0)) + d = ImageDraw.Draw(im) + u = s / 70.0 + d.ellipse((0, 0, s - 1, s - 1), fill=ICON_TEAL) + # shackle + d.rounded_rectangle((24 * u, 12 * u, 46 * u, 40 * u), radius=11 * u, fill=WHITE) + d.rounded_rectangle((29 * u, 18 * u, 41 * u, 44 * u), radius=6 * u, fill=ICON_TEAL) + # body + d.rounded_rectangle((17 * u, 31 * u, 53 * u, 57 * u), radius=4 * u, fill=WHITE) + for cx in (26, 35, 44): + d.ellipse((cx * u - 2.4 * u, 44 * u - 2.4 * u, cx * u + 2.4 * u, 44 * u + 2.4 * u), fill=ICON_TEAL) + return im.resize((size, size), Image.Resampling.LANCZOS) + + +Font = ImageFont.FreeTypeFont | ImageFont.ImageFont + + +def centered_text(d: ImageDraw.ImageDraw, at: tuple[int, int], text: str, f: Font, fill: tuple[int, int, int]) -> None: + cx, y = at + w = d.textlength(text, font=f) + d.text((cx - w / 2, y), text, font=f, fill=fill) + + +def cover() -> Image.Image: + im = Image.new("RGB", (500, 500), (0, 0, 0)) + d = ImageDraw.Draw(im) + icon = lock_icon(300) + im.paste(icon, (100, 60), icon) + centered_text(d, (250, 392), "OTP Wallet", font(FONT_BOLD, 56), WHITE) + d.rounded_rectangle((210, 466, 290, 471), radius=2, fill=TEAL) + return im + + +def bezel_watch(name: str, size: int) -> Image.Image: + ring = 14 + total = size + 2 * ring + s = total * SS + out = Image.new("RGBA", (s, s), (0, 0, 0, 0)) + d = ImageDraw.Draw(out) + d.ellipse((0, 0, s - 1, s - 1), fill=(58, 62, 68)) + d.ellipse((4 * SS, 4 * SS, s - 4 * SS - 1, s - 4 * SS - 1), fill=(22, 24, 28)) + out = out.resize((total, total), Image.Resampling.LANCZOS) + scr = round_screen(name).resize((size, size), Image.Resampling.LANCZOS) + out.paste(scr, (ring, ring), scr) + return out + + +def hero() -> Image.Image: + w, h = 1440, 720 + # Diagonal gradient, slightly teal toward the bottom right. + grad = Image.new("RGB", (w, h)) + px = grad.load() + assert px is not None + for y in range(h): + for x in range(w): + t = x / w * 0.6 + y / h * 0.4 + px[x, y] = (int(8 + 4 * t), int(12 + 30 * t), int(16 + 34 * t)) + im = grad.convert("RGBA") + d = ImageDraw.Draw(im) + + # text, left + x0 = 100 + d.text((x0, 150), "OTP Wallet", font=font(FONT_BOLD, 96), fill=WHITE) + d.rounded_rectangle((x0, 268, x0 + 120, 274), radius=3, fill=TEAL) + d.text((x0, 300), "One-time codes and barcodes", font=font(FONT_REG, 38), fill=(220, 230, 232)) + d.text((x0, 346), "on your wrist", font=font(FONT_REG, 38), fill=(220, 230, 232)) + bf = font(FONT_REG, 32) + for i, line in enumerate( + ["TOTP, HOTP and Steam Guard", "QR, PDF417, Aztec and more barcodes", "Encrypted on the watch. No cloud."] + ): + y = 440 + i * 60 + d.ellipse((x0, y + 12, x0 + 16, y + 28), fill=TEAL) + d.text((x0 + 36, y), line, font=bf, fill=WHITE) + + # watches, right: back-left list, back-right qr, front otp + for name, size, cx, cy in (("list", 290, 930, 400), ("code_qr", 290, 1215, 330), ("otp", 360, 1075, 440)): + wt = bezel_watch(name, size) + shadow = Image.new("RGBA", wt.size, (0, 0, 0, 0)) + ImageDraw.Draw(shadow).ellipse((0, 0, wt.width - 1, wt.height - 1), fill=(0, 0, 0, 110)) + im.alpha_composite(shadow, (cx - wt.width // 2 + 8, cy - wt.height // 2 + 12)) + im.alpha_composite(wt, (cx - wt.width // 2, cy - wt.height // 2)) + return im.convert("RGB") + + +def save_png(img: Image.Image, path: Path, description: str) -> None: + # Every PNG in the repo carries Author and Description chunks (lint HDR001). + info = PngInfo() + info.add_text("Author", "cirakg & claude ai") + info.add_text("Description", description) + img.save(path, format="PNG", optimize=True, pnginfo=info) + + +def main() -> None: + OUT.mkdir(parents=True, exist_ok=True) + for tag, name in SCREENS: + description = f"Connect IQ Store screen image: {name} on the Forerunner 70." + save_png(round_screen(name), OUT / f"screen-{tag}.png", description) + save_png(cover(), OUT / "cover-500.png", "Connect IQ Store cover image, 500 x 500.") + save_png(hero(), OUT / "hero-1440x720.png", "Connect IQ Store hero image for mobile, 1440 x 720.") + for p in sorted(OUT.glob("*.png")): + with Image.open(p) as im: + print(f"{p.name}: {im.size[0]}x{im.size[1]} {p.stat().st_size} bytes") + + +if __name__ == "__main__": + main() |
