# WristCode watch app SwiftUI, watch only, Swift 6 with strict concurrency, and no dependencies. It talks to the gateway over HTTPS + JSON as described in [docs/api.md](../docs/api.md); `WristCode/API/Models.swift` mirrors [gateway/src/contract.ts](../gateway/src/contract.ts). ## Build and test ```sh cd watch xcodegen generate # the .xcodeproj is generated and gitignored xcodebuild -scheme WristCode -destination 'platform=watchOS Simulator,name=Apple Watch Series 12 (46mm)' build xcodebuild -scheme WristCode -destination 'platform=watchOS Simulator,name=Apple Watch Series 12 (46mm)' test ``` ## TestFlight The App Store only takes a watch-only app inside an empty iOS stub, so `project.yml` has two app targets, the same pair Xcode's "Watch-only App" template makes: | Target | Bundle ID | Job | | --- | --- | --- | | `WristCodeContainer` | `org.gbrown.wristcode` | iOS stub that owns the App Store Connect record and embeds the watch app | | `WristCode` | `org.gbrown.wristcode.watchkitapp` | The watch app, and the APNs topic | | `WristCodeWidget` | `org.gbrown.wristcode.watchkitapp.widget` | The complication, embedded in the watch app | ```sh scripts/testflight.sh # archive, export and upload to App Store Connect scripts/testflight.sh --no-upload # archive and export an .ipa to build/export- only ``` The build number defaults to a `YYYYMMDDHHMM` timestamp (override it with `BUILD_NUMBER`). Signing is automatic through the App Store Connect API key in `ASC_KEY_PATH`, `ASC_KEY_ID` and `ASC_ISSUER_ID`, which default to the key in `~/.appstoreconnect/private_keys`. The entitlements file says `aps-environment: development`; the App Store export re-signs it as `production`. Release builds register their token with the gateway as `production`, Debug builds as `sandbox`. ## Demo mode `DemoGateway` serves fixture data shaped like the docs/api.md examples, and it reacts to writes: Allow moves a thread to working, a reply restarts a finished thread, and Settle hides a thread from the feed. One card carries a subagent's request, so its Allow goes to the subagent's thread. Recent holds 14 threads, so the feed shows its first 10 and a "4 more" row. The SwiftUI previews use it, the pairing screen has a "Try the demo" button, and launch arguments open a screen directly: ```sh xcrun simctl launch org.gbrown.wristcode.watchkitapp -demo # feed xcrun simctl launch org.gbrown.wristcode.watchkitapp -demo -thread littleman/t-next # thread xcrun simctl launch org.gbrown.wristcode.watchkitapp -demo -screen new # new thread xcrun simctl launch org.gbrown.wristcode.watchkitapp -demo -screen recent # full Recent list xcrun simctl launch org.gbrown.wristcode.watchkitapp -demo -screen status # status overview xcrun simctl launch org.gbrown.wristcode.watchkitapp -demo -host laptop # one environment xcrun simctl launch org.gbrown.wristcode.watchkitapp -demo -screen notifications # notifications ``` `screenshots/` holds the screens captured this way. The complication, the multi-select question, the status screens, the subagent card and thread, Recent with Settle all and the "N more" row, the full Recent list, a finished thread with Settle, a running thread with Ask before acting, and New thread with its model list are rendered by `SnapshotTests` instead, which only run when given a directory: ```sh TEST_RUNNER_WC_SCREENSHOT_DIR=$PWD/screenshots xcodebuild -scheme WristCode \ -destination 'platform=watchOS Simulator,name=Apple Watch Series 12 (46mm)' test \ -only-testing:WristCodeTests/SnapshotTests ``` In demo mode the complication shows the demo counts too. ## Subagents and settling A thread an agent started for another thread has no card of its own. When it waits on you, the gateway puts its request on the parent's card and names it in `via`. The card says "Subagent wants to run:" or "asks:", and the parent's thread screen shows the request first, labeled "From subagent". Every answer goes to the subagent's thread. Settle clears a finished or failed thread from the feed, as in T3. Recent keeps every finished or failed thread until it is settled, however old. The feed shows the newest 10, then an "N more" row that opens the full Recent list (`GET /v1/recent`, loaded when the screen appears). Swipe a Recent row left, here or on the full list, or tap Settle under Reply on the thread screen. A row leaves at once and comes back with the error if its call fails. Settle all, on the Recent header and the full list, is one `POST /v1/recent/settle` for every completed thread in Recent, including those past the first 10. Failed threads stay. The completed rows leave at once, and the header says so when the call fails or some threads did not settle. ## Status and settings The gear at the top left of the feed opens the status overview (`GET /v1/status`). It leads with "All connected" or "Needs attention", lists only the issues worst first (offline hosts, a host on a fallback route, tokens expiring within 7 days, push problems), and folds the healthy hosts into one row. Each host opens its route ladder with Reconnect. The Notifications row shows whether this watch is registered, the last push, a switch per kind of push (Needs me, Finished, Failed, Subagents ask), Send test, the gateway, and Unpair. A switch flips at once and sends only its own key with `PUT /v1/device/notifications`. If that fails, it flips back and the error shows under the switches. Host problems always notify. Each screen loads once when it appears; nothing polls. ## Models and Ask before acting On New thread, the Agent row opens every model the host offers, grouped by provider, with the project default's provider first. The watch remembers the last pick per host and falls back to the project's default when the host stops offering it. It sends `model` only when the pick differs from the default, so the default keeps its options, such as reasoning effort. The Ask before acting switch under it remembers its last value. It is hidden, and the thread starts with `askFirst: false`, when the provider cannot ask first. A thread's screen has the same switch between Reply and Stop. It flips at once and posts `/v1/threads/:hostId/:threadId/mode`. If that fails, it flips back and the error shows under it. Against a gateway older than these routes, the switches and the model list stay hidden. ## Complication `WristCodeWidget` shows how many threads wait on you, from `GET /v1/summary`, in the circular, rectangular, inline and corner families. Tapping it opens the app, at the longest-waiting thread when there is one (`wristcode://thread/<hostId>/<threadId>`). WidgetKit refreshes it about every 15 minutes; the app reloads it sooner when its feed counts change, when a push arrives in the foreground, and after a notification action. The app and the extension share the Keychain access group `<team>.org.gbrown.wristcode.shared`, which holds the device token, gateway URL, demo flag and last summary. It is not an App Group because automatic signing with an App Store Connect API key can register bundle IDs and profiles but cannot create or assign App Groups; a Keychain group needs no portal setup. Builds before the complication kept the token in the app's own Keychain group; `DeviceStore.migrateToSharedGroup()` moves it on launch, so a paired watch stays paired. To add it: long-press the watch face, Edit, swipe to Complications, pick a slot, then WristCode. ## Layout | Path | Job | | --- | --- | | `WristCodeApp.swift` | App entry, root navigation, `WKApplicationDelegate` for APNs registration | | `AppModel.swift` | Paired, demo or unpaired; navigation path; the feed; 401 sends you back to pairing | | `API/GatewayClient.swift` | `GatewayAPI` protocol and the URLSession client | | `API/Models.swift` | Codable mirror of the contract | | `API/Route.swift` | Screens the app can open, and their `wristcode://` URLs | | `Auth/DeviceStore.swift` | Device token and gateway URL in the shared Keychain group | | `Shared/SharedState.swift` | Demo flag and last summary in the shared Keychain group, for the complication | | `Demo/DemoGateway.swift` | In-memory gateway with fixtures | | `Views/` | Pair, Feed, full Recent list, Thread, New thread, Status, and the mock B theme | | `Views/StatusReport.swift` | What the status overview says: headline, issues worst first, healthy hosts | | `Notifications/NotificationHandler.swift` | Categories `approval`, `finished`, `failed`, `input`, `host`, `test` and their actions | | `../WristCodeWidget/` | The complication: timeline provider, and one view per family |