From 969e74383bfa65435999e80a93657ef8f49d0a1b Mon Sep 17 00:00:00 2001 From: kipp-ing Date: Sun, 26 Jul 2026 00:26:44 +0200 Subject: [PATCH 1/4] docs: sweep the stale claims out of docs/ and the spec statuses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Post-merge docs cleanup after the tier collapse, the German rollout, and the 1200 merge. Every finding was verified against the tree before editing, and three were refuted in the process (noted below). Docs and specs only — no Swift, no project file. The canonical map was the most wrong file. docs/spec-overview.md still described 220/800/900/1000/1100 as living on branches that no longer exist, carried three generations of stale gate counts, repeated the SKTestSession "needs the Xcode IDE" caveat that was diagnosed as a test-setup bug on 2026-07-21, and had no row at all for 1200. Issue #15 was listed as an unimplemented release blocker although it merged in PR #19. 1200 was the significant find: it shipped in PR #39 on 2026-07-22, but its spec still said "Draft" and none of its 26 tasks were ticked. 20 are now ticked against artifacts in the merged tree. T012 is genuinely open — no XCUITest asserts chrome insets across both fit modes; the test that looks like it does predates 1200 and covers Ken-Burns-on/off in landscape only. Docs that would have caused a wrong action: - next-device-session.md told the ASC day to "Purchase Pro" and "Purchase Automation". Neither product exists; both collapsed into the Supporter Unlock. - testing.md documented four accessibility identifiers that no longer exist, two renamed tests, and a copy-pasteable -only-testing: command matching nothing — the exact silent-exit-0 false green the same file warns about. - engineering-notes.md recommended the M5 simulator, whose default 26.4 runtime serves zero StoreKit products and fails all seven StoreKitClientTests. - traceability.md concluded in bold that CI has been red for months, so traceability "would still gate nothing". #20 closed on 2026-07-21. - app-store-listing.md carried a 68-character subtitle under a "30 chars max" heading with a stale "29/30 chars" count — ASC would have rejected it. privacy-policy.md is live text linked from ASC and had not been touched since 2026-07-09: it described an iPad-only Immich viewer. It now covers iPhone and Apple TV, the Apple Photos library access that shipped with 900, the iCloud/ CloudKit config sync, and App Store purchases. Data-Not-Collected posture unchanged. CLAUDE.md now heads its orchestration section with the standing 2026-07-09 Codex-disable ruling, which until now lived only in session memory while the file still read as if Codex delegation were binding. Obsolete handovers and the executed gap-closure/implementation plans got dated historical banners instead of deletion, so their provenance survives without reading as live instructions. Refuted while verifying, and therefore not applied: the audit claimed onboarding.apiKey was dead (it is live in ConnectionStepView); it attributed a calibration table to PR #34 that predates it, so the table was labelled as the older sample rather than renumbered; and specs/600-broker-setup/quickstart.md turned out not to exist at all, so the broken 006/600 link now points at spec.md with corrected SC ids. Claude-Session: https://claude.ai/code/session_01XWbnBdWdcjnCH5smD6X1Ai --- CLAUDE.md | 38 ++++++++++------- docs/app-store-listing.md | 16 ++++---- docs/automation-recipes.md | 12 +++++- docs/device-testing.md | 10 +++++ docs/engineering-notes.md | 5 ++- docs/gap-closure-plan.md | 56 ++++++++++++++++++-------- docs/handover-iphone-roundtrip.md | 30 +++++++++++--- docs/handover-live-ha-verification.md | 37 +++++++++++++---- docs/handover-release-prep.md | 8 ++-- docs/implementation-session-plan.md | 12 +++++- docs/manual-verification.md | 10 +++-- docs/next-device-session.md | 5 ++- docs/next-traceability-session.md | 29 +++++++------ docs/privacy-policy.md | 49 +++++++++++++++++++--- docs/spec-overview.md | 13 +++--- docs/spec-traceability.md | 9 +++-- docs/testing.md | 21 ++++++---- docs/traceability.md | 23 ++++++++--- docs/where-the-money-goes.md | 12 ++++-- specs/1100-purchase-gate/spec.md | 9 ++++- specs/1200-observed-fixes/spec.md | 6 ++- specs/1200-observed-fixes/tasks.md | 55 ++++++++++++++++--------- specs/220-onboarding-welcome/spec.md | 13 +++--- specs/310-slideshow-resilience/spec.md | 4 +- specs/800-app-intents/spec.md | 11 +++-- 25 files changed, 345 insertions(+), 148 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 8c438a7e..cac7b056 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -40,17 +40,24 @@ This project uses XcodeBuildMCP for builds, tests, and the simulator. singletons). - Network behind a protocol (`ImmichAPI`), so tests run without a real server (mock/stub). -## Orchestration: Claude Orchestrates, Codex Implements -A two-model workflow applies to non-trivial implementation work: +## Orchestration: Claude Orchestrates, Subagents Implement + +> ⚠️ **Codex delegation is DISABLED** (Jan's standing ruling, 2026-07-09). Do **not** start +> `codex-agent`, `/codex:rescue`, `/codex:review`, or `/codex:adversarial-review`. The Codex +> sections below are kept for the day it is re-enabled — until then, read "Codex agent" as +> "Claude subagent (the `Agent` tool)" throughout, and ignore the `codex-agent` CLI invocations. + +The orchestration shape still holds; only the implementer changed: - **Claude (you) orchestrates and judges.** Read the task, decide what to delegate, write the - briefing, review Codex's diff, own the verification gate. Write as little code yourself as + briefing, review the diff, own the verification gate. Write as little code yourself as possible — implementation is delegated. -- **Codex is the implementation army.** Codex agents (via the `codex-agent` CLI of the - `codex-orchestrator` plugin, or `/codex:rescue` for small/quick tasks) implement against a - briefing, run their own unit tests, and commit their own work. -- **Cross-model review.** `/codex:review` or `/codex:adversarial-review` for an independent - look — a different model reviews code it didn't generate itself. +- **Claude subagents are the implementation army.** Spawn them with the `Agent` tool against a + briefing; they run their own unit tests. Launch independent ones in one message so they run + concurrently. Unlike Codex they do **not** commit — you review and commit their work. +- **Cross-model review** is unavailable while Codex is off. Substitute an adversarial + verification pass: a fresh subagent whose brief is to *refute* each finding, told to treat the + original claim as a hypothesis rather than a fact. ### When to Delegate Delegate well-scoped implementation work: a feature slice, a bugfix, a refactor with a clear @@ -137,8 +144,8 @@ reading order, then read the relevant module spec under `specs/Nxx-*/spec.md`. F the full spec number (`FR-700-03`). There is no single "current plan" — each module spec is the source of truth for its area. -Active feature: `1100-purchase-gate` (branch `1100-purchase-gate`, cut from `main` 2026-07-19) — -spec at `specs/1100-purchase-gate/spec.md`, current plan at `specs/1100-purchase-gate/plan.md` +Release-blocking feature: `1100-purchase-gate` — **merged to `main`**; the branch is gone. Spec at +`specs/1100-purchase-gate/spec.md`, plan at `specs/1100-purchase-gate/plan.md` (+ research/data-model/contracts/quickstart, 2026-07-19). Purchase gate / one-time unlocks: the free core stays whole (all sources + full core playback, basic transitions); one paid **Supporter Unlock** grants everything gated — ambience (**Ken Burns @@ -151,7 +158,7 @@ entitlement caching so unattended frames work offline indefinitely; never-claw-b (FR-1100-13); **sequencing is release-blocking: the gated build must be the first version the public ever sees — approved v1.0 build 8 stays unreleased (FR-1100-17)**. No price points anywhere in this public repo — pricing is decided in App Store Connect at submission. -**Status (2026-07-20): implemented on-branch** — new `Packages/PurchaseKit` (entitlement model, +**Status: code-complete and merged to `main`** — new `Packages/PurchaseKit` (entitlement model, `AmbienceGate` per-photo latch, `StoreClient` + host-testable resolver/cache/store, and now the **real `StoreKitClient` StoreKit 2 adapter**, `LockedRow`/`UnlockScreenView`/`TipJarView`), gates at the point of effect in both apps, Unlocks settings section (Restore + tip jar), US5 broker @@ -176,8 +183,7 @@ destination) with the ambience locked row, a Home-Assistant row (both Supporter- (`TVLockedBrokerView` masked-config banner when unentitled), and an Unlocks section (Restore + tip), reusing PurchaseKit UI via `fullScreenCover`; Apple-TV-simulator screenshot-verified under the `--uitest-entitlements` seams; the shared unlock/tip screens gained a tvOS-only opaque -backing. **Code-complete (T001–T041 done):** final gate 2026-07-20 — PurchaseKit 110 host + full -iOS XCUITest 153/0/9 (iOS 26.5, same on 18.6) green, iOS + tvOS build. **Remaining: T042 only** — +backing. **T001–T041 done. Remaining: T042 only** — the manual ASC/device day (create IAPs, sandbox purchase/restore/Family-Sharing/universal checks, release sequencing v1.0-b8-stays-unreleased → v1.1-gated-first, FR-1100-17); blocked on Jan's ASC access. Now purely ASC-gated — the StoreKitTest run came off this list on 2026-07-21. @@ -187,6 +193,8 @@ shared scoped-animation `KenBurnsMotionModifier` + `DecodedImageStore` decode-ah **merged to main + pushed (2026-07-18)**. Remaining there: real-hardware device gates (SC-1000-02/05/06/08, CloudKit-decrypt-on-tvOS proof, 24h soak), real MQTT/CloudKit, and the tvOS clock + FR-1000-10 pixel-shift — tick-list in `docs/manual-verification.md` ("FINAL DEVICE -DAY"). Earlier context: `510` clock merged; `900`/`800`/`220` merged + implemented (their device -ship-gates share that same device day); v1.0 in App Store review; `310`/`320` implemented. +DAY"). Earlier context: `510` clock merged; `900`/`800`/`220`/`310`/`320` merged (their device +ship-gates share that same device day). `1200-observed-fixes` (album no-server guidance, Ken Burns +honors Fit, battery/charging HA telemetry) merged 2026-07-22 via PR #39. v1.0 build 8 is +**approved but deliberately unreleased** — see FR-1100-17 above. diff --git a/docs/app-store-listing.md b/docs/app-store-listing.md index c9973003..f4e1c939 100644 --- a/docs/app-store-listing.md +++ b/docs/app-store-listing.md @@ -11,9 +11,10 @@ self-hosters; they distrust marketing language. Field limits noted per section. ## Subtitle (30 chars max) - Slideshow for your own server or a shared album. Ultra simple setup. + Your photos, your own frame -29/30 chars. +27/30 chars. Source-neutral on purpose — it covers an Immich server, a shared +link, and the Apple Photos / iCloud library that shipped with 900. ## Promotional text (170 chars max, changeable without review) @@ -72,8 +73,8 @@ This is an independent app. It is not affiliated with or endorsed by Immich or F ``` ~3,400/4,000 chars. The "WHAT'S INCLUDED…" section and the reworked Home Assistant -bullets landed with the 1100 purchase gate (2026-07-20); on 2026-07-22 the two tiers -(Pro + Automation) and the bundle were consolidated into a single **Supporter Unlock**, +bullets landed with the 1100 purchase gate (2026-07-20); on 2026-07-23 (`76c9b78`) the two +tiers (Pro + Automation) and the bundle were consolidated into a single **Supporter Unlock**, so this section now names one product, not three. Before the gate the listing described Ken Burns and full HA control as if they were free, which the gated build would have made untrue. No price points here on purpose: pricing is set in ASC at submission (FR-1100-06). @@ -139,15 +140,14 @@ link *is* the demo access and reviewers need no account. are preserved. ⚠️ **ASC still carries the old name** — update the App Store Connect app name (and any name-bearing subtitle) to "OwnFrame" before submission. -- **When 900 (photo-library source) ships**: subtitle could become - `OwnFrame & iCloud` (17/30 chars) and the description gets an Apple - Photos/iCloud albums section. +- **900 (photo-library source) has shipped** (merged 2026-07-18): the description already covers + the Photos library, and the subtitle was made source-neutral on 2026-07-26 for the same reason. - **What's New**: "Initial release." — no need to invent history. Note the version this ships under is **not** 1.0: approved build 1.0 (8) is deliberately never released, so the gated build is the first version the public ever sees (FR-1100-17). It still is an initial release from a user's point of view, so the copy stands; only the version number moves. - **IAP metadata in ASC**: the single **Supporter Unlock** is non-consumable and MUST have Family Sharing enabled (FR-1100-06); the tips are consumable and not family-shared. (The - earlier plan of two unlocks + a bundle was consolidated to one product on 2026-07-22.) Review + earlier plan of two unlocks + a bundle was consolidated to one product on 2026-07-23.) Review notes for the IAP itself can point at the same demo link — no purchase is needed to reach the unlock screen, only to complete a purchase. diff --git a/docs/automation-recipes.md b/docs/automation-recipes.md index 9fe76c4b..cd2c4f86 100644 --- a/docs/automation-recipes.md +++ b/docs/automation-recipes.md @@ -8,7 +8,13 @@ extra hardware: the frame schedules itself. ## What you can say / run All actions appear in the Shortcuts app automatically after installing the app -(no setup), and respond to Siri with the app name in the phrase: +(no setup), and respond to Siri with the app name in the phrase. **Running them +needs the Supporter Unlock** — the one-time purchase that also unlocks Ken Burns +motion, the clock overlay, and Home Assistant control. The actions stay visible +in Shortcuts either way, so you can build your automations first; without the +unlock they stop with a short message instead of doing anything (see *Errors you +might see* below). Everything the frame does on its own — every photo source and +the full slideshow — stays free. | Action | Siri phrase | Notes | |---|---|---| @@ -88,6 +94,10 @@ third-party intents: ## Errors you might see +- "Remote control requires the Supporter Unlock." — the frame doesn't own the + unlock yet. Open OwnFrame → Settings → Unlocks (or **Restore Purchases** if you + already bought it on another device). Your shortcut is fine as it is; it will + work as soon as the unlock is in place. - "Set up the frame first — open OwnFrame and add a source." — the app has never finished onboarding. - "OwnFrame must be open on the frame device for this." — the app wasn't diff --git a/docs/device-testing.md b/docs/device-testing.md index 7de691ce..adc97880 100644 --- a/docs/device-testing.md +++ b/docs/device-testing.md @@ -132,6 +132,16 @@ exercising the HA contract; that is exactly what hardware is for. hactl --dir /Users/jan/dev/repos/hactl-dev/jansHA ent ls --pattern '*photo_frame*' ``` + **The `*photo_frame*` pattern is right for the existing rig and wrong for a new one.** HA + freezes an `entity_id` at first discovery, so the Framepad's entities keep the + `photo_frame_slideshow_*` slugs minted under the pre-OwnFrame device name; renaming the frame + changes only the display name (FR-700-22), and the identity survives delete+reinstall (#15), + so those slugs will not change on their own. A **freshly configured** frame registers under + today's default name `OwnFrame` (`OwnFrame (Apple TV)` on tvOS) and slugs to `*ownframe*` — + match on that instead. Broker-side topic greps are unaffected by either: the root is + `ownframe/` for every frame since the `immichslideshow/` → `ownframe/` rename + (2026-07-22). + `hactl` needs no MQTT credentials. Broker: `home.kippings.de:8883`, user `car`, `--cafile /etc/ssl/cert.pem` (publicly-trusted ZeroSSL chain, no TLS exception). A lone `Connection Refused: not authorised` is **transient — retry** before concluding the diff --git a/docs/engineering-notes.md b/docs/engineering-notes.md index 87a3eb3e..af55baed 100644 --- a/docs/engineering-notes.md +++ b/docs/engineering-notes.md @@ -63,7 +63,10 @@ Editing `project.pbxproj` by hand is brittle — **prefer to avoid it.** (the `mcp` subcommand). Without it the server prints usage and the client gets `-32000`. Run `/mcp` after a fresh start to confirm the connection. - **`preferXcodebuild: true`** for `build_sim`/`test_sim` — the incremental builder - (xcodemake) chokes on project changes. Default sim: **iPad Pro 11" (M5)**. + (xcodemake) chokes on project changes. Default sim: **iPad Pro 11-inch (M4)** — pin it by + `simulatorId`, not by name. An unpinned "iPad Pro 11"" resolves to the **M5**, whose default + **iOS 26.4** runtime serves 0 StoreKit products and fails all 7 `StoreKitClientTests`; see + [testing.md](testing.md#known-traps--false-greens-flakes-and-landmines). - **No `axe`/`idb`** installed → MCP UI automation is read-only (`snapshot_ui`/`screenshot`). Drive UI through XCUITest instead. - **`cd` in a Bash tool call drifts the working directory** for later calls. Use diff --git a/docs/gap-closure-plan.md b/docs/gap-closure-plan.md index 73102373..9bc0dfb3 100644 --- a/docs/gap-closure-plan.md +++ b/docs/gap-closure-plan.md @@ -1,5 +1,14 @@ # Gap-Closure Plan +> **Historical / superseded as of 2026-07-26.** This captured the state on 2026-07-19 and is kept +> for the sequencing provenance only. Much of what it lists as open has since shipped — the `1100` +> purchase gate is specced, implemented and merged (one **Supporter Unlock**, not the two tiers +> proposed below, with a real StoreKit 2 adapter in `Packages/PurchaseKit`; only the manual App +> Store Connect day, `1100/T042`, is left), German localization shipped 2026-07-23 across iOS + +> tvOS via the String Catalogs, and `docs/app-store-listing.md` carries the unlock copy. Do not +> read the open checkboxes below as current work: start from +> [`spec-overview.md`](spec-overview.md) and the module spec under `specs/Nxx-*/`. + **Written**: 2026-07-19. **Scope**: everything known-but-unfinished *except* the Apple TV / tvOS work (topic `1000`), which is tracked separately — see [`specs/1000-apple-tv/tasks.md`](../specs/1000-apple-tv/tasks.md) and the FINAL DEVICE DAY @@ -45,31 +54,42 @@ Both are single points of failure. Neither takes long. ## 1. Critical path to first public release -Only two things block shipping publicly. Everything in sections 2–5 is post-release. +Two things blocked shipping publicly when this was written. §1a has since been built and merged — +only its manual App Store Connect day (`1100/T042`) is left — so what remains is execution: that +day plus the device day in §1b. Everything in sections 2–5 is post-release. ### 1a. Monetization → new spec `1100` Next free hundreds-block (`1000` is taken). This is a new module — a new gating seam that every paid capability area reads — not an amendment. -**Current state**: no spec, no plan, no tasks, **no StoreKit code of any kind** (no `StoreKit` -import, no `.storekit` config, no IAP entitlement). `docs/app-store-listing.md` has no price or -IAP section and reads as a free app throughout. The legal half of the plan *did* ship — `LICENSE` +**State on 2026-07-19**: no spec, no plan, no tasks, **no StoreKit code of any kind** (no +`StoreKit` import, no `.storekit` config, no IAP entitlement). `docs/app-store-listing.md` had no +IAP section and read as a free app throughout. The legal half of the plan *did* ship — `LICENSE` is FSL-1.1-MIT and the README/listing carry the Fair Source line. +**Superseded**: `specs/1100-purchase-gate/` holds spec, plan and tasks; `Packages/PurchaseKit` +ships the entitlement model and a real StoreKit 2 adapter against +`OwnFrameTests/Configuration.storekit`; and the listing carries the Supporter Unlock section. + **Sequencing constraint (from the research, and the reason this is urgent):** v1.0 build 8 is approved-but-unreleased and includes HA/MQTT for free. Never claw back a shipped-free feature — so the gated build must be the *first version the public ever sees*. Releasing b8 as-is forecloses the option. -- [ ] Spec + plan + tasks: which capability areas gate (research proposes a polish tier and an - automation tier), the purchase / restore / Family-Sharing surface, and the entitlement seam. -- [ ] Implement TDD-first behind a `PurchaseGating`-style protocol with an in-memory fake, so the - gating logic is host-testable without StoreKit — same pattern as `ImmichAPI` and - `CodeScanning`. -- [ ] Update `docs/app-store-listing.md` (price + IAP), and the live ASC description, which still - says "Open source (MIT)" after the FSL switch — it must ride along with the next version's - metadata. +- [X] ~~Spec + plan + tasks: which capability areas gate (research proposes a polish tier and an + automation tier), the purchase / restore / Family-Sharing surface, and the entitlement + seam.~~ **Done** — `specs/1100-purchase-gate/`. The two-tier shape proposed here was + **abandoned**: the polish and automation tiers and the bundle were collapsed into a single + **Supporter Unlock** on 2026-07-23 (PR #40). +- [X] ~~Implement TDD-first behind a `PurchaseGating`-style protocol with an in-memory fake, so + the gating logic is host-testable without StoreKit — same pattern as `ImmichAPI` and + `CodeScanning`.~~ **Done** — `StoreClient` + `StubStoreClient` in `Packages/PurchaseKit`, + with `StoreKitClient` as the real adapter. +- [ ] ~~Update `docs/app-store-listing.md` (price + IAP)~~ — **done**, the listing carries the + Supporter Unlock and the ASC IAP-metadata notes. Still open: the live ASC description, + which says "Open source (MIT)" after the FSL switch — it must ride along with the next + version's metadata (part of `1100/T042`). ### 1b. The shared device day @@ -122,8 +142,11 @@ Roughly by value. All need SDD artifacts before any code. - [ ] **`730` HA presence-driven sleep/wake** — number already reserved in three places (`spec-overview.md`, `400/spec.md:97`, `700/spec.md:86`) with acceptance criteria written. Only needs a directory when scheduled. -- [ ] **German localization (FR-300-30)** — `300/spec.md:191` requires each roadmap item to be - scheduled as its own Spec Kit feature. Translation pass over the existing string catalogs. +- [X] ~~**German localization (FR-300-30)** — `300/spec.md:191` requires each roadmap item to be + scheduled as its own Spec Kit feature. Translation pass over the existing string + catalogs.~~ **Shipped 2026-07-23** across iOS + tvOS via the String Catalogs (PR #40); + `300/spec.md` carries the amendment. Repo policy is unchanged — Swift source, comments, + specs and docs stay English, and German lives only in the catalogs. - [ ] **Pre-explain permission prompts** — amendment to `220` (`220/spec.md:257`). Low-priority polish that directly serves the ease-of-use goal. @@ -131,8 +154,9 @@ Roughly by value. All need SDD artifacts before any code. ## 4. Docs debt -- [ ] `spec-traceability.md` has **no sections for 110, 120, 210, or 710**, all shipped. (1000's - is owned by task `1000/T025`.) No task owns these four. +- [ ] `spec-traceability.md` has **no sections for 110, 210, or 710**, all shipped — `120` gained + one on 2026-07-19 (FR-120-12 only), after this line was written. (1000's is owned by task + `1000/T025`; `1100` has no section either.) No task owns the rest. - [ ] The traceability tables still mark FR-300-08, FR-300-29 and others `missing` although they shipped as 320/510 — the header now warns about this, but the rows themselves are stale. diff --git a/docs/handover-iphone-roundtrip.md b/docs/handover-iphone-roundtrip.md index abaa83d4..b7a133f4 100644 --- a/docs/handover-iphone-roundtrip.md +++ b/docs/handover-iphone-roundtrip.md @@ -1,5 +1,15 @@ # Handover — iPhone Roundtrip (last one before Submit) +> **Historical as of 2026-07-26.** This captured the state on 2026-07-11 and is kept for the +> device-matrix / screenshot / ASC-upload provenance only. It is **not** a session entry point: +> §5's roundtrip is done (see the status block directly below), and everything its closing +> "Deferred after release" note lists has since shipped — `800-app-intents`, +> `900-photo-library-source`, `1000-apple-tv`, the `510` clock overlay, and the German +> localization (topic 300, 2026-07-23). The build staged in ASC is now **v1.0 (8)**, approved +> and **deliberately unreleased**: the purchase-gated build must be the first version the public +> ever sees (FR-1100-17). Do not read the roadmap parts as current: start from +> `docs/spec-overview.md` and the module spec under `specs/Nxx-*/`. + > **STATUS 2026-07-11 (late): the §5 extreme-device roundtrip is DONE — one real bug found > and fixed, build 1.0 (5) uploaded and selected.** > All four matrix devices ran the full suite + live noob smoke (all frames eyeballed): @@ -35,8 +45,9 @@ > **Next session:** the extreme-device noob roundtrip (§5) — iOS 17.5/18.6 + smallest > screens, same live-smoke method that caught the two chrome bugs. -State as of 2026-07-11. Read this first in the next session; `handover-release-prep.md` is -historical — everything in it is done. +State as of 2026-07-11 (`handover-release-prep.md` was already historical then). Everything from +here down is the record of that session — sections 1–3 and 5 are done, and the app went through +review; the build now sitting approved in ASC is **v1.0 (8)**, held back on purpose (FR-1100-17). ## Where the project stands @@ -50,8 +61,9 @@ v1.0 is **fully staged in ASC** (app id `6784154405`, version id 38 images, expires 2027-07-11. Live-validated against the exact client paths in build 3. - **7 iPad-13" screenshots** (2752×2064) uploaded to **both** locales, all COMPLETE. -**Why not submitted yet:** the app targets **iPhone too** (`TARGETED_DEVICE_FAMILY = "1,2"`, -all four orientations) and **iPhone has never been tested** — not the suite, not manually. +**Why not submitted yet** *(as of 2026-07-11 — since resolved; the status block at the top is +the outcome)*: the app targets **iPhone too** (`TARGETED_DEVICE_FAMILY = "1,2"`, all four +orientations) and iPhone had not been tested at that point — neither the suite nor manually. One more roundtrip: test on iPhone → fix what surfaces → iPhone screenshots → ASC upload → then Jan's three human clicks (privacy label, age rating, Submit). @@ -124,7 +136,10 @@ smaller phones by auto-scaling). Privacy label "Data Not Collected" → age rating confirm (all-NONE = 4+) → **Submit for Review**. What's New is N/A for a first version. -## 5. Next session — extreme-device noob roundtrip (iOS 17/18 floor + newest) +## 5. Extreme-device noob roundtrip (iOS 17/18 floor + newest) — **DONE 2026-07-11** + +Ran as planned below; the outcome (one real bug found and fixed, build bumped to 1.0 (5)) is in +the status block at the top of this file. The plan text is kept as the recipe, not as a to-do. **Why:** every test so far ran on iOS 26.5 flagships. The iOS 17 floor (View+Compat shims, no Liquid Glass pre-26) is a **different rendering and interaction path that has never been @@ -181,4 +196,7 @@ build 4's upload is pending anyway. app installed with the demo-link source — reset to German only if Jan asks. The iPhone 26.5 sim `82562538-…` is English too (keeps a German QWERTZ keyboard; the capture rig dismisses the keyboard, so it doesn't matter). -- Deferred after release: `800-app-intents` → `900-photo-library-source`; clock overlay. +- ~~Deferred after release: `800-app-intents` → `900-photo-library-source`; clock overlay.~~ + **All three shipped and are merged to main** (800 + 900 in July 2026, the `510` clock overlay + on 2026-07-18) — as did `1000-apple-tv` and the German localization (topic 300, 2026-07-23). + See `docs/spec-overview.md`. diff --git a/docs/handover-live-ha-verification.md b/docs/handover-live-ha-verification.md index 7df0600d..0c1bbd4f 100644 --- a/docs/handover-live-ha-verification.md +++ b/docs/handover-live-ha-verification.md @@ -1,10 +1,20 @@ # Handover — Live Home Assistant Verification, Session 2 +> **Historical as of 2026-07-26.** This captured the state on 2026-07-05 and is kept for the +> bug write-ups and the live-rig recipes ("How to resume") only. The work it hands over is +> **finished**: session 2 closed the whole 710 checklist live on 2026-07-08 — see +> `docs/manual-verification.md`, "Topic 710", which is the current record. Its session fixes are +> committed and on main (`c179840`, 2026-07-06) and the app has been renamed to **OwnFrame** since +> (2026-07-22, PR #37), so the MQTT topic root is `ownframe/` and every entity id below carries +> the old slug. Do not read the "Open / unresolved" section as a to-do: start from +> `docs/spec-overview.md` and the module spec under `specs/Nxx-*/`. + Written 2026-07-05 at the end of the **first** live-verification session (the previous version of this file was the entry point *into* that session; this version supersedes it for the next one). Session 1 got the app connected to a real broker + real HA instance for the first time, found and -fixed 4 real bugs via TDD, and got most of the 700+710 checklist passing live. **Not finished** — -pick up with the "Open / unresolved" section below. +fixed 4 real bugs via TDD, and got most of the 700+710 checklist passing live. It was not finished +at the time of writing — session 2 (2026-07-08) picked up the "Open / unresolved" section below +and cleared all of it. ## Where things stand @@ -62,11 +72,18 @@ pick up with the "Open / unresolved" section below. not a bug — needs its own spec/plan + TDD implementation as a follow-up. Do not attempt it as part of a "live verification" session again; it needs the normal SDD workflow. -## Open / unresolved — pick up here +## Open / unresolved as of 2026-07-05 — **all cleared on 2026-07-08** + +Session 2 worked through every item below and passed all 7 checklist points live; the `next` / +`current_photo` item turned out to be a false alarm (now pinned by a committed host +characterization test). The authoritative record is `docs/manual-verification.md`, "Topic 710". +Kept here for the investigation trail only. - **`next` button / `current_photo` sensor did not update.** Pressed "next" via HA - (`button.press` on `button.immich_slideshow_slideshow_next`); `sensor.immich_slideshow_ - slideshow_current_photo` kept the exact same asset ID and `last_changed` timestamp, despite + (`button.press` on `button.immich_slideshow_slideshow_next` — pre-rename slug; today's device + is "OwnFrame", so the ids read `button.ownframe_slideshow_next` / + `sensor.ownframe_slideshow_current_photo`); the current-photo sensor + kept the exact same asset ID and `last_changed` timestamp, despite `photo_count=45`, `phase=playing`, `playback=on` (so it's not an empty-album or paused-state issue). **We stopped mid-investigation** — the very next step is confirming whether the photo actually advanced *on the iPad's screen* when "next" was pressed: @@ -92,11 +109,14 @@ pick up with the "Open / unresolved" section below. - Once all of the above pass: tick `docs/manual-verification.md` (700's T019/T023/T027 + the 710 section), remove the "live Home-Assistant confirmation is still pending" line from the README banner (per the original handover's closing instructions), and reconsider tagging the first - release (version is still 1.0 (1)). + release. **All three done** (the version was 1.0 (1) when this was written; the build approved + in ASC and deliberately held back is now **v1.0 (8)**, FR-1100-17). -## Repo state — uncommitted +## Repo state — uncommitted *(historical; long since committed)* -Nothing from this session has been committed. `git status --short`: +The session ended with the changes below unstaged. They were committed as `c179840` (2026-07-06) +and are on main — this listing is dead, kept only to show what the session touched. +`git status --short` at the time: ``` M OwnFrame/OwnFrameApp.swift @@ -118,6 +138,7 @@ something we edited, safe to include, nothing to review there. All 4 fixes are covered by tests and green (80/80 `HAControlKit`, 67/67 full simulator suite as of the last full run this session). Suggest committing (one commit per fix, or logically grouped) at the start of next session before continuing — ask the user first, per normal workflow. +*(Done: committed as `c179840` on 2026-07-06; on main.)* ## How to resume diff --git a/docs/handover-release-prep.md b/docs/handover-release-prep.md index 85d2e5a0..a62e4267 100644 --- a/docs/handover-release-prep.md +++ b/docs/handover-release-prep.md @@ -19,7 +19,6 @@ State as of 2026-07-09. Read this first in the next session; the previous handov - **ASC**: app id `6784154405`, state PREPARE_FOR_SUBMISSION. ⚠️ **Name/subtitle still carry the old "Photo Frame for Immich" name — update them to "OwnFrame" before submission.** description/promo/keywords are **not** yet pushed — source of truth is - description/promo/keywords are **not** yet pushed — source of truth is `docs/app-store-listing.md`. Privacy policy is live (ASC links `docs/privacy-policy.md` on GitHub; was a 404 until 2026-07-09, now fixed and agreed). Contact for anything published: **app@kipp.ing**. @@ -67,8 +66,11 @@ feature.json already points there) → then the checklist below. Before every re - [ ] **App Review demo access**: reviewers have no Immich server — provide a working demo *shared link* (password-free) in the review notes, plus one sentence on what Immich is and the naming provenance (accepted by the Immich creator) in case 5.2.1 comes up. -- [ ] The `de-DE` ASC localization exists and mirrors the English text — fine (repo policy is - English-only); just keep both locales in sync when patching. +- [ ] The `de-DE` ASC localization still only mirrors the English text. **Stale since + 2026-07-23**: the app's UI now ships German (topic 300), so that locale needs real German + listing copy — `docs/app-store-listing.md` has no German section yet. (Repo policy is + unchanged: source, specs and docs stay English; German lives in the String Catalogs, and + for the store in ASC.) ## Deferred (do not start before release) diff --git a/docs/implementation-session-plan.md b/docs/implementation-session-plan.md index 4daf53a7..f62e4963 100644 --- a/docs/implementation-session-plan.md +++ b/docs/implementation-session-plan.md @@ -1,5 +1,13 @@ # Implementation Session Plan — Roadmap Majors (900 first) +> **Historical as of 2026-07-26.** Phases 0–4 below are **executed history**, still written in +> the imperative: `900-photo-library-source`, `800-app-intents`, and `1000-apple-tv` are all +> implemented and merged to main. Kept for the delegation model and the Phase-1 leak table, which +> record how the source-protocol refactor was shaped. The one part that is still live is the +> "Ship gate" section — and it is tracked more accurately in `docs/manual-verification.md` +> ("FINAL DEVICE DAY", §C). Current truth: `docs/spec-overview.md` and the module spec under +> `specs/Nxx-*/`. The 2026-07-09 Codex ruling noted below still stands. + **Created**: 2026-07-16 · **Orchestrator**: Claude Fable (this harness) · **Implementers**: Opus subagents (Agent tool, `model: opus`) · Codex remains disabled (2026-07-09 ruling). @@ -77,10 +85,12 @@ HA select/metadata for Photos sources (FR-900-11/12, Opus slice against HAContro image-publishing opt-in copy, quality-ceiling honesty in Settings (FR-900-15). Device spot-check with real iCloud content (SC-900-02), authorization UI paths (SC-900-05). -## Ship gate (not this session) +## Ship gate (still open — tracked elsewhere) SC-900-07: US1/US2 on the newest iOS beta with a real legacy shared album + the upgraded-album vanish drill. Schedule when a 27 beta is on the test iPad. +**Tick it in `docs/manual-verification.md` ("FINAL DEVICE DAY", §C — "900 quickstart device/beta +gates"), not here** — that list is the single place device gates are tracked. --- diff --git a/docs/manual-verification.md b/docs/manual-verification.md index 520f7d30..f09c6bae 100644 --- a/docs/manual-verification.md +++ b/docs/manual-verification.md @@ -11,8 +11,10 @@ passes. Nothing here runs in CI. ## FINAL DEVICE DAY — consolidated tick-list (added 2026-07-18, everything merged to main) -Everything below is hardware-gated; all sim/host gates are green (182 SlideshowKit host tests, -iOS XCUITest 120/0/2, tvOS builds). One device day covers it. Details live in the linked specs — +Everything below is hardware-gated; all sim/host gates are green (2026-07-25, iPad Pro 11-inch +(M4) sim: PurchaseKit 106 host tests, full iOS suite 163/0/5 — the 5 skips are the +ASC-screenshot, live-smoke and 3 device-rig items; tvOS builds). One device day covers it. +Details live in the linked specs — this list is the single place to tick. ### A. Ken Burns smoothness redesign (needs only eyes + devices) @@ -109,7 +111,7 @@ account, a second device, a family member account, and ASC access. **Nothing her verified 7/7 on the iOS 18.6 sim, Framepad (17.7.10) and FramePhone (26.0.1). This is the runtime proof of the StoreKit adapter (T030); it runs in CI now, not on device day. - [ ] Products load at all (the id-drift smoke test — if this fails, re-check the ids above). -- [ ] Buy each unlock for real; the feature activates without a relaunch (SC-1100-03). +- [ ] Buy the Supporter Unlock for real; the gated features activate without a relaunch (SC-1100-03). - [ ] Buy a tip → thank-you state, and **no entitlement change whatsoever** (FR-1100-08). - [ ] Cancel mid-flow → back to the offer, no charge, no nagging follow-up prompt. - [ ] Ask-to-Buy with a child test account → pending state; approve later → the entitlement @@ -244,7 +246,7 @@ Run on the iPad simulator via XcodeBuildMCP (scheme "OwnFrame"). stored there; **no username/password**. Credentials live in the Keychain only. *(SC-003)* ### T019 — quickstart SC mapping -Confirm SC-001…SC-006 from [specs/006-broker-setup/quickstart.md](../specs/006-broker-setup/quickstart.md) +Confirm SC-600-01…SC-600-06 from [specs/600-broker-setup/spec.md](../specs/600-broker-setup/spec.md) on the simulator. The host-side criteria are already covered by `BrokerSetupKit` tests; this step is the simulator-side confirmation (form validation hints, persistence, secret boundary). US2's change/remove UI (SC-005/SC-006/FR-009) is already covered automatically by `BrokerSetupUITests`. diff --git a/docs/next-device-session.md b/docs/next-device-session.md index 9618deb7..d7d645b2 100644 --- a/docs/next-device-session.md +++ b/docs/next-device-session.md @@ -54,8 +54,9 @@ The remaining T042 work is ASC-gated only (1b below). Needs IAPs created in App Store Connect first — blocked on Jan's ASC access, so confirm that is done before planning this leg. -- [ ] Purchase Pro; verify the Ken Burns + clock gates unlock at the point of effect. -- [ ] Purchase Automation; verify HA control entities appear (telemetry → full). +- [ ] Purchase the Supporter Unlock (the single product); verify **both** capability groups + unlock at the point of effect — ambience (Ken Burns + clock overlay) *and* automation + (HA control entities go telemetry → full). - [ ] Restore on a second install; verify entitlements return. - [ ] Family Sharing; universal purchase (iOS purchase unlocks tvOS). - [ ] **Never-claw-back (FR-1100-13)**: with the frame offline for a long period, entitlements diff --git a/docs/next-traceability-session.md b/docs/next-traceability-session.md index fa17705f..1cb55726 100644 --- a/docs/next-traceability-session.md +++ b/docs/next-traceability-session.md @@ -8,9 +8,11 @@ this file is only the current state and the next moves. > 65 → 196; 24 of 448 verdicts refuted and stripped; the audit stage fired for the first time > (8 rulings, 4 upheld additions adopted). Findings filed as issues #28–#33 — six more of the > #26 shape. The per-requirement gap backlog lives in the seven commit messages on that PR. -> What remains from this file: the "Do not do these unattended" list below (#21, #22, #26, -> test repair — now joined by #28–#33) and the write-a-test gap backlog. The pipeline itself -> is done; do not re-run it wholesale. +> What remains from this file (as of 2026-07-26): from the "Do not do these unattended" list +> below, only **test repair** — #21, #22 and #26 were fixed in PRs #35/#36, as were #28, #30 and +> #31 of the new batch. Still open are **#29, #32 and #33**, all tvOS-side and therefore deferred +> under the iOS-first policy. The write-a-test gap backlog also stands. The pipeline itself is +> done; do not re-run it wholesale. ## Where things stand @@ -78,9 +80,9 @@ these entries are much larger, so budget above that, not below. **Dead wiring.** The single most valuable output so far was not a tag — it was issue **#26**: FR-510-03 requires the random clock to "never land on the caption's place". The picker honours an -`occupied` set and has a green test for it, but `SlideshowView.relocateRandomClockIfNeeded()` -passes `occupied: []`, hardcoded. The feature does not work in the shipping app, and every test -passes. +`occupied` set and had a green test for it, but `SlideshowView.relocateRandomClockIfNeeded()` +passed `occupied: []`, hardcoded. The feature did not work in the shipping app, and every test +passed. (Fixed in PR #35; kept here because the *shape* is the point.) That is the shape to hunt: **a correct component, a green component test, and an app that does not wire it.** All three verifier prompts now instruct agents to follow app-behaviour @@ -123,15 +125,16 @@ naming the layer that would own one. Tags say where you are exposed; gaps say wh Real logic changes need review; a comment-only backfill does not. Leave these for a session with a human in the loop: -- **#26** — FR-510-03 dead wiring. Pass the caption's place into +- ~~**#26** — FR-510-03 dead wiring. Pass the caption's place into `relocateRandomClockIfNeeded()`. Test the *wiring*, not the picker; the picker already has a - green test and it did not help. -- **#21** — `BrokerSetupUITests` fails on a clean simulator on stock `main` (`broker.username` - never appears). Suspect the 1100 free-telemetry rework (T046–T048) moved the field or changed - its gate state. -- **#22** — `ShareSheetIncomingUITests` is order-dependent: passes alone, fails in the full + green test and it did not help.~~ **Closed in PR #35.** +- ~~**#21** — `BrokerSetupUITests` fails on a clean simulator on stock `main` (`broker.username` + never appears).~~ **Closed in PR #36.** The suspicion recorded here — that the 1100 + free-telemetry rework (T046–T048) moved the field or changed its gate state — was wrong: the + field was merely off-screen, and the fix scrolls it into view. +- ~~**#22** — `ShareSheetIncomingUITests` is order-dependent: passes alone, fails in the full suite, reading `https://host/s/slug` where it expects `https://demo.example.com/s/abc123`. - Grep that literal to find the leaking test. + Grep that literal to find the leaking test.~~ **Closed in PR #36.** - **Test repair** — the `FR-600-02` class: tests that pass while proving nothing (replacing `validate()` with `return nil` keeps it green). Strengthening those changes assertions, not comments, and belongs in its own reviewed run. diff --git a/docs/privacy-policy.md b/docs/privacy-policy.md index 5af8795f..d1beab90 100644 --- a/docs/privacy-policy.md +++ b/docs/privacy-policy.md @@ -1,9 +1,10 @@ # Privacy Policy — OwnFrame -Last updated: 2026-07-09 +Last updated: 2026-07-26 -OwnFrame is an iPad app that displays photos from an Immich server you control. -This policy is short because the app collects nothing. +OwnFrame turns an iPhone, iPad, or Apple TV into a full-screen photo frame. It shows photos from +an [Immich](https://immich.app) server you control, from an Immich shared link, or from your own +Apple Photos / iCloud library. This policy is short because the app collects nothing. ## What the app collects @@ -12,13 +13,16 @@ no account system. The developer never receives, sees, or stores any of your dat ## Where the app connects -The app makes network connections only to endpoints **you** configure: +The app makes network connections only to endpoints **you** configure, plus Apple services you +already use: 1. **Your Immich server** (or the server behind an Immich shared link you paste) — to load albums and photos over HTTPS. 2. **Your MQTT broker**, only if you set one up for Home Assistant control. +3. **Apple's iCloud**, only if you use the Apple TV app — see "Syncing to Apple TV" below. +4. **Apple's App Store**, only when you make a purchase or restore one — see "Purchases" below. -There are no connections to the developer or to any third party. +There are no connections to the developer or to any other third party. ## Credentials @@ -26,7 +30,7 @@ Your Immich API key, shared-link passwords, and MQTT broker credentials are stor device Keychain only. They are sent solely to the server they belong to and never appear in logs or plain-text settings. -## Photos +## Photos from your server Photos are downloaded from your server for display and kept in a bounded on-device cache. Nothing is uploaded anywhere. @@ -36,6 +40,39 @@ If you configure Home Assistant control, the app publishes the current photo's * is published only if you explicitly enable "Publish photo image to Home Assistant" — it is off by default. Both topics are sent not retained, so they don't linger on the broker. +## Photos on your device + +If you choose an album from your Apple Photos / iCloud library as a source, the app asks for +photo-library permission and reads **only** to display those photos on the frame. Your photos +are never uploaded, never sent to the developer, and never leave the device except by the two +routes you configure yourself: the Home Assistant image topic described above, if you turn it +on, and Apple's own iCloud sync, which is between you and Apple. You can revoke the permission +at any time in the system Settings. + +The camera is used for exactly one thing: scanning a QR code to read a shared link during +setup. No image from the camera is stored or transmitted. + +## Syncing to Apple TV + +The Apple TV app can pick up the configuration from your iPhone or iPad so you don't have to +type it in on a remote. This travels through **your own iCloud account**, never through the +developer: + +- Non-secret settings (server URL, chosen album, display options) go via iCloud key-value + storage. +- Secrets (API key, shared-link password, MQTT credentials) go via CloudKit **encrypted + fields**, which are end-to-end encrypted — Apple cannot read them either. + +If you are not signed in to iCloud, sync simply doesn't happen and you configure the Apple TV +directly. + +## Purchases + +The optional Supporter Unlock and the tips are one-time purchases handled entirely by Apple. +The developer receives no payment details, no card data, and no identity information — only +Apple's anonymous sales reports. Whether you own the unlock is cached on your device so an +unattended frame keeps working offline. + ## Changes Changes to this policy are visible in this repository's version history. diff --git a/docs/spec-overview.md b/docs/spec-overview.md index d90c413a..df608f22 100644 --- a/docs/spec-overview.md +++ b/docs/spec-overview.md @@ -35,7 +35,7 @@ existing module becomes a sub-spec (`N10`, `N20`, …) or amends the module spec | 130 | [immich-api-v3](../specs/130-immich-api-v3/spec.md) | ImmichClient | *(sub-spec of 100)* v3-only API baseline: album assets via metadata search, shared-link assets via `/me`, shared-link password in body, outdated-server (v<3) notice. Drops v2. | Active | | 200 | [connection-onboarding](../specs/200-connection-onboarding/spec.md) | OnboardingKit | First-run setup, in-place connection editing, and the Settings-screen structure. | Active | | 210 | [shared-link-onboarding](../specs/210-shared-link-onboarding/spec.md) | OnboardingKit | *(sub-spec of 200)* Choice-first onboarding, shared-link-only setup (no API key), iOS Share Sheet acceptance, resolve-first/password-when-needed, one searchable/subscrollable album picker shared by onboarding + Settings. | Active | -| 220 | [onboarding-welcome](../specs/220-onboarding-welcome/spec.md) | OnboardingKit | *(sub-spec of 200)* Welcome-screen overhaul: iCloud album at the top, camera QR scan for shared links, and three friction-ordered options with light decoration; reuses the 900 photoLibrary source. Camera end-to-end is a device gate. | Implemented on branch (2026-07-17); camera device gate pending; merges after 900 | +| 220 | [onboarding-welcome](../specs/220-onboarding-welcome/spec.md) | OnboardingKit | *(sub-spec of 200)* Welcome-screen overhaul: iCloud album at the top, camera QR scan for shared links, and three friction-ordered options with light decoration; reuses the 900 photoLibrary source. Camera end-to-end is a device gate. | Merged to main (2026-07-18); camera QR end-to-end (SC-220-07) still a device gate | | 300 | [slideshow](../specs/300-slideshow/spec.md) | SlideshowKit | Fullscreen playback engine + Liquid Glass UI: chrome, gestures, album browser, info. | Active | | 310 | [slideshow-resilience](../specs/310-slideshow-resilience/spec.md) | SlideshowKit | *(sub-spec of 300)* Auto-retry with backoff + periodic source refresh — unattended frame survival. | Active | | 320 | [disk-image-cache](../specs/320-disk-image-cache/spec.md) | SlideshowKit | *(sub-spec of 300)* Byte-capped disk cache + remembered source list — whole-album offline survival incl. relaunch; budget in Settings (500 MB default). | Active | @@ -43,12 +43,13 @@ existing module becomes a sub-spec (`N10`, `N20`, …) or amends the module spec | 500 | [display-options](../specs/500-display-options/spec.md) | ThemeKit | User-configurable order/duration/transition/Ken Burns/fit/quality/clock, applied live. | Active | | 510 | [clock-overlay](../specs/510-clock-overlay/spec.md) | ThemeKit + app target | *(sub-spec of 500)* Rendered clock overlay: Digits/Pill/Analog styles, six places + Random, Room/Cozy sizes, yields while chrome shows; off by default. tvOS rendering rides 1000 (FR-1000-10 pixel-shift — not started). | Implemented (iOS/iPadOS, 2026-07-18) | | 600 | [broker-setup](../specs/600-broker-setup/spec.md) | BrokerSetupKit | Enter and persist MQTT broker credentials (Keychain) so 700 has something to connect to. | Active | -| 700 | [ha-control](../specs/700-ha-control/spec.md) | HAControlKit | Remote control via MQTT/HA: availability + pause/play + brightness + album (730 deferred). **Amended 2026-07-21: frame identity** (US3, FR-700-16…22, SC-700-11…14) — identity must survive reinstall and is split from a user-editable frame name; documents a live-verified defect against FR-700-06. | Active — identity amendment **release-blocking for the first public release**, not implemented (issue #15) | +| 700 | [ha-control](../specs/700-ha-control/spec.md) | HAControlKit | Remote control via MQTT/HA: availability + pause/play + brightness + album (730 deferred). **Amended 2026-07-21: frame identity** (US3, FR-700-16…22, SC-700-11…14) — identity must survive reinstall and is split from a user-editable frame name; documents a live-verified defect against FR-700-06. | Active — identity amendment implemented + merged to main (2026-07-21, PR #19); issue #15 closed | | 710 | [ha-full-control](../specs/710-ha-full-control/spec.md) | HAControlKit | *(sub-spec of 700)* Read/set every display setting, next/previous, current-photo image + metadata, and diagnostics over MQTT. | Active | -| 800 | [app-intents](../specs/800-app-intents/spec.md) | app target (+ AppIntentsKit) | Shortcuts/Siri/personal-automation control via App Intents — second front-end to 700's command surface. | Implemented on branch (2026-07-17); device gates scheduled | -| 900 | [photo-library-source](../specs/900-photo-library-source/spec.md) | PhotoLibraryKit (new) | Apple Photos / iCloud albums (incl. Shared Albums) as a source, behind a backend-neutral source protocol. Amended 2026-07-16: full-access gate, shared-album quality ceiling, iOS 27 rebuild risk. | Implemented on branch (2026-07-16); device/beta gates scheduled | -| 1000 | [apple-tv](../specs/1000-apple-tv/spec.md) | tvOS app target (new) | Apple TV port: same packages/engine on tvOS 17+, purgeable-storage discipline, config sync (non-secrets via KVS, secrets E2E via CloudKit encrypted fields — constitution III v1.1.0), remote-first chrome, HA device parity. | In progress on branch `1000-apple-tv` (2026-07-18): **all four user stories implemented + sim-verified.** US1 (frame plays, real demo-link end-to-end), US2 (onboarding + real-source routing + KVS prefill/restore + secret hydration seam), US3 (purge-tolerance), US4 (HA adapter + coordinator with distinct identity + broker onboarding). All packages tvOS + new ConfigSyncKit; software-dim, remote chrome, FR-1000-07 bypass removed; iPad companion publishes full payload on launch/foreground; ThemeSettings Codable. iOS XCUITest 120/0/2. Ken Burns redesigned on this branch (2026-07-18 micro-judder fix): shared scoped-animation `KenBurnsMotionModifier` + `DecodedImageStore` decode-ahead — motion contract unchanged, swap decode-stalls eliminated (see 1000 tasks.md Status). Remaining (device-gated): real MQTT/CloudKit, tvOS clock + FR-1000-10 pixel-shift, real-hardware gates (SC-1000-02/05/06/08 + CloudKit-on-tvOS proof + 24h soak). | -| 1100 | [purchase-gate](../specs/1100-purchase-gate/spec.md) | app targets (package decided at plan time) | Purchase gate: free core stays whole (all sources + core playback + basic transitions); a single one-time **Supporter Unlock** grants every gated capability at once (ambience — **Ken Burns + clock**, never-publicly-shipped rule — plus HA/MQTT + App Intents); no tiers, no bundle; offline entitlement caching for unattended frames; Family Sharing + universal purchase (incl. tvOS); never-claw-back; gated build must be the **first** public release (v1.0 b8 stays unreleased). No price points in-repo by design. | Implemented on branch `1100-purchase-gate` (2026-07-20): entitlement model + all US1–US6 gates/UI + US5 broker degradation + the real StoreKit adapter + the tvOS unlock surface committed & green (PurchaseKit 110 host, full iOS suite 153/0/9; 9 skips = ASC-screenshot + live-smoke + the 7 SKTestSession cases, which skip under headless `xcodebuild` and run for real from the Xcode IDE / device). tvOS surface Apple-TV-simulator screenshot-verified. Remaining: **T042** only — ASC/device day (incl. the one IDE/device StoreKitTest run). | +| 800 | [app-intents](../specs/800-app-intents/spec.md) | app target (+ AppIntentsKit) | Shortcuts/Siri/personal-automation control via App Intents — second front-end to 700's command surface. | Merged to main (2026-07-18); device gate T029 (SC-800-02/03) pending | +| 900 | [photo-library-source](../specs/900-photo-library-source/spec.md) | PhotoLibraryKit (new) | Apple Photos / iCloud albums (incl. Shared Albums) as a source, behind a backend-neutral source protocol. Amended 2026-07-16: full-access gate, shared-album quality ceiling, iOS 27 rebuild risk. | Merged to main (2026-07-18); device/beta gates pending | +| 1000 | [apple-tv](../specs/1000-apple-tv/spec.md) | tvOS app target (new) | Apple TV port: same packages/engine on tvOS 17+, purgeable-storage discipline, config sync (non-secrets via KVS, secrets E2E via CloudKit encrypted fields — constitution III v1.1.0), remote-first chrome, HA device parity. | Merged to main (2026-07-18): **all four user stories implemented + sim-verified.** US1 (frame plays, real demo-link end-to-end), US2 (onboarding + real-source routing + KVS prefill/restore + secret hydration seam), US3 (purge-tolerance), US4 (HA adapter + coordinator with distinct identity + broker onboarding). All packages tvOS + new ConfigSyncKit; software-dim, remote chrome, FR-1000-07 bypass removed; iPad companion publishes full payload on launch/foreground; ThemeSettings Codable. Ken Burns redesigned here (2026-07-18 micro-judder fix): shared scoped-animation `KenBurnsMotionModifier` + `DecodedImageStore` decode-ahead — motion contract unchanged, swap decode-stalls eliminated (see 1000 tasks.md Status). Remaining (device-gated): real MQTT/CloudKit, tvOS clock + FR-1000-10 pixel-shift, real-hardware gates (SC-1000-02/05/06/08 + CloudKit-on-tvOS proof + 24h soak). | +| 1100 | [purchase-gate](../specs/1100-purchase-gate/spec.md) | app targets (package decided at plan time) | Purchase gate: free core stays whole (all sources + core playback + basic transitions); a single one-time **Supporter Unlock** grants every gated capability at once (ambience — **Ken Burns + clock**, never-publicly-shipped rule — plus HA/MQTT + App Intents); no tiers, no bundle; offline entitlement caching for unattended frames; Family Sharing + universal purchase (incl. tvOS); never-claw-back; gated build must be the **first** public release (v1.0 b8 stays unreleased). No price points in-repo by design. | Merged to main (2026-07-20, PR #14; tiers collapsed into the single Supporter Unlock 2026-07-23, PR #40): entitlement model + all US1–US6 gates/UI + US5 broker degradation + the real StoreKit adapter + the tvOS unlock surface green (measured 2026-07-25: PurchaseKit 106 host, full iOS suite 163/0/5; the 5 skips = ASC-screenshot + live-smoke + 3 device-rig items). tvOS surface Apple-TV-simulator screenshot-verified. Remaining: **T042** only — the manual ASC day (create the IAPs, sandbox purchase/restore/Family-Sharing/universal checks, release sequencing FR-1100-17), blocked on ASC access. | +| 1200 | [observed-fixes](../specs/1200-observed-fixes/spec.md) | OnboardingKit + app UI, SlideshowKit, HAControlKit | Work-order bundle of three fixes observed on the running frame: Album-tab no-server guidance instead of a dead-end load error (FR-210-30), Ken Burns honors the Fit setting instead of forcing Fill (FR-500-20), battery + charging as free read-only HA telemetry (FR-710-23). Defines no new durable FR-1200 IDs. | Merged to main via PR #39 (2026-07-22); live MQTT/HA + perceived-motion checks ride the device day | ## How they connect diff --git a/docs/spec-traceability.md b/docs/spec-traceability.md index aeb0b4e1..502fa45c 100644 --- a/docs/spec-traceability.md +++ b/docs/spec-traceability.md @@ -20,9 +20,10 @@ Generated for the active topic specs on 2026-06-23. This maps functional require > > The live source of truth is each `specs/Nxx-*/spec.md`; this file is a point-in-time aid. > -> **Known coverage holes in this file** (reconciliation pass, 2026-07-19): there are no sections -> for `110`, `120`, `210`, `710`, or `1000`, all of which are shipped. Adding the `1000` section is -> task 1000/T025; the other four have no owning task yet. +> **Known coverage holes in this file** (reconciliation pass, 2026-07-19; list corrected +> 2026-07-26): there are no sections for `110`, `210`, `710`, `1000`, or `1100`, all of which are +> shipped. `120` *does* have one — added 2026-07-19, after this note was written, covering +> FR-120-12 only. Adding the `1000` section is task 1000/T025; the others have no owning task yet. Status values: `covered` means existing tests exercise the requirement directly enough for the current scope, `partial` means tests cover only part of the requirement or the code path is split across app UI and package logic, and `missing` means no existing test was found or the behavior is not implemented. @@ -344,7 +345,7 @@ green. The live camera QR decode + permission prompt is a manual **device gate** | FR-220-10 | Sources land in one library (downstream/HA/App-Intent unchanged) | reuses `SourceLibrary`/`addPhotoLibrarySource`/`resolveSharedLink` (120/900 round-trip; `OwnFrameTests/HAControlRoundTripTests`) | covered | host-unit (reuse) | | FR-220-11 | No secrets; a scanned URL carries none (password still prompted) | `ScannedLinkRoutingTests` (nothing persisted on invalid; `.needsPassword` still prompts); `QRScannerView` logs no decoded URL — audit 2026-07-17 | covered | host-unit + static audit | | FR-220-12 | Scan feeds a host-testable seam (no camera in unit tests) | `ScannedShareLinkTests` + `ScannedLinkRoutingTests` drive a fake `CodeScanning` (no `AVFoundation`) | covered | host-unit | -| FR-220-13 | New user-facing strings are English-only | repo localization hook enforces English; new strings audited | covered | static | +| FR-220-13 | New user-facing strings are English-only | repo localization hook enforces English **source literals** in Swift; user-facing German ships via the String Catalogs (2026-07-23) and is not blocked by the hook; new strings audited | covered | static | | SC | Outcome (short) | Evidence | Status | |---|---|---|---| diff --git a/docs/testing.md b/docs/testing.md index 0e84b925..04818735 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -70,22 +70,29 @@ no live server, no real Keychain, fully deterministic and CI-safe. | Identifier | Element | |------------|---------| | `onboarding.serverURL` | server URL text field | - | `onboarding.server.continue` | "Weiter" button (step 1) | | `onboarding.apiKey` | API-key secure field | - | `onboarding.apiKey.connect` | "Verbinden" button (step 2) | + | `onboarding.connection.continue` | Continue — validates URL + key in one action | | `onboarding.album.` | each album row | - | `main.completed` | the post-onboarding main screen | + | `onboarding.source.continue` | Continue, once a source has been added | + | `onboarding.confirm.start` | Start, on the confirmation step | + | `slideshow.image` | the running slideshow's current image | -**Current tests** (`OwnFrameUITests/OwnFrameUITests.swift`): + Server URL and API key share **one** combined connection step — there is no separate + per-field step or per-step button. -- `testOnboardingHappyPathReachesMainScreen` — drives Server → API key → album → main screen. -- `testFreshLaunchShowsServerStep` — a fresh launch starts at step 1. +**The suite** — 25 files under `OwnFrameUITests/`, one per flow (album browser, clock overlay, +purchase gate, settings, shared links, slideshow chrome, …). The core onboarding pair lives in +`OwnFrameUITests/OwnFrameUITests.swift`: + +- `testOnboardingHappyPathReachesSlideshow` — connection (URL + key on one screen) → source + (album) → confirm → the running slideshow. +- `testFreshLaunchShowsConnectionStep` — a fresh launch starts at the combined connection step. **Run just the flow** (skips the slow launch-perf suite): ```text test_sim extraArgs: - -only-testing:OwnFrameUITests/OwnFrameUITests/testOnboardingHappyPathReachesMainScreen + -only-testing:OwnFrameUITests/OwnFrameUITests/testOnboardingHappyPathReachesSlideshow ``` **Extending it to a new flow** (e.g. SlideshowView): add accessibility identifiers diff --git a/docs/traceability.md b/docs/traceability.md index 9f3d6833..061bd6d8 100644 --- a/docs/traceability.md +++ b/docs/traceability.md @@ -97,7 +97,8 @@ Ten findings of this class are worth more than three hundred tags. ## Calibration — what a healthy run looks like -Measured over 6 modules, 2026-07-21: +Measured over the first 6 modules, 2026-07-21 — the oldest and narrowest sample; see the note +under the table before using it as the reference rate: | Module | Tags claimed | Refuted | Rate | |---|---|---|---| @@ -108,6 +109,12 @@ Measured over 6 modules, 2026-07-21: | 600-broker-setup | 14 | 2 | 14% | | 500-display-options | 25 | 9 | **36%** | +**Later runs supersede this table.** A seventh module took the observed set to +0/0/10/14/14/33/36% (recorded in +[next-traceability-session.md](next-traceability-session.md)), and the full seven-entry pipeline +run of 2026-07-22 (PR #34) refuted 24 of 448 verdicts across the remaining modules. Read the six +rows above as the original hand-checked sample, not as the current rate. + **Read the rate as a health check on the verifier, not only on the tagger.** ~10–20% is the healthy band. Near 0% across a batch means the verifier is rubber-stamping — re-read its evidence strings and check they contain real quoted assertions. Near 100% means the bar has @@ -155,9 +162,12 @@ So traceability is a **map**, not an alarm. It makes these questions answerable - which requirements are proven only by a human remembering (`manual only`) - which tests must run for a change to a given spec area -— and none of those are "did this change break something". That is CI's job, and -[#20](https://github.com/kipp-ing/OwnFrame/issues/20) records that CI has been red for -months. **Until that is fixed, 100% traceability would still gate nothing.** +— and none of those are "did this change break something". That is CI's job, and CI does it +again: [#20](https://github.com/kipp-ing/OwnFrame/issues/20) — red on `main` for months because +it was pinned to Xcode 16 — was **closed 2026-07-21**. The job now tracks macos-26/Xcode 26.x, +runs 852 host tests across 12 packages, and asserts a per-package test-count line, so a green +check cannot mean "nothing ran". **Traceability still gates nothing by itself; the alarm is CI, +and it is armed.** ## Running it @@ -192,5 +202,6 @@ in the commit message, since that is the durable part. **Budget.** The verified batch of 4 modules cost ~489k subagent tokens across 8 agents (~122k/module) — roughly double the unverified rate. Worth it for tags, which are trusted silently for a long time afterwards. Not worth it for output a human is about to read anyway. -The remaining large modules (`300`, `200`, `210`) span packages and layers and should be -budgeted higher than the observed average, not lower. +The large modules (`300`, `200`, `210`) span packages and layers. They were covered by the +2026-07-22 pipeline run (PR #34), so they are no longer outstanding; budget any repeat over +them higher than the observed average, not lower. diff --git a/docs/where-the-money-goes.md b/docs/where-the-money-goes.md index b1bc9977..efb00926 100644 --- a/docs/where-the-money-goes.md +++ b/docs/where-the-money-goes.md @@ -1,8 +1,10 @@ # Where your money goes > **English reference copy.** This is the canonical English text of the transparency -> statement. The in-app version (see "Short in-app version" below) is the string to be -> localized in a separate file; this document itself stays English-only. +> statement. The in-app version (see "Short in-app version" below) is already localized and +> lives in the String Catalogs — `OwnFrame/Localizable.xcstrings` and +> `OwnFrameTV/Localizable.xcstrings` (English + German since 2026-07-23); this document itself +> stays English-only. The Supporter Unlock in OwnFrame keeps this project alive, and I want to be straight with you about where the money goes. @@ -30,7 +32,11 @@ commitment about what I do with the proceeds — not a donation, and not a contr ## Short in-app version -Condensed copy for the Unlocks settings section (the string to be localized separately): +Condensed copy for the Unlocks settings section. It already ships localized: the shipped wording is +its own String Catalog key (`"Where your money goes: the Supporter Unlock covers the project's +running costs — …"`) in `OwnFrame/Localizable.xcstrings` and `OwnFrameTV/Localizable.xcstrings`, +with a German translation since 2026-07-23. Edit the catalogs, not this file, to change what users +read; the paragraph below is the reference the shipped string was condensed from: > The Supporter Unlock keeps this project going. It covers my costs — Apple Developer > Program, AI tools, test hardware — and everything beyond that I give to open-source diff --git a/specs/1100-purchase-gate/spec.md b/specs/1100-purchase-gate/spec.md index 09ab14e7..3d46c4dd 100644 --- a/specs/1100-purchase-gate/spec.md +++ b/specs/1100-purchase-gate/spec.md @@ -4,8 +4,13 @@ **Created**: 2026-07-19 -**Status**: Draft — amended 2026-07-19: **Ken Burns motion + clock overlay form the ambience -launch composition** (decided with Jan; possible because no version was ever publicly released, +**Status**: Code-complete + merged to main (2026-07-20, PR #14; the single-unlock collapse landed +2026-07-23 in PR #40) — T001–T041 done and green (measured 2026-07-25: PurchaseKit 106 host tests, +full iOS suite 163 passed / 0 failed / 5 skipped). **T042** — the manual App Store Connect day +(create the IAPs, sandbox purchase/restore/Family-Sharing/universal checks, release sequencing per +FR-1100-17) — is all that remains, blocked on ASC access. Amended 2026-07-19: **Ken Burns motion + +clock overlay form the ambience launch composition** (decided with Jan; possible because no +version was ever publicly released, so the never-claw-back rule does not yet bind anything). Locked-row presentation refined the same day: dimmed is fine, but locked rows must carry a lock badge and stay tappable. Amended 2026-07-20: **Home Assistant telemetry is free, only *control* is gated** — an unentitled frame diff --git a/specs/1200-observed-fixes/spec.md b/specs/1200-observed-fixes/spec.md index 0114f51d..4544aa19 100644 --- a/specs/1200-observed-fixes/spec.md +++ b/specs/1200-observed-fixes/spec.md @@ -2,7 +2,11 @@ **Feature Branch**: `1200-observed-fixes` **Created**: 2026-07-22 -**Status**: Draft +**Status**: Implemented + merged to main via PR #39 (2026-07-22) — all three fixes shipped: +`OnboardingKit.serverConfigured` + the `noServer` phase in the album picker and browser (US1), +the shared `KenBurnsFraming` framing/pan decision honored by both renderers (US2), and the +`battery`/`charging` HA entities behind `BatteryReporting` (US3, omitted on batteryless devices). +Remaining: live MQTT/HA and perceived-motion checks on the frame — device-day gates. **Input**: Three issues observed on the running frame: 1. Adding an album with no Immich server configured shows "Couldn't load albums" instead of guiding the user to add a server. 2. The Ken Burns effect ignores the **Fit** setting (it forces Fill framing). diff --git a/specs/1200-observed-fixes/tasks.md b/specs/1200-observed-fixes/tasks.md index ac0833c7..548f482a 100644 --- a/specs/1200-observed-fixes/tasks.md +++ b/specs/1200-observed-fixes/tasks.md @@ -2,6 +2,21 @@ **Feature**: `1200-observed-fixes` | **Spec**: [spec.md](./spec.md) | **Plan**: [plan.md](./plan.md) +**Status (2026-07-26)**: the feature is **merged to main via PR #39** (impl commit `4d1f3de`, +2026-07-22). All three user stories shipped; the boxes below are ticked from artifacts in the +merged tree. Two implementation deviations, both intentional: T009 landed as a new shared +`Packages/SlideshowKit/Sources/SlideshowKit/KenBurnsFraming.swift` (host-testable) instead of an +edit inside `KenBurnsMotionModifier.swift`, and T021 omits the tvOS battery entities by passing no +`BatteryReporting` source to `HAControlCoordinator` in `OwnFrameTV/TVRootView.swift` (the +coordinator skips battery entities when the source is absent) rather than by adding a +`hasBattery == false` adapter. **Genuinely open:** T012 (no XCUITest asserts chrome insets across +*both* fit modes — `SlideshowChromeUITests.testChromeInsetsStableAcrossOrientationAndKenBurns` +predates 1200 and covers landscape Ken-Burns-on/off only), T024, and T025 (conditional, no +`docs/testing.md` note was added). T001/T007/T013 were one-off process gates run during the +feature with no artifact in the repo; T022/T023 are ticked against the measured post-merge gate +(2026-07-25: host suites green, full iOS suite 163/0/5). The perceived-motion and live-MQTT +device checks are not yet listed in `docs/manual-verification.md`. + **Tests are REQUIRED** — Constitution Principle I (Test-First, NON-NEGOTIABLE): every implementation task is preceded by a demonstrably-red test. Host tests via `swift test`; app-target + UI via XcodeBuildMCP. @@ -32,11 +47,11 @@ a network/load error, instead of a single "Couldn't load albums". prompt that routes to the connection editor; with a configured-but-unreachable server it shows the retryable "Couldn't load albums" (SC-210-13). -- [ ] T002 [P] [US1] Red: host test for the "server configured" predicate in `Packages/OnboardingKit/Tests/OnboardingKitTests/` — asserts `false` when base URL and/or API key is missing, `true` only when both are present. -- [ ] T003 [US1] Add the host-testable `serverConfigured` predicate/helper (reads `ConfigStore.loadBaseURL()` presence + `KeychainAPIKeyStore.read()` presence) in `Packages/OnboardingKit/Sources/OnboardingKit/` — make T002 green. -- [ ] T004 [US1] Add a `noServer` load phase and render an "Add a server" `ContentUnavailableView` whose action opens the server-connection editor (FR-210-29) in `OwnFrame/Slideshow/SourceLibraryView.swift` (`AddAlbumPicker`), keeping the `catch` path on the existing `.failed` message. -- [ ] T005 [P] [US1] Add the new English string(s) for the no-server guidance ("Add a server or check your connection" + action label) in `OwnFrame/Localizable.xcstrings`. -- [ ] T006 [US1] Apply the same no-server vs network-error distinction in `OwnFrame/Slideshow/AlbumBrowserView.swift` (runtime album browser) so the two surfaces stay consistent (FR-210-27). +- [x] T002 [P] [US1] Red: host test for the "server configured" predicate in `Packages/OnboardingKit/Tests/OnboardingKitTests/` — asserts `false` when base URL and/or API key is missing, `true` only when both are present. +- [x] T003 [US1] Add the host-testable `serverConfigured` predicate/helper (reads `ConfigStore.loadBaseURL()` presence + `KeychainAPIKeyStore.read()` presence) in `Packages/OnboardingKit/Sources/OnboardingKit/` — make T002 green. +- [x] T004 [US1] Add a `noServer` load phase and render an "Add a server" `ContentUnavailableView` whose action opens the server-connection editor (FR-210-29) in `OwnFrame/Slideshow/SourceLibraryView.swift` (`AddAlbumPicker`), keeping the `catch` path on the existing `.failed` message. +- [x] T005 [P] [US1] Add the new English string(s) for the no-server guidance ("Add a server or check your connection" + action label) in `OwnFrame/Localizable.xcstrings`. +- [x] T006 [US1] Apply the same no-server vs network-error distinction in `OwnFrame/Slideshow/AlbumBrowserView.swift` (runtime album browser) so the two surfaces stay consistent (FR-210-27). - [ ] T007 [US1] Simulator verify via XcodeBuildMCP per `quickstart.md` Fix 1: no-server setup → add-a-server routing; configured + unreachable → retryable error. **Checkpoint**: US1 independently shippable (MVP). @@ -52,10 +67,10 @@ never switching to Fill; with fit Fill, motion is unchanged. is `0` under Fit and `basePan` under Fill (SC-500-09); chrome insets pixel-identical KB on/off in both fit modes (SC-300-13). -- [ ] T008 [P] [US2] Red: host tests in `Packages/SlideshowKit/Tests/SlideshowKitTests/` — `fillsScreen == false` when `fit == .fit` (KB on and off) and `true` when `fit == .fill`; Ken Burns pan input `== 0` under Fit, `== basePan` under Fill. -- [ ] T009 [US2] Make the Ken Burns pan input fit-aware (pan `0` under Fit; centered zoom only) in `Packages/SlideshowKit/Sources/SlideshowKit/KenBurnsMotionModifier.swift` (and any `KenBurnsDrift.swift` seam) — leave the scale envelope unchanged; make T008 green. -- [ ] T010 [US2] Remove `|| effectiveKenBurns` from `fillsScreen` and pass the fit-aware pan into `.kenBurnsMotion` in `OwnFrame/Slideshow/SlideshowView.swift`. -- [ ] T011 [P] [US2] Mirror the identical change (`fillsScreen` + fit-aware `contentMode`/pan) in `OwnFrameTV/TVSlideshowView.swift`. +- [x] T008 [P] [US2] Red: host tests in `Packages/SlideshowKit/Tests/SlideshowKitTests/` — `fillsScreen == false` when `fit == .fit` (KB on and off) and `true` when `fit == .fill`; Ken Burns pan input `== 0` under Fit, `== basePan` under Fill. +- [x] T009 [US2] Make the Ken Burns pan input fit-aware (pan `0` under Fit; centered zoom only) in `Packages/SlideshowKit/Sources/SlideshowKit/KenBurnsMotionModifier.swift` (and any `KenBurnsDrift.swift` seam) — leave the scale envelope unchanged; make T008 green. +- [x] T010 [US2] Remove `|| effectiveKenBurns` from `fillsScreen` and pass the fit-aware pan into `.kenBurnsMotion` in `OwnFrame/Slideshow/SlideshowView.swift`. +- [x] T011 [P] [US2] Mirror the identical change (`fillsScreen` + fit-aware `contentMode`/pan) in `OwnFrameTV/TVSlideshowView.swift`. - [ ] T012 [US2] Red→green UI inset regression: XCUITest asserting chrome edge insets are pixel-identical Ken-Burns-on vs off in **both** Fit and Fill, portrait + landscape (SC-300-13), in the `OwnFrame` UI test target. - [ ] T013 [US2] Build `OwnFrame` + `OwnFrameTV` via XcodeBuildMCP and run the iOS suite green; record the Framepad perceived-motion check as a manual gate (out of automated scope). @@ -72,15 +87,15 @@ telemetry; omit both on non-battery devices (tvOS). `battery`/`charging` match the contract; when `hasBattery == false` neither entity is announced; both publish in telemetry-only (unentitled) mode (SC-710-07). -- [ ] T014 [P] [US3] Red: host tests in `Packages/HAControlKit/Tests/HAControlKitTests/` — `battery` discovery has `device_class: battery`, `unit_of_measurement: "%"`, `state_class: measurement`, `entity_category: diagnostic`, no `command_topic`; `charging` is `binary_sensor` with `device_class: battery_charging` + `payload_on/off`; echo publishes `level 87 → "87"` and `isOnPower → "ON"/"OFF"`; `hasBattery == false` omits both from announce; both publish under telemetry-only mode. -- [ ] T015 [US3] Add `BatteryReading` + `BatteryReporting` protocol (`hasBattery`, `current`, change signal) in a new `Packages/HAControlKit/Sources/HAControlKit/BatteryReporting.swift` (UIKit-free). -- [ ] T016 [US3] Add `.battery` and `.charging` cases to `HAEntity` and classify them read-only (free telemetry) in `Packages/HAControlKit/Sources/HAControlKit/HAEntityState.swift`. -- [ ] T017 [US3] Map `.battery → sensor` and `.charging → binary_sensor` in `component(for:)` in `Packages/HAControlKit/Sources/HAControlKit/HATopics.swift` (adds the first `binary_sensor` component). -- [ ] T018 [US3] Add discovery fields (`device_class`, `unit_of_measurement`, `state_class`, `payload_on/off`) and human names for both entities in `Packages/HAControlKit/Sources/HAControlKit/HADiscovery.swift`. -- [ ] T019 [US3] Emit battery/charging state in `echo(_:)` and omit both entities from announce when `hasBattery == false` in `Packages/HAControlKit/Sources/HAControlKit/HAControlCoordinator.swift` — make T014 green. -- [ ] T020 [US3] Implement `BatteryReporting` over `UIDevice` (set `isBatteryMonitoringEnabled = true`, observe `batteryLevelDidChange`/`batteryStateDidChange`, `charging` ON for `.charging`/`.full`) in `OwnFrame/Slideshow/SlideshowRemoteControlAdapter.swift`, and enable monitoring + add the entities to `enabledEntities` in `OwnFrame/OwnFrameApp.swift`. -- [ ] T021 [P] [US3] tvOS adapter reports `hasBattery == false` (entities omitted) in `OwnFrameTV/TVRemoteControlAdapter.swift`. -- [ ] T022 [US3] Run `HAControlKit` host tests green and build both app targets via XcodeBuildMCP. +- [x] T014 [P] [US3] Red: host tests in `Packages/HAControlKit/Tests/HAControlKitTests/` — `battery` discovery has `device_class: battery`, `unit_of_measurement: "%"`, `state_class: measurement`, `entity_category: diagnostic`, no `command_topic`; `charging` is `binary_sensor` with `device_class: battery_charging` + `payload_on/off`; echo publishes `level 87 → "87"` and `isOnPower → "ON"/"OFF"`; `hasBattery == false` omits both from announce; both publish under telemetry-only mode. +- [x] T015 [US3] Add `BatteryReading` + `BatteryReporting` protocol (`hasBattery`, `current`, change signal) in a new `Packages/HAControlKit/Sources/HAControlKit/BatteryReporting.swift` (UIKit-free). +- [x] T016 [US3] Add `.battery` and `.charging` cases to `HAEntity` and classify them read-only (free telemetry) in `Packages/HAControlKit/Sources/HAControlKit/HAEntityState.swift`. +- [x] T017 [US3] Map `.battery → sensor` and `.charging → binary_sensor` in `component(for:)` in `Packages/HAControlKit/Sources/HAControlKit/HATopics.swift` (adds the first `binary_sensor` component). +- [x] T018 [US3] Add discovery fields (`device_class`, `unit_of_measurement`, `state_class`, `payload_on/off`) and human names for both entities in `Packages/HAControlKit/Sources/HAControlKit/HADiscovery.swift`. +- [x] T019 [US3] Emit battery/charging state in `echo(_:)` and omit both entities from announce when `hasBattery == false` in `Packages/HAControlKit/Sources/HAControlKit/HAControlCoordinator.swift` — make T014 green. +- [x] T020 [US3] Implement `BatteryReporting` over `UIDevice` (set `isBatteryMonitoringEnabled = true`, observe `batteryLevelDidChange`/`batteryStateDidChange`, `charging` ON for `.charging`/`.full`) in `OwnFrame/Slideshow/SlideshowRemoteControlAdapter.swift`, and enable monitoring + add the entities to `enabledEntities` in `OwnFrame/OwnFrameApp.swift`. +- [x] T021 [P] [US3] tvOS adapter reports `hasBattery == false` (entities omitted) in `OwnFrameTV/TVRemoteControlAdapter.swift`. +- [x] T022 [US3] Run `HAControlKit` host tests green and build both app targets via XcodeBuildMCP. **Checkpoint**: US3 independently verifiable with fakes; live MQTT/HA is a device-day gate. @@ -88,10 +103,10 @@ both publish in telemetry-only (unentitled) mode (SC-710-07). ## Phase 6: Polish & Cross-Cutting -- [ ] T023 Full sweep: host tests for `OnboardingKit` + `SlideshowKit` + `HAControlKit` green; full iOS XCUITest suite via XcodeBuildMCP green; `OwnFrame` + `OwnFrameTV` build — confirm no regressions vs the T001 baseline. +- [x] T023 Full sweep: host tests for `OnboardingKit` + `SlideshowKit` + `HAControlKit` green; full iOS XCUITest suite via XcodeBuildMCP green; `OwnFrame` + `OwnFrameTV` build — confirm no regressions vs the T001 baseline. - [ ] T024 [P] Run `/speckit-analyze` for cross-artifact consistency (spec ↔ plan ↔ tasks ↔ amended module FRs 210/500/300/710). - [ ] T025 [P] If new reusable test seams warrant it, note the battery fake, the `fillsScreen`-honors-Fit case, and the no-server predicate in `docs/testing.md`. -- [ ] T026 Verify no secrets on the touched surfaces (Fix 1 reads only key *presence*; Fixes 2/3 touch none) in code/UserDefaults/logs; commit the branch with the spec + plan + implementation. +- [x] T026 Verify no secrets on the touched surfaces (Fix 1 reads only key *presence*; Fixes 2/3 touch none) in code/UserDefaults/logs; commit the branch with the spec + plan + implementation. --- diff --git a/specs/220-onboarding-welcome/spec.md b/specs/220-onboarding-welcome/spec.md index 3afad0f1..3c4408b2 100644 --- a/specs/220-onboarding-welcome/spec.md +++ b/specs/220-onboarding-welcome/spec.md @@ -4,13 +4,12 @@ **Created**: 2026-07-17 -**Status**: Implemented on branch (2026-07-17) — *(sub-spec of 200 connection-onboarding)*. -Specced 2026-07-17; branch `220-onboarding-welcome` cut from the `900-photo-library-source` tip -(this feature surfaces the 900 iCloud/Photos source, so it needs that code). Host + UI-sim gates -are green (OnboardingKit host suites; `WelcomeICloudUITests` and the extended onboarding UITests; -full XCUITest suite before merge); the camera QR end-to-end + camera-denied fallback is a manual -device gate (SC-220-07), riding the 900/800 device day. Cannot merge ahead of 900 (depends on the -photoLibrary source). +**Status**: Implemented + merged to main (2026-07-18, after 900) — *(sub-spec of 200 +connection-onboarding)*. Specced 2026-07-17; branch `220-onboarding-welcome` was cut from the +`900-photo-library-source` tip (this feature surfaces the 900 iCloud/Photos source, so it needs +that code). Host + UI-sim gates are green (OnboardingKit host suites; `WelcomeICloudUITests` and +the extended onboarding UITests; full XCUITest suite before merge); the camera QR end-to-end + +camera-denied fallback remains a manual device gate (SC-220-07), riding the 900/800 device day. **Input**: User description: "Initial-onboarding gap — no iCloud on the welcome screen. Overhaul the first-run welcome into an explaining, noob-welcoming screen offering, in friction order: an diff --git a/specs/310-slideshow-resilience/spec.md b/specs/310-slideshow-resilience/spec.md index 39f7515a..87ce5e28 100644 --- a/specs/310-slideshow-resilience/spec.md +++ b/specs/310-slideshow-resilience/spec.md @@ -4,8 +4,8 @@ **Created**: 2026-07-09 -**Status**: Implemented (2026-07-09, branch `310-slideshow-resilience`) — was the pre-release -gate before the App Store release. FR→test mapping in `docs/spec-traceability.md` (310 section). +**Status**: Implemented + merged to main (2026-07-09) — was the pre-release gate before the App +Store release. FR→test mapping in `docs/spec-traceability.md` (310 section). **Input**: Sub-spec of `specs/300-slideshow`. A photo frame runs unattended for weeks: it must survive network loss without anyone touching it, and newly added photos must enter rotation diff --git a/specs/800-app-intents/spec.md b/specs/800-app-intents/spec.md index 57155177..1e5b58e3 100644 --- a/specs/800-app-intents/spec.md +++ b/specs/800-app-intents/spec.md @@ -4,12 +4,11 @@ **Created**: 2026-07-09 -**Status**: Implemented on branch `800-app-intents` (2026-07-17) — T001–T028 complete; -all automated gates green (40 AppIntentsKit tests, 13 `FrameIntentGlueTests`, -`build_sim` clean, full XCUITest suite 108 passed / 0 failed / 2 intentional skips). -Remaining before ship: T029 only — the quickstart manual device checklist (SC-800-02 -overnight automation, SC-800-03 Siri/Shortcuts discovery, honesty/edge drills) on the -real frame iPad — then merge. +**Status**: Implemented + merged to main (2026-07-18) — T001–T028 complete; all automated gates +green at merge time (40 AppIntentsKit tests, 13 `FrameIntentGlueTests`, `build_sim` clean, full +XCUITest suite 108 passed / 0 failed / 2 intentional skips — re-measure before quoting these +counts). Remaining: T029 only — the quickstart manual device checklist (SC-800-02 overnight +automation, SC-800-03 Siri/Shortcuts discovery, honesty/edge drills) on the real frame iPad. **Input**: New module (next free hundreds-block). Expose the slideshow's existing remote-control command surface as **App Intents**, so Shortcuts, Siri, and on-device personal automations can From 1eb4775796556e0b1810fadbc8d29d0f25547908 Mon Sep 17 00:00:00 2001 From: kipp-ing Date: Sun, 26 Jul 2026 00:43:43 +0200 Subject: [PATCH 2/4] docs(asc): write the German listing copy and push both locales MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The de-DE App Store localization had been mirroring the English text since the app shipped German (topic 300, 2026-07-23). It now has real German copy — subtitle, promotional text, description, keywords, What's New — written for German rather than translated, with terminology matched to the shipped String Catalogs (Diashow, Quelle, geteilter Link, Fotos-Mediathek, Supporter-Freischaltung, Uhr-Einblendung, Kurzbefehle) and the same informal "du" the UI uses. Pushing it surfaced that en-US was equally stale on the 1.1 version record: the description still opened with "Photo Frame for Immich turns an iPad…", the subtitle was the pre-2026-07-26 "Slideshow for your own server", and promo text and What's New were empty. Both locales were pushed from this doc, so the doc is now byte-identical to what ASC holds. Keywords in both locales get "immich" (and "server") back. They used to ride on the old subtitle; the source-neutral rewrite dropped both words, which would have left an Immich client unfindable under "immich". "self-hosted" came out to make room — the tokenizer splits it anyway and "selfhosted" covers the rest. Verified by reading all ten fields back from the API after the write. Claude-Session: https://claude.ai/code/session_01XWbnBdWdcjnCH5smD6X1Ai --- docs/app-store-listing.md | 107 ++++++++++++++++++++++++++++++++-- docs/handover-release-prep.md | 30 ++++++---- 2 files changed, 119 insertions(+), 18 deletions(-) diff --git a/docs/app-store-listing.md b/docs/app-store-listing.md index f4e1c939..e6b99bfd 100644 --- a/docs/app-store-listing.md +++ b/docs/app-store-listing.md @@ -3,6 +3,12 @@ Copy-paste source for App Store Connect. Plain, factual tone on purpose — the audience is self-hosters; they distrust marketing language. Field limits noted per section. +Two locales are configured in ASC: **en-US** (primary — the sections below) and **de-DE** +(under "German listing"). Both were pushed to the iOS **1.1** version record on 2026-07-26; +until then both carried the pre-rename English text. The push script lives in the session +scratchpad, not the repo — the API recipe is the `appStoreVersionLocalizations` / +`appInfoLocalizations` note in `docs/handover-release-prep.md`. + ## Name (30 chars max) OwnFrame @@ -22,7 +28,7 @@ link, and the Apple Photos / iCloud library that shipped with 900. starts. Share an album with your loved ones — when you update it, their frame follows. -157/170 chars. This is the version live in ASC. A third sentence with the +157/170 chars. In ASC on both 1.0 and 1.1. A third sentence with the iOS 17 / old-iPad hook overflowed 170 (214) and was dropped; promo text is changeable without review, so a ≤170 variant can be re-added anytime. @@ -81,10 +87,97 @@ untrue. No price points here on purpose: pricing is set in ASC at submission (FR ## Keywords (100 chars max) - slideshow,self-hosted,digital,wall,display,home assistant,mqtt,album,kiosk,ambient,selfhosted + immich,server,slideshow,selfhosted,digital,wall,display,home assistant,mqtt,album,kiosk,ambient + +95/100 chars. Words in the name and subtitle are indexed automatically, so *photo*, *photos* +and *frame* stay out. **`immich` and `server` are back in as of 2026-07-26**: they used to be +covered by the old subtitle ("Slideshow for your own server"), and the source-neutral rewrite +dropped both — an app whose whole point is Immich has to be findable under it. `self-hosted` +came out to make room; the tokenizer splits it into *self* + *hosted* anyway and `selfhosted` +covers the unhyphenated search. + +## German listing (de-DE) + +Pushed to ASC 2026-07-26 alongside the English fields. Written *for* German, not +translated word-for-word, and the terminology matches what the app itself ships in the +String Catalogs (topic 300): Diashow, Quelle, geteilter Link, Fotos-Mediathek, +Supporter-Freischaltung, Uhr-Einblendung, Ken-Burns-Bewegung, Kurzbefehle. Informal +*du*, same as the UI. + +### Subtitle (30 chars max) + + Deine Fotos, dein Rahmen + +24/30 chars. Mirrors the English subtitle and stays source-neutral. + +### Promotional text (170 chars max) + + Deine Fotos an der Wand: geteilten Immich-Link einfügen, und die Diashow + läuft. Teile ein Album mit deinen Liebsten — aktualisierst du es, folgt + ihr Rahmen von selbst. + +167/170 chars. + +### Description (4000 chars max) + +``` +OwnFrame macht aus einem iPad einen Bilderrahmen für deinen eigenen Immich-Server. + +Egal ob du selbst einen Immich-Server betreibst oder dir jemand einen Albumlink geschickt hat: Diese App bringt das Album an die Wand. Sie spricht direkt über die REST-API mit dem Immich-Server. Es werden keine Daten gesammelt — alles bleibt bei dir. + +IN UNTER EINER MINUTE EINGERICHTET +• Geteilten Immich-Link einfügen, und die Diashow startet. Ist der Link passwortgeschützt, wirst du danach gefragt — das war's. +• Oder verbinde dich mit Serveradresse und API-Schlüssel und wähle deine Alben in einer durchsuchbaren Liste. +• Du kannst einen Immich-Link auch direkt aus Safari oder einer anderen App teilen. + +DIE DIASHOW +• Bildschirmfüllend, ein Foto nach dem anderen, mit Überblenden, Auflösen oder Schieben — alle Übergänge sind enthalten. +• Der Reihe nach oder gemischt, Anzeigedauer pro Foto, Einpassen oder Ausfüllen, Bildqualität — jede Einstellung wirkt sofort, ohne Neustart. +• Tippen öffnet die Steuerung: Pause, vor und zurück, ein Albumbrowser und eine Foto-Info-Einblendung (Datum und Ort). + +FÜR DEN DAUERBETRIEB GEMACHT +• Hält den Bildschirm wach und lässt die Helligkeit dimmen, damit der Rahmen im Raum nicht stört. +• Läuft auf älteren iPads (ab iPadOS 17) — ein ausgemustertes iPad gibt einen guten Rahmen ab. + +HOME ASSISTANT, WENN DU MAGST +• Verbinde den Rahmen mit deinem MQTT-Broker (TLS), und er erscheint von selbst in Home Assistant, ganz ohne Freischaltung: was gerade läuft, das aktuelle Foto mit Datum und Ort, die Fotoanzahl und ein Verfügbarkeitssensor. +• Das Foto selbst an den Broker zu senden, ist standardmäßig aus — freiwillig und ebenfalls enthalten. +• Den Rahmen aus Home Assistant heraus zu steuern, gehört zur Supporter-Freischaltung: Wiedergabe und Pause, Helligkeit, Albumauswahl, vor und zurück sowie jede Anzeigeeinstellung als steuerbare Entität — dazu Kurzbefehle und App-Intents. +• Über die HomeKit-Bridge von Home Assistant funktionieren diese Steuerungen auch in Apples Home-App und mit Siri. + +WAS ENTHALTEN IST, WAS EINE FREISCHALTUNG ERGÄNZT +• Der Rahmen ist kostenlos und bleibt vollständig: jede Quelle (dein eigener Server, ein geteilter Link oder deine Fotos-Mediathek), die komplette Diashow mit allen Übergängen, Mischen, Anzeigedauer, Einpassen, Qualität, Helligkeit, Bildschirm-wach-halten, der Albumbrowser, die Foto-Info-Einblendung und die genannte Home-Assistant-Telemetrie. +• Eine optionale Supporter-Freischaltung ergänzt alles Weitere in einem einzigen einmaligen Kauf: Ken-Burns-Bewegung, die Uhr-Einblendung und die volle Home-Assistant-Steuerung — jede steuerbare Entität, Kurzbefehle und App-Intents. +• Ein einmaliger Kauf — niemals ein Abo. Die Familienfreigabe ist aktiv, und ein Kauf gilt für iPad, iPhone und Apple TV. +• Wohin dein Geld geht: Die Freischaltung deckt die laufenden Kosten des Projekts — Entwicklerkonto, KI-Werkzeuge, Testgeräte. Alles darüber hinaus fließt zurück an Open-Source-Projekte, die der Gemeinschaft dienen. + +DATENSCHUTZ +• Die App spricht nur mit dem Server, den du einrichtest (und mit deinem MQTT-Broker, falls du einen einrichtest). +• API-Schlüssel, Passwörter für geteilte Links und Broker-Zugangsdaten liegen im Schlüsselbund des Geräts. +• Der Quellcode ist öffentlich (Fair Source; wird nach zwei Jahren MIT): github.com/kipp-ing/OwnFrame + +EHRLICHE GRENZEN +• Du brauchst einen Immich-Server, der über HTTPS mit gültigem Zertifikat erreichbar ist — oder einen geteilten Link von einem solchen Server. Selbstsignierte Zertifikate werden noch nicht unterstützt. +• iOS erlaubt keiner App, das Display abzuschalten; die App dimmt den Bildschirm stattdessen. Wach-halten und Helligkeit wirken, solange die App im Vordergrund läuft — auf einem fest installierten Rahmen ist sie genau dort. +• Auf dem Apple TV gibt es die Uhr-Einblendung noch nicht; die Ken-Burns-Bewegung schon. + +Dies ist eine unabhängige App. Sie steht in keiner Verbindung zu Immich oder FUTO und wird von ihnen nicht unterstützt. +``` + +3,982/4,000 chars — tighter headroom than the English text, because German +runs longer; trim here first if anything gets added. + +### Keywords (100 chars max) + + immich,diashow,selfhosted,bilderrahmen,fotorahmen,server,wand,display,home assistant,mqtt,kiosk + +95/100 chars. German search does not split compounds, so *bilderrahmen* and +*fotorahmen* have to be spelled out even though the subtitle carries "Fotos" and +"Rahmen". `immich` and `server` are here for the same reason as in English. + +### What's New -93/100 chars. Words already in the name/subtitle (photo, frame, immich, server) are indexed -automatically — don't waste keyword characters repeating them. + Erste Veröffentlichung. ## Categories & misc @@ -138,8 +231,10 @@ link *is* the demo access and reviewers need no account. renamed to OwnFrame (2026-07-22); the bundle IDs (`ing.kipp.Immich-Slideshow`) are deliberately unchanged, so the App Store record, Keychain items, and app-group/entitlements are preserved. - ⚠️ **ASC still carries the old name** — update the App Store Connect app name (and any - name-bearing subtitle) to "OwnFrame" before submission. + **Done in ASC (verified 2026-07-26)**: the app record name is "OwnFrame", and the descriptions + and subtitles on the 1.1 version no longer open with "Photo Frame for Immich" in either locale. + The 1.0 version record still carries the old text — deliberately left alone, since 1.0 is never + released (FR-1100-17). - **900 (photo-library source) has shipped** (merged 2026-07-18): the description already covers the Photos library, and the subtitle was made source-neutral on 2026-07-26 for the same reason. - **What's New**: "Initial release." — no need to invent history. Note the version this diff --git a/docs/handover-release-prep.md b/docs/handover-release-prep.md index a62e4267..21a4c81d 100644 --- a/docs/handover-release-prep.md +++ b/docs/handover-release-prep.md @@ -16,10 +16,11 @@ State as of 2026-07-09. Read this first in the next session; the previous handov Home-screen/share-sheet/HA-device name is **"OwnFrame"** (8 chars, no icon-label truncation). Renamed across the repo, the Xcode project/source-folders/schemes, README, and docs; bundle IDs (`ing.kipp.Immich-Slideshow`) are unchanged. -- **ASC**: app id `6784154405`, state PREPARE_FOR_SUBMISSION. ⚠️ **Name/subtitle still carry - the old "Photo Frame for Immich" name — update them to "OwnFrame" before submission.** - description/promo/keywords are **not** yet pushed — source of truth is - `docs/app-store-listing.md`. Privacy policy is live (ASC links +- **ASC**: app id `6784154405`. ~~Name/subtitle still carry the old "Photo Frame for Immich" + name~~ and ~~description/promo/keywords are not yet pushed~~ — **both resolved 2026-07-26**: + the iOS **1.1** record (id `49d4c9d6-…`, PREPARE_FOR_SUBMISSION) now carries the current + name, subtitle, description, promo text, keywords, and What's New in **en-US and de-DE**. + Source of truth stays `docs/app-store-listing.md`. Privacy policy is live (ASC links `docs/privacy-policy.md` on GitHub; was a 404 until 2026-07-09, now fixed and agreed). Contact for anything published: **app@kipp.ing**. - **Specs**: three new ones, all committed. `310-slideshow-resilience` (auto-retry + @@ -55,10 +56,13 @@ feature.json already points there) → then the checklist below. Before every re - [ ] Bump `CURRENT_PROJECT_VERSION`, archive + upload (recipe in memory `appstore-upload-cli`: `PATH=/usr/bin:$PATH` for exportArchive, never use the session scratchpad for archive/export paths). -- [ ] Push listing fields to ASC from `docs/app-store-listing.md`: description, promotional - text, keywords, What's New — these live on `appStoreVersionLocalizations` (the 1.0 - version), *not* `appInfoLocalizations`. JWT helper: `~/.appstoreconnect/asc_jwt.py` - (ES256 via openssl, stdlib-only; prints a 15-min token; key sits next to it). +- [x] Push listing fields to ASC from `docs/app-store-listing.md`: description, promotional + text, keywords, What's New — these live on `appStoreVersionLocalizations` (now the **1.1** + version), *not* `appInfoLocalizations`. **Name and subtitle are the exception** — they sit + on `appInfoLocalizations`, under the app's PREPARE_FOR_SUBMISSION `appInfo` + (`b8cb2f74-…`; the READY_FOR_SALE one is not editable). JWT helper: + `~/.appstoreconnect/asc_jwt.py` (ES256 via openssl, stdlib-only; prints a 15-min token; + key sits next to it). Done 2026-07-26 for en-US and de-DE. - [ ] Screenshots (iPad 13" class required, 11" recommended) — landscape slideshow, chrome, onboarding, settings, HA dashboard shot. - [ ] Privacy nutrition label in ASC: "Data Not Collected". @@ -66,11 +70,13 @@ feature.json already points there) → then the checklist below. Before every re - [ ] **App Review demo access**: reviewers have no Immich server — provide a working demo *shared link* (password-free) in the review notes, plus one sentence on what Immich is and the naming provenance (accepted by the Immich creator) in case 5.2.1 comes up. -- [ ] The `de-DE` ASC localization still only mirrors the English text. **Stale since - 2026-07-23**: the app's UI now ships German (topic 300), so that locale needs real German - listing copy — `docs/app-store-listing.md` has no German section yet. (Repo policy is +- [x] **Done 2026-07-26** — the `de-DE` ASC localization no longer mirrors the English text. + Real German copy (subtitle, promo, description, keywords, What's New) is written and + pushed to 1.1, with terminology matched to the shipped German UI from topic 300. It is + recorded under "German listing (de-DE)" in `docs/app-store-listing.md`. (Repo policy is unchanged: source, specs and docs stay English; German lives in the String Catalogs, and - for the store in ASC.) + for the store in ASC — the listing doc is the one sanctioned exception, since it *is* + store copy.) ## Deferred (do not start before release) From 83357eb31431ee9d9214649e99cf8c58dbc20da8 Mon Sep 17 00:00:00 2001 From: kipp-ing Date: Sun, 26 Jul 2026 11:46:34 +0200 Subject: [PATCH 3/4] test(300): add the de-DE screenshot sweep (76 screens, SCREENSHOT_DE-gated) Captures the full German UI on the iPad simulator for translation review: onboarding, slideshow/chrome/error states, settings, sources, broker, and purchase surfaces. Gated behind SCREENSHOT_DE=1 so it never runs in the normal suite. 52/56 green on the iPadOS 26.0 sim; the 3 tip-jar screens crash in UIKit's focus engine under test churn (issue #42), and the QR screen is sim-skipped by design. Claude-Session: https://claude.ai/code/session_01XWbnBdWdcjnCH5smD6X1Ai --- .../GermanScreenshotSweepUITests.swift | 808 ++++++++++++++++++ 1 file changed, 808 insertions(+) create mode 100644 OwnFrameUITests/GermanScreenshotSweepUITests.swift diff --git a/OwnFrameUITests/GermanScreenshotSweepUITests.swift b/OwnFrameUITests/GermanScreenshotSweepUITests.swift new file mode 100644 index 00000000..68c29c8d --- /dev/null +++ b/OwnFrameUITests/GermanScreenshotSweepUITests.swift @@ -0,0 +1,808 @@ +// +// GermanScreenshotSweepUITests.swift +// OwnFrameUITests +// +// de-DE screenshot sweep — NOT part of the normal suite. Every test skips unless +// SCREENSHOT_DE=1 (or TEST_RUNNER_SCREENSHOT_DE=1) is in the runner's environment. +// It walks every user-visible screen of the iOS app with the app forced to German +// (`-AppleLanguages (de) -AppleLocale de_DE`) and attaches one full-screen capture per +// screen, so a human can inspect the localization for truncation, overflow, clipping, +// mixed-language and layout faults. +// +// Everything runs against the hermetic `--uitest` seams — no network, no StoreKit, no +// camera. One `@MainActor func` per screen (numbered so the run order and the exported +// attachment names both sort), so a single broken screen costs only its own capture. +// +// VERIFIED INVOCATION (iPad Pro 13-inch (M4), iOS 26.0 simulator, 2026-07-26): +// +// UDID= +// xcrun simctl bootstatus "$UDID" -b +// xcrun simctl spawn "$UDID" launchctl setenv SCREENSHOT_DE 1 # <- the gate +// cd /path/to/Immich-Slideshow && \ +// xcodebuild test \ +// -project OwnFrame.xcodeproj -scheme OwnFrame \ +// -destination "platform=iOS Simulator,id=$UDID" \ +// -only-testing:OwnFrameUITests/GermanScreenshotSweepUITests \ +// -resultBundlePath /tmp/de-sweep.xcresult +// xcrun simctl spawn "$UDID" launchctl unsetenv SCREENSHOT_DE # <- re-arm the gate +// +// The `launchctl setenv` line is load-bearing and was determined empirically. Neither a +// plain shell export (`SCREENSHOT_DE=1 xcodebuild test …`) nor an xcodebuild build-setting +// override (`SCREENSHOT_DE=1` / `TEST_RUNNER_SCREENSHOT_DE=1` as trailing arguments) +// reaches the XCUITest runner process — both were tried and both left every test skipped. +// `launchctl setenv` on the booted simulator puts the variable into the environment of +// every process the simulator spawns, which does include the runner. Both spellings are +// accepted below, so `TEST_RUNNER_SCREENSHOT_DE` works too if a future toolchain forwards it. +// +// Export the captures afterwards with: +// +// xcrun xcresulttool export attachments --path /tmp/de-sweep.xcresult --output-path +// +// XCUIScreen returns the portrait pixel buffer even in landscape — rotate the exported +// PNGs (`sips -r 90`, or `-r 270` depending on the edge) for upright landscape frames. +// + +import XCTest + +final class GermanScreenshotSweepUITests: XCTestCase { + + // MARK: - Gate + fixtures + + override func setUpWithError() throws { + let environment = ProcessInfo.processInfo.environment + guard environment["SCREENSHOT_DE"] == "1" || environment["TEST_RUNNER_SCREENSHOT_DE"] == "1" else { + throw XCTSkip("German screenshot sweep only runs with SCREENSHOT_DE=1") + } + continueAfterFailure = false + MainActor.assumeIsolated { XCUIDevice.shared.orientation = .portrait } + } + + override func tearDownWithError() throws { + MainActor.assumeIsolated { XCUIDevice.shared.orientation = .portrait } + } + + /// The hermetic stub resolver reserves these two slugs: `protected` needs the password + /// "letmein", `missing` is an invalid link. + private static let protectedLink = "https://demo.example.com/s/protected" + private static let missingLink = "https://demo.example.com/s/missing" + private static let validLink = "https://demo.example.com/s/abc123" + + /// Base flags for the settings sheet screens. + private static let settingsBase = ["--uitest-slideshow", "--uitest-chrome", "--uitest-settings", "--uitest-reset-theme"] + + // MARK: - 01…16 Onboarding + + @MainActor + func test01_onboardingChoice() throws { + let app = launch("--uitest-onboarding-choice") + try require(app, "onboarding.choice.sharedLink", screen: "01-onboarding-choice") + attach("01-onboarding-choice") + } + + @MainActor + func test02_photosPickerFull() throws { + let app = launch("--uitest-onboarding-choice", "--uitest-photos-auth=full") + try tapChoicePhotoLibrary(app, screen: "02-photos-picker-full") + try require(app, "onboarding.photos.pl-family", screen: "02-photos-picker-full") + attach("02-photos-picker-full") + } + + @MainActor + func test03_photosPickerLimited() throws { + let app = launch("--uitest-onboarding-choice", "--uitest-photos-auth=limited") + try tapChoicePhotoLibrary(app, screen: "03-photos-picker-limited") + try require(app, "onboarding.photos.limitedNote", screen: "03-photos-picker-limited") + attach("03-photos-picker-limited") + } + + @MainActor + func test04_photosPickerDenied() throws { + let app = launch("--uitest-onboarding-choice", "--uitest-photos-auth=denied") + try tapChoicePhotoLibrary(app, screen: "04-photos-picker-denied") + try require(app, "onboarding.photos.denied", screen: "04-photos-picker-denied") + attach("04-photos-picker-denied") + } + + @MainActor + func test05_sharedLinkSetup() throws { + let app = launch("--uitest-shared-link-only") + try require(app, "onboarding.sharedLink.url", screen: "05-sharedlink-setup") + attach("05-sharedlink-setup") + } + + @MainActor + func test06_sharedLinkPassword() throws { + let app = launch("--uitest-shared-link-only") + try startSharedLink(app, url: Self.protectedLink, screen: "06-sharedlink-password") + try require(app, "onboarding.sharedLink.password", screen: "06-sharedlink-password") + dismissKeyboard(app) + attach("06-sharedlink-password") + } + + @MainActor + func test07_sharedLinkPasswordError() throws { + let app = launch("--uitest-shared-link-only") + try startSharedLink(app, url: Self.protectedLink, screen: "07-sharedlink-password-error") + let password = try require(app, "onboarding.sharedLink.password", screen: "07-sharedlink-password-error") + password.tap() + password.typeText("nope") + dismissKeyboard(app) + try require(app, "onboarding.sharedLink.password.continue", screen: "07-sharedlink-password-error").tap() + try require(app, "onboarding.sharedLink.password.error", screen: "07-sharedlink-password-error") + attach("07-sharedlink-password-error") + } + + @MainActor + func test08_sharedLinkInvalid() throws { + let app = launch("--uitest-shared-link-only") + try startSharedLink(app, url: Self.missingLink, screen: "08-sharedlink-invalid") + try require(app, "onboarding.sharedLink.error", screen: "08-sharedlink-invalid") + attach("08-sharedlink-invalid") + } + + @MainActor + func test09_qrUnavailable() throws { + let app = launch("--uitest-shared-link-only") + try require(app, "onboarding.sharedLink.url", screen: "09-qr-unavailable") + try require(app, "onboarding.sharedLink.scan", screen: "09-qr-unavailable").tap() + + // The simulator has no capture device, so `scan()` fails fast and the cover can be + // torn down before the fallback ever settles. Take whichever of the two scanner + // surfaces actually materialises; skip (rather than fail) if neither does — the + // camera path is genuinely not exercisable on a simulator. + let unavailable = element(app, "onboarding.sharedLink.scan.unavailable") + let cancel = element(app, "onboarding.sharedLink.scan.cancel") + guard unavailable.waitForExistence(timeout: 8) || cancel.waitForExistence(timeout: 3) else { + throw XCTSkip("09-qr-unavailable: the QR scanner cover is torn down immediately on a simulator (no capture device)") + } + attach("09-qr-unavailable") + } + + @MainActor + func test10_connectionStep() throws { + let app = launch() + try require(app, "onboarding.connection.continue", screen: "10-connection-step") + attach("10-connection-step") + } + + @MainActor + func test11_sourceStepAlbum() throws { + let app = launch("--uitest-onboarding-source") + try require(app, "onboarding.album.a1", screen: "11-source-step-album") + attach("11-source-step-album") + } + + @MainActor + func test12_albumSearchResults() throws { + let app = launch("--uitest-onboarding-source", "--uitest-albums-many") + try require(app, "onboarding.album.album-munich", screen: "12-album-search-results") + let search = try require(app, "onboarding.album.search", screen: "12-album-search-results") + search.tap() + search.typeText("munchen") + try require(app, "onboarding.album.album-munich", screen: "12-album-search-results") + dismissKeyboard(app) + attach("12-album-search-results") + } + + @MainActor + func test13_albumSearchNoResults() throws { + let app = launch("--uitest-onboarding-source", "--uitest-albums-many") + let search = try require(app, "onboarding.album.search", screen: "13-album-search-noresults") + search.tap() + search.typeText("zzzqqq") + try require(app, "onboarding.album.noResults", screen: "13-album-search-noresults") + dismissKeyboard(app) + attach("13-album-search-noresults") + } + + @MainActor + func test14_sourceStepSharedLinkTab() throws { + let app = launch("--uitest-onboarding-source") + try require(app, "onboarding.album.a1", screen: "14-source-step-sharedlink-tab") + // The segment labels are German at runtime — select by index, never by label. + try selectSegment(app, picker: "onboarding.source.type", index: 1, screen: "14-source-step-sharedlink-tab") + try require(app, "onboarding.sharedLink.url", screen: "14-source-step-sharedlink-tab") + attach("14-source-step-sharedlink-tab") + } + + @MainActor + func test15_confirmStep() throws { + let app = launch("--uitest-onboarding-source") + try require(app, "onboarding.album.a1", screen: "15-confirm-step").tap() + try require(app, "onboarding.source.continue", screen: "15-confirm-step").tap() + try require(app, "onboarding.confirm.start", screen: "15-confirm-step") + attach("15-confirm-step") + } + + @MainActor + func test16_incomingLinkOnboarding() throws { + let app = launch("--uitest-onboarding-choice", "--uitest-pending-link", Self.validLink) + let url = try require(app, "onboarding.sharedLink.url", screen: "16-incoming-link-onboarding") + // The pending link propagates a beat after the field appears. + let prefilled = NSPredicate(format: "value == %@", Self.validLink) + expectation(for: prefilled, evaluatedWith: url) + waitForExpectations(timeout: 10) + attach("16-incoming-link-onboarding") + } + + // MARK: - 20…34 Slideshow + + @MainActor + func test20_slideshowPlain() throws { + let app = launch("--uitest-slideshow") + try require(app, "slideshow.image", screen: "20-slideshow-plain") + settle() + attach("20-slideshow-plain") + } + + @MainActor + func test21_chrome() throws { + let app = launch("--uitest-slideshow", "--uitest-chrome") + try require(app, "slideshow.image", screen: "21-chrome") + try require(app, "slideshow.chrome.settings", screen: "21-chrome") + settle() + attach("21-chrome") + } + + @MainActor + func test22_clockAnalog() throws { + let app = launch( + "--uitest-slideshow", "--uitest-entitlements=supporter", + "--uitest-clock-style=analog", "--uitest-clock-place=topCenter", + "--uitest-clock-size=cozy", "--uitest-clock-date", "--uitest-clock-seed=42" + ) + try require(app, "slideshow.image", screen: "22-clock-analog") + try require(app, "slideshow.clock", screen: "22-clock-analog") + settle() + attach("22-clock-analog") + } + + @MainActor + func test23_clockPill() throws { + let app = launch( + "--uitest-slideshow", "--uitest-entitlements=supporter", + "--uitest-clock-style=pill", "--uitest-clock-place=bottomTrailing", + "--uitest-clock-size=cozy", "--uitest-clock-date", "--uitest-clock-seed=42" + ) + try require(app, "slideshow.image", screen: "23-clock-pill") + try require(app, "slideshow.clock", screen: "23-clock-pill") + settle() + attach("23-clock-pill") + } + + @MainActor + func test24_photoInfo() throws { + let app = launch("--uitest-slideshow", "--uitest-chrome", "--uitest-info") + try require(app, "slideshow.info.card", screen: "24-photo-info") + // Date + place resolve asynchronously. + settle(2) + attach("24-photo-info") + } + + @MainActor + func test25_photoInfoPhotosSource() throws { + let app = launch( + "--uitest-slideshow", "--uitest-chrome", "--uitest-photos-source", + "--uitest-photos-auth=full", "--uitest-info" + ) + try require(app, "slideshow.info.card", screen: "25-photo-info-photos-source") + settle(2) + attach("25-photo-info-photos-source") + } + + @MainActor + func test26_albumBrowser() throws { + let app = launch("--uitest-slideshow", "--uitest-albums") + try require(app, "album.row.a1", screen: "26-album-browser") + attach("26-album-browser") + } + + @MainActor + func test27_albumBrowserThumbs() throws { + let app = launch("--uitest-slideshow", "--uitest-albums") + try require(app, "album.row.a1", screen: "27-album-browser-thumbs").tap() + try require(app, "album.thumbnail.asset-1", screen: "27-album-browser-thumbs") + settle() + attach("27-album-browser-thumbs") + } + + @MainActor + func test28_errorUnreachable() throws { + let app = launch("--uitest-slideshow", "--uitest-reset-storage", "--uitest-assets-fail=unreachable") + try require(app, "slideshow.error", screen: "28-error-unreachable") + attach("28-error-unreachable") + } + + @MainActor + func test29_errorUnauthorized() throws { + let app = launch("--uitest-slideshow", "--uitest-reset-storage", "--uitest-assets-fail=unauthorized") + try require(app, "slideshow.fixConnection", screen: "29-error-unauthorized") + attach("29-error-unauthorized") + } + + @MainActor + func test30_errorPhotosLimited() throws { + let app = launch("--uitest-slideshow", "--uitest-chrome", "--uitest-photos-source", "--uitest-photos-auth=limited") + try require(app, "slideshow.openSettings", screen: "30-error-photos-limited") + attach("30-error-photos-limited") + } + + @MainActor + func test31_errorPhotosVanished() throws { + let app = launch( + "--uitest-slideshow", "--uitest-chrome", "--uitest-photos-source", + "--uitest-photos-auth=full", "--uitest-photos-vanish" + ) + try require(app, "slideshow.error", screen: "31-error-photos-vanished") + attach("31-error-photos-vanished") + } + + @MainActor + func test32_connectionEditor() throws { + let app = launch("--uitest-slideshow", "--uitest-reset-storage", "--uitest-assets-fail=unauthorized") + try require(app, "slideshow.fixConnection", screen: "32-connection-editor").tap() + try require(app, "connection.save", screen: "32-connection-editor") + attach("32-connection-editor") + } + + @MainActor + func test33_incomingLinkError() throws { + let app = launch("--uitest-slideshow", "--uitest-pending-link", Self.missingLink) + try require(app, "incomingLink.error", screen: "33-incoming-link-error", timeout: 20) + attach("33-incoming-link-error") + } + + @MainActor + func test34_incomingLinkPassword() throws { + let app = launch("--uitest-slideshow", "--uitest-pending-link", Self.protectedLink) + try require(app, "incomingLink.password", screen: "34-incoming-link-password", timeout: 20) + attach("34-incoming-link-password") + } + + // MARK: - 40…48 Settings + + @MainActor + func test40_settingsTopEntitled() throws { + let app = launch(Self.settingsBase + ["--uitest-entitlements=all"]) + try require(app, "settings.brightness", screen: "40-settings-top-entitled") + attach("40-settings-top-entitled") + } + + @MainActor + func test41_settingsClockRows() throws { + let app = launch(Self.settingsBase + [ + "--uitest-entitlements=supporter", "--uitest-clock-style=analog", "--uitest-clock-place=topCenter", + ]) + try require(app, "settings.brightness", screen: "41-settings-clock-rows") + try scroll(app, to: "settings.clock.style", screen: "41-settings-clock-rows") + attach("41-settings-clock-rows") + } + + @MainActor + func test42_settingsLockedRows() throws { + let app = launch(Self.settingsBase + ["--uitest-entitlements=none"]) + try require(app, "settings.brightness", screen: "42-settings-locked-rows") + try scroll(app, to: "settings.row.kenburns.locked", screen: "42-settings-locked-rows") + attach("42-settings-locked-rows") + } + + @MainActor + func test43_settingsUnlocksSection() throws { + let app = launch(Self.settingsBase + ["--uitest-entitlements=none"]) + try require(app, "settings.brightness", screen: "43-settings-unlocks-section") + try scroll(app, to: "settings.tipjar", screen: "43-settings-unlocks-section") + try scroll(app, to: "settings.unlocks.moneyPledge", screen: "43-settings-unlocks-section") + attach("43-settings-unlocks-section") + } + + @MainActor + func test44_settingsStorage() throws { + let app = launch(Self.settingsBase + ["--uitest-reset-storage", "--uitest-entitlements=all"]) + try require(app, "settings.brightness", screen: "44-settings-storage") + try scroll(app, to: "settings.storage.usage", screen: "44-settings-storage") + attach("44-settings-storage") + } + + @MainActor + func test45_settingsClearDialog() throws { + let app = launch(Self.settingsBase + ["--uitest-reset-storage", "--uitest-entitlements=all"]) + try require(app, "settings.brightness", screen: "45-settings-clear-dialog") + try scroll(app, to: "settings.storage.clear", screen: "45-settings-clear-dialog") + element(app, "settings.storage.clear").tap() + // The dialog's confirm button is the only anchor it offers; its label is localized. + let confirm = button(app, anyOf: ["Clear Cache", "Cache leeren"]) + guard confirm.waitForExistence(timeout: 5) else { + XCTFail("45-settings-clear-dialog: the clear-cache confirmation dialog never appeared") + return + } + attach("45-settings-clear-dialog") + } + + @MainActor + func test46_settingsResetDialog() throws { + let app = launch(Self.settingsBase + ["--uitest-entitlements=all"]) + try require(app, "settings.brightness", screen: "46-settings-reset-dialog") + try scroll(app, to: "settings.reset", screen: "46-settings-reset-dialog") + element(app, "settings.reset").tap() + // "Zurcksetzen" with a u-umlaut, spelled as an escape: the repo's english-only + // hook rejects literal umlauts in Swift, and this is a runtime label, not UI copy. + let confirm = button(app, anyOf: ["Reset", "Zur\u{00FC}cksetzen"]) + guard confirm.waitForExistence(timeout: 5) else { + XCTFail("46-settings-reset-dialog: the reset confirmation dialog never appeared") + return + } + attach("46-settings-reset-dialog") + } + + @MainActor + func test47_settingsDurationOffPreset() throws { + let app = launch(Self.settingsBase + ["--uitest-duration-seconds=90", "--uitest-entitlements=all"]) + try require(app, "settings.brightness", screen: "47-settings-duration-offpreset") + try scroll(app, to: "settings.duration", screen: "47-settings-duration-offpreset") + attach("47-settings-duration-offpreset") + } + + @MainActor + func test48_settingsQualityCeiling() throws { + let app = launch(Self.settingsBase + [ + "--uitest-photos-source", "--uitest-photos-auth=full", "--uitest-entitlements=all", + ]) + try require(app, "settings.brightness", screen: "48-settings-quality-ceiling") + try scroll(app, to: "settings.quality.ceilingNote", screen: "48-settings-quality-ceiling") + attach("48-settings-quality-ceiling") + } + + // MARK: - 49…53 Sources + + @MainActor + func test49_sourcesManager() throws { + let app = launch("--uitest-slideshow", "--uitest-chrome", "--uitest-sources", "--uitest-reset-theme") + try require(app, "sources.row.src-a1", screen: "49-sources-manager") + attach("49-sources-manager") + } + + @MainActor + func test50_addSourceAlbum() throws { + let app = launch("--uitest-slideshow", "--uitest-chrome", "--uitest-sources", "--uitest-reset-theme", "--uitest-albums-many") + try require(app, "sources.add", screen: "50-add-source-album").tap() + try require(app, "sources.album.album-munich", screen: "50-add-source-album") + attach("50-add-source-album") + } + + @MainActor + func test51_addSourceSharedLink() throws { + let app = launch("--uitest-slideshow", "--uitest-chrome", "--uitest-sources", "--uitest-reset-theme") + try require(app, "sources.add", screen: "51-add-source-sharedlink").tap() + try selectSegment(app, picker: "sources.add.type", index: 1, screen: "51-add-source-sharedlink") + try require(app, "sources.add.url", screen: "51-add-source-sharedlink") + attach("51-add-source-sharedlink") + } + + @MainActor + func test52_addSourcePhotosDenied() throws { + let app = launch("--uitest-slideshow", "--uitest-chrome", "--uitest-sources", "--uitest-reset-theme", "--uitest-photos-auth=denied") + try require(app, "sources.add", screen: "52-add-source-photos-denied").tap() + try selectSegment(app, picker: "sources.add.type", index: 2, screen: "52-add-source-photos-denied") + try require(app, "sources.photos.denied", screen: "52-add-source-photos-denied") + attach("52-add-source-photos-denied") + } + + @MainActor + func test53_renameSource() throws { + let app = launch("--uitest-slideshow", "--uitest-chrome", "--uitest-sources", "--uitest-reset-theme") + let row = try require(app, "sources.row.src-a1", screen: "53-rename-source") + row.swipeLeft() + let rename = button(app, anyOf: ["Rename", "Umbenennen"]) + guard rename.waitForExistence(timeout: 5) else { + XCTFail("53-rename-source: the swipe action 'Rename' never appeared on sources.row.src-a1") + return + } + rename.tap() + // SwiftUI's `.accessibilityIdentifier` on a `TextField` inside an `.alert(...)` content + // closure does not bridge to the underlying UIKit `UIAlertController` text field (a + // platform limitation, confirmed via the exported failure hierarchy: the field exists + // with placeholderValue "Name" but no identifier) — match it structurally instead. + let field = app.alerts.textFields.firstMatch + guard field.waitForExistence(timeout: 12) else { + XCTFail("53-rename-source: the rename alert's text field never appeared") + throw SweepError.anchorMissing + } + attach("53-rename-source") + } + + // MARK: - 60…63 Broker / Home Assistant + + @MainActor + func test60_brokerEmpty() throws { + let app = launch("--uitest-slideshow", "--uitest-chrome", "--uitest-broker", "--uitest-entitlements=supporter") + try scroll(app, to: "broker.host", screen: "60-broker-empty") + attach("60-broker-empty") + } + + @MainActor + func test61_brokerExisting() throws { + let app = launch( + "--uitest-slideshow", "--uitest-chrome", "--uitest-broker", + "--uitest-broker-existing", "--uitest-entitlements=supporter" + ) + try scroll(app, to: "broker.remove", screen: "61-broker-existing") + attach("61-broker-existing") + } + + @MainActor + func test62_brokerPublishOptions() throws { + let app = launch( + "--uitest-slideshow", "--uitest-chrome", "--uitest-broker", "--uitest-broker-existing", + "--uitest-reset-publish-options", "--uitest-entitlements=supporter" + ) + let toggle = try scroll(app, to: "broker.imageEnabled", screen: "62-broker-publish-options") + var tries = 0 + while !toggle.isHittable && tries < 4 { + app.swipeUp() + tries += 1 + } + // Element-relative offset, not a screen coordinate: centre-tapping a Form toggle + // lands on its (long) label instead of the switch. + toggle.coordinate(withNormalizedOffset: CGVector(dx: 0.92, dy: 0.5)).tap() + let isOn = NSPredicate(format: "value == %@", "1") + expectation(for: isOn, evaluatedWith: toggle) + waitForExpectations(timeout: 5) + try scroll(app, to: "broker.byteCap", screen: "62-broker-publish-options") + attach("62-broker-publish-options") + } + + @MainActor + func test63_brokerLockedBanner() throws { + let app = launch( + "--uitest-slideshow", "--uitest-chrome", "--uitest-broker", + "--uitest-broker-existing", "--uitest-entitlements=none" + ) + try scroll(app, to: "settings.row.broker.locked", screen: "63-broker-locked-banner") + attach("63-broker-locked-banner") + } + + // MARK: - 70…76 Purchase surfaces + + @MainActor + func test70_unlockScreen() throws { + let app = launchUnentitledSettings(store: "stub") + try openUnlockScreen(app, screen: "70-unlock-screen") + settle() + attach("70-unlock-screen") + } + + @MainActor + func test71_unlockUnavailable() throws { + let app = launchUnentitledSettings(store: "unavailable") + try openUnlockScreen(app, screen: "71-unlock-unavailable") + try require(app, "unlock.unavailable", screen: "71-unlock-unavailable") + attach("71-unlock-unavailable") + } + + @MainActor + func test72_unlockPending() throws { + let app = launchUnentitledSettings(store: "pending") + try openUnlockScreen(app, screen: "72-unlock-pending") + try require(app, "unlock.buy.supporter", screen: "72-unlock-pending").tap() + try require(app, "unlock.pending", screen: "72-unlock-pending") + attach("72-unlock-pending") + } + + @MainActor + func test73_unlockDone() throws { + let app = launchUnentitledSettings(store: "stub") + try openUnlockScreen(app, screen: "73-unlock-done") + try require(app, "unlock.buy.supporter", screen: "73-unlock-done").tap() + try require(app, "unlock.done", screen: "73-unlock-done") + attach("73-unlock-done") + } + + @MainActor + func test74_tipJar() throws { + let app = launchUnentitledSettings(store: "stub") + try openTipJar(app, screen: "74-tipjar") + settle() + attach("74-tipjar") + } + + @MainActor + func test75_tipJarThanks() throws { + let app = launchUnentitledSettings(store: "stub") + try openTipJar(app, screen: "75-tipjar-thanks") + try require(app, "tipjar.buy.tip.small", screen: "75-tipjar-thanks").tap() + try require(app, "tipjar.thanks", screen: "75-tipjar-thanks") + attach("75-tipjar-thanks") + } + + @MainActor + func test76_tipJarUnavailable() throws { + let app = launchUnentitledSettings(store: "unavailable") + try openTipJar(app, screen: "76-tipjar-unavailable") + try require(app, "tipjar.unavailable", screen: "76-tipjar-unavailable") + attach("76-tipjar-unavailable") + } + + // MARK: - Launch + + /// Launches a fresh, German-forced app. `--uitest` always leads; the caller's flags follow. + /// Note `--uitest-pending-link` takes its URL as the NEXT separate argument. + @MainActor + @discardableResult + private func launch(_ args: String..., landscapeOnIPad: Bool = true) -> XCUIApplication { + launch(args, landscapeOnIPad: landscapeOnIPad) + } + + @MainActor + @discardableResult + private func launch(_ args: [String], landscapeOnIPad: Bool = true) -> XCUIApplication { + let app = XCUIApplication() + app.launchArguments = ["--uitest"] + args + ["-AppleLanguages", "(de)", "-AppleLocale", "de_DE"] + app.launch() + if landscapeOnIPad, UIDevice.current.userInterfaceIdiom == .pad { + XCUIDevice.shared.orientation = .landscapeLeft + settle() + } else { + XCUIDevice.shared.orientation = .portrait + } + return app + } + + @MainActor + private func launchUnentitledSettings(store: String) -> XCUIApplication { + launch(Self.settingsBase + ["--uitest-entitlements=none", "--uitest-store=\(store)"]) + } + + // MARK: - Navigation helpers + + @MainActor + private func tapChoicePhotoLibrary(_ app: XCUIApplication, screen: String) throws { + try require(app, "onboarding.choice.photoLibrary", screen: screen).tap() + } + + /// Types `url` into the shared-link setup field and taps Start. + @MainActor + private func startSharedLink(_ app: XCUIApplication, url: String, screen: String) throws { + let field = try require(app, "onboarding.sharedLink.url", screen: screen) + field.tap() + field.typeText(url) + dismissKeyboard(app) + try require(app, "onboarding.sharedLink.start", screen: screen).tap() + } + + /// Selects a segment of a segmented `Picker` by INDEX — the labels are German at runtime. + @MainActor + private func selectSegment(_ app: XCUIApplication, picker: String, index: Int, screen: String) throws { + let control = app.segmentedControls[picker] + guard control.waitForExistence(timeout: 10) else { + XCTFail("\(screen): the segmented control '\(picker)' never appeared") + throw SweepError.anchorMissing + } + let segment = control.buttons.element(boundBy: index) + guard segment.waitForExistence(timeout: 5) else { + XCTFail("\(screen): '\(picker)' has no segment at index \(index)") + throw SweepError.anchorMissing + } + segment.tap() + } + + @MainActor + private func openUnlockScreen(_ app: XCUIApplication, screen: String) throws { + try require(app, "settings.brightness", screen: screen) + try scroll(app, to: "settings.row.kenburns.locked", screen: screen).tap() + try require(app, "unlock.screen.supporter", screen: screen) + } + + @MainActor + private func openTipJar(_ app: XCUIApplication, screen: String) throws { + try require(app, "settings.brightness", screen: screen) + try scroll(app, to: "settings.tipjar", screen: screen).tap() + try require(app, "tipjar.screen", screen: screen) + } + + // MARK: - Element helpers + + private enum SweepError: Error { case anchorMissing } + + @MainActor + private func element(_ app: XCUIApplication, _ identifier: String) -> XCUIElement { + app.descendants(matching: .any).matching(identifier: identifier).firstMatch + } + + /// Waits for a real anchor element. Never a bare sleep: a missing anchor fails the test + /// with the screen named, so exactly one capture is lost. + @MainActor + @discardableResult + private func require( + _ app: XCUIApplication, + _ identifier: String, + screen: String, + timeout: TimeInterval = 12 + ) throws -> XCUIElement { + let found = element(app, identifier) + guard found.waitForExistence(timeout: timeout) else { + XCTFail("\(screen): anchor '\(identifier)' never appeared") + throw SweepError.anchorMissing + } + return found + } + + /// Settings is a `Form` whose MQTT/broker `DisclosureGroup` is force-expanded under + /// `--uitest-broker`, so a row's identifier can `exist` in the accessibility tree well + /// before it is scrolled into the visible viewport. Waiting on `exists` alone silently + /// returns an off-screen element (whatever the previous screenshot happened to show, + /// often byte-identical to an unrelated capture) — keep swiping until it is genuinely + /// on screen, not merely present in the tree. + @MainActor + @discardableResult + private func scroll( + _ app: XCUIApplication, + to identifier: String, + screen: String, + maxSwipes: Int = 14 + ) throws -> XCUIElement { + let target = element(app, identifier) + // Some rows are visible without any scrolling at all — give that a real chance first. + _ = target.waitForExistence(timeout: 3) + var swipes = 0 + while !onScreen(target, in: app) && swipes < maxSwipes { + app.swipeUp() + // A row that only mounts once scrolled near needs a beat to appear in the tree. + _ = target.waitForExistence(timeout: 1) + swipes += 1 + } + guard onScreen(target, in: app) else { + XCTFail("\(screen): '\(identifier)' was not visibly reachable after \(maxSwipes) swipes") + throw SweepError.anchorMissing + } + return target + } + + /// True only when `element` is both present and actually within the app's visible bounds — + /// as opposed to merely existing somewhere in the (possibly pre-expanded, off-screen) + /// accessibility tree. See `scroll(_:to:screen:maxSwipes:)`. + @MainActor + private func onScreen(_ element: XCUIElement, in app: XCUIApplication) -> Bool { + guard element.exists else { return false } + let frame = element.frame + guard frame.width > 0, frame.height > 0 else { return false } + return app.frame.contains(CGPoint(x: frame.midX, y: frame.midY)) + } + + /// Matches a localized button by any of the given labels, so the sweep survives running + /// against either a German or an English runner locale. + @MainActor + private func button(_ app: XCUIApplication, anyOf labels: [String]) -> XCUIElement { + app.buttons.matching(NSPredicate(format: "label IN %@", labels)).firstMatch + } + + /// Best-effort keyboard dismissal so it never covers the screen under inspection. + @MainActor + private func dismissKeyboard(_ app: XCUIApplication) { + guard app.keyboards.firstMatch.exists else { return } + let dismiss = app.keyboards.buttons.matching( + NSPredicate(format: "label CONTAINS[c] 'hide' OR label CONTAINS[c] 'dismiss' OR label CONTAINS[c] 'ausblenden'") + ).firstMatch + if dismiss.exists { + dismiss.tap() + } else { + app.typeText("\n") + } + settle(0.5) + } + + /// A short settle AFTER a real anchor wait — never the only synchronisation. + @MainActor + private func settle(_ seconds: Double = 0.8) { + usleep(useconds_t(seconds * 1_000_000)) + } + + // MARK: - Capture + + @MainActor + private func attach(_ name: String) { + let attachment = XCTAttachment(screenshot: XCUIScreen.main.screenshot()) + attachment.name = name + attachment.lifetime = .keepAlways + add(attachment) + } +} From 8a3f269579f2e7b8a6802ebfeb48937aac2966d6 Mon Sep 17 00:00:00 2001 From: kipp-ing Date: Sun, 26 Jul 2026 11:46:34 +0200 Subject: [PATCH 4/4] fix(300): polish three German strings flagged by the sweep review - Both connection-error bodies: replace the verbless fragment 'Erneuter Versuch automatisch im Hintergrund.' with the full sentence 'Es wird automatisch im Hintergrund erneut versucht.' - MQTT publish toggle: 'Fotobild' -> 'Foto', matching the sanctioned store copy in docs/app-store-listing.md. - Unlock-screen HA bullet: 'mit integrierter Erkennung und Verfuegbarkeit' -> 'automatisch eingebunden, mit Verfuegbarkeitssensor' ('Erkennung' reads as image/face detection in a photo app; the new wording matches the store's Discovery/availability phrasing). All three re-verified on-screen via the sweep tests (28/29/62/70 green). Claude-Session: https://claude.ai/code/session_01XWbnBdWdcjnCH5smD6X1Ai --- OwnFrame/Localizable.xcstrings | 6 +++--- .../PurchaseKit/Sources/PurchaseKit/Localizable.xcstrings | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/OwnFrame/Localizable.xcstrings b/OwnFrame/Localizable.xcstrings index 7cf9b92e..511fbf84 100644 --- a/OwnFrame/Localizable.xcstrings +++ b/OwnFrame/Localizable.xcstrings @@ -429,7 +429,7 @@ "de" : { "stringUnit" : { "state" : "translated", - "value" : "Prüfe die Verbindung zu deinem Immich-Server und versuche es erneut. Erneuter Versuch automatisch im Hintergrund." + "value" : "Prüfe die Verbindung zu deinem Immich-Server und versuche es erneut. Es wird automatisch im Hintergrund erneut versucht." } } } @@ -440,7 +440,7 @@ "de" : { "stringUnit" : { "state" : "translated", - "value" : "Prüfe deine Verbindungseinstellungen — der API-Schlüssel oder der geteilte Link ist möglicherweise abgelaufen. Erneuter Versuch automatisch im Hintergrund." + "value" : "Prüfe deine Verbindungseinstellungen — der API-Schlüssel oder der geteilte Link ist möglicherweise abgelaufen. Es wird automatisch im Hintergrund erneut versucht." } } } @@ -1727,7 +1727,7 @@ "de" : { "stringUnit" : { "state" : "translated", - "value" : "Fotobild an Home Assistant senden" + "value" : "Foto an Home Assistant senden" } } } diff --git a/Packages/PurchaseKit/Sources/PurchaseKit/Localizable.xcstrings b/Packages/PurchaseKit/Sources/PurchaseKit/Localizable.xcstrings index b6c4cbe3..e26e9b92 100644 --- a/Packages/PurchaseKit/Sources/PurchaseKit/Localizable.xcstrings +++ b/Packages/PurchaseKit/Sources/PurchaseKit/Localizable.xcstrings @@ -84,7 +84,7 @@ "de" : { "stringUnit" : { "state" : "translated", - "value" : "Helligkeit, Album und Pause oder Wiedergabe über MQTT — mit integrierter Erkennung und Verfügbarkeit." + "value" : "Helligkeit, Album und Pause oder Wiedergabe über MQTT — automatisch eingebunden, mit Verfügbarkeitssensor." } } }