Files
wristcode/watch

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; WristCode/API/Models.swift mirrors gateway/src/contract.ts.

Build and test

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
scripts/testflight.sh              # archive, export and upload to App Store Connect
scripts/testflight.sh --no-upload  # archive and export an .ipa to build/export-<n> 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. The SwiftUI previews use it, the pairing screen has a "Try the demo" button, and launch arguments open a screen directly:

xcrun simctl launch <udid> org.gbrown.wristcode.watchkitapp -demo                                # feed
xcrun simctl launch <udid> org.gbrown.wristcode.watchkitapp -demo -thread littleman/t-next       # thread
xcrun simctl launch <udid> org.gbrown.wristcode.watchkitapp -demo -screen new                    # new thread
xcrun simctl launch <udid> org.gbrown.wristcode.watchkitapp -demo -screen status                 # status overview
xcrun simctl launch <udid> org.gbrown.wristcode.watchkitapp -demo -host laptop                   # one environment
xcrun simctl launch <udid> org.gbrown.wristcode.watchkitapp -demo -screen notifications          # notifications

screenshots/ holds the screens captured this way. The complication, the multi-select question and the status screens are rendered by SnapshotTests instead, which only run when given a directory:

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.

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, Send test, the gateway, and Unpair. Each screen loads once when it appears; nothing polls.

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, 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