The decode-failure freeze (BULLETPROOFING.md L3) is production-reachable after all - a phone app can update days before the watch app auto-updates, and every push in between fails to decode, silently freezing the watch at its last good sync. The bridge now tracks schemaMismatch (set on a failed decode, cleared on the next good apply) and ActiveWorkoutGateView shows an "Update both apps to resume sync" banner while it's set. Also give requestSync an error handler that logs the dropped pull (L2; deliberately no retry - the activation/reachability edges re-pull), and sync DEVICE-COMMUNICATION.md to the post-bulletproofing reality (T1 as optimization, session recovery, degraded pushes, surfaced mismatch). Claude-Session: https://claude.ai/code/session_01PVNBVKp5bcq52X722uMjwT
162 lines
20 KiB
Markdown
162 lines
20 KiB
Markdown
# Device Communication: iPhone ↔ Apple Watch
|
||
|
||
A low-level, exhaustive reference for **every** way the phone and the watch exchange data — what triggers each exchange, the exact datapoints on the wire, the transport/path, and the internal structures each device mutates as a result.
|
||
|
||
> Scope: only cross-device (phone↔watch) communication. Phone-internal iCloud/SwiftData mechanics and phone-only surfaces (widgets, Live Activity) are covered only where they *trigger* a cross-device exchange.
|
||
|
||
---
|
||
|
||
## 0. Mental model (read this first)
|
||
|
||
Three facts govern everything below:
|
||
|
||
1. **The watch never touches iCloud.** It has no ubiquity container, no `SyncEngine`, no CloudKit. Its entire world is a local SwiftData cache fed **only** by pushes from the phone. So **no datapoint ever travels phone↔watch "via iCloud."** Every cross-device byte rides one of four transports in §1.
|
||
2. **The phone is the sole writer of iCloud Drive.** The watch round-trips domain documents *through* the phone: watch → phone (WatchConnectivity) → iCloud file → phone cache → authoritative echo back to the watch.
|
||
3. **iCloud is a *source*, not a *path*.** A change that arrives on the phone from iCloud (edited on another device, or downloaded after being offloaded) lands in the phone cache and fires the same `onCacheChanged` → `pushAll` that a local edit does. So iCloud changes reach the watch, but always *re-transported* as a WatchConnectivity application-context push — never seen by the watch directly.
|
||
|
||
```mermaid
|
||
flowchart TB
|
||
iCloud[("iCloud Drive<br/>· other devices ·")]
|
||
|
||
subgraph phone ["iPhone — sole iCloud writer"]
|
||
direction TB
|
||
SE["SyncEngine"]
|
||
WL["WorkoutLauncher"]
|
||
Files["iCloud JSON files<br/>Splits · Workouts · Stubs"]
|
||
MO["MetadataObserver"]
|
||
CM["CacheMapper"]
|
||
Cache[("SwiftData cache")]
|
||
OCC["onCacheChanged"]
|
||
PB["PhoneConnectivityBridge"]
|
||
IFW["ingestFromWatch"]
|
||
|
||
SE --> Files --> MO --> CM --> Cache --> OCC --> PB
|
||
IFW --> SE
|
||
end
|
||
|
||
subgraph watch ["Apple Watch — no iCloud, no SyncEngine"]
|
||
direction TB
|
||
WB["WatchConnectivityBridge"]
|
||
WCA["WatchCacheApplier"]
|
||
WCache[("SwiftData cache")]
|
||
OWC["onWorkoutsChanged"]
|
||
Coord["WorkoutSessionCoordinator"]
|
||
WSM["WorkoutSessionManager<br/>· HKWorkoutSession ·"]
|
||
|
||
WB --> WCA --> WCache --> OWC --> Coord --> WSM
|
||
end
|
||
|
||
iCloud <--> SE
|
||
|
||
PB ==>|"T2 · updateApplicationContext<br/>splits · workouts · settings · edit locks"| WB
|
||
WSM ==>|"T3 / T4 · workoutUpdate<br/>one WorkoutDocument"| IFW
|
||
WL -.->|"T1 · startWatchApp(toHandle:)<br/>HKWorkoutConfiguration"| WSM
|
||
WB -.->|"T3 · requestSync"| PB
|
||
PB -.->|"T3 · live-run frame<br/>phone drives → watch mirrors"| WB
|
||
WB -.->|"T3 · live-run frame<br/>watch drives → phone mirrors"| PB
|
||
```
|
||
|
||
*Edge encoding: **thick solid** = durable, authoritative data transfer (T2 state push, T3/T4 forwarded edit); **dotted** = best-effort / ephemeral triggers (T1 launch handoff, T3 `requestSync`, T3 live-run mirror). `iCloud ↔ SyncEngine` is phone-internal — the watch has no edge to iCloud.*
|
||
|
||
---
|
||
|
||
## 1. Transports (the four physical channels)
|
||
|
||
| # | Channel | API | Direction | Delivery semantics | Used for |
|
||
|---|---------|-----|-----------|--------------------|----------|
|
||
| **T1** | HealthKit launch handoff | `HKHealthStore.startWatchApp(toHandle:)` | Phone → Watch | Best-effort, one-shot; foregrounds/launches the watch app | Launching the watch into a workout + handing it an `HKWorkoutConfiguration` |
|
||
| **T2** | WC application context | `WCSession.updateApplicationContext(_:)` | Phone → Watch | **Durable, latest-wins, single slot.** Delivered even if the watch is asleep/offline; the system holds the *latest* context and hands it over when the watch next activates (`receivedApplicationContext`) | Authoritative full state: splits, workouts, settings, edit locks |
|
||
| **T3** | WC message | `WCSession.sendMessage(_:replyHandler:errorHandler:)` | Both ways | **Immediate, reachable-only.** Fails if the peer isn't reachable | `requestSync`, watch→phone `workoutUpdate` (with T4 fallback), live-run frames |
|
||
| **T4** | WC user-info transfer | `WCSession.transferUserInfo(_:)` | Both ways | **Queued, guaranteed, FIFO.** Survives offline; delivered in order when possible | Fallback for watch→phone `workoutUpdate` when T3 isn't reachable |
|
||
|
||
Wire format for T2–T4 lives in `Shared/Connectivity/WCPayload.swift`. Documents ride as JSON-encoded `Data` blobs (`DocumentCoder`, ISO-8601 dates); live-run `Date`s ride as **native plist values** to keep sub-second timer precision (`WCPayload.swift:83-94`).
|
||
|
||
---
|
||
|
||
## 2. Phone → Watch exchanges
|
||
|
||
| Event / trigger | Datapoints on the wire | Transport & path | Internal structures updated | Notes |
|
||
|---|---|---|---|---|
|
||
| **P1 — Start a workout** (the split picker is the sole start path — `SplitPickerSheet.start(with:)`, `WorkoutLogsView.swift:283-312`) | `HKWorkoutConfiguration { activityType (from the split), locationType = .indoor }` | **T1** (HealthKit, direct — *not* WatchConnectivity) | **Watch:** `WatchAppDelegate.handle(_:)` → `WorkoutSessionManager.start(with:)` creates `HKWorkoutSession` + `HKLiveWorkoutBuilder`, `session.startActivity`, `builder.beginCollection`, `isRunning = true`. **Phone:** requests workout-share auth inline (`WorkoutLauncher.swift:38`). | Best-effort; no-ops without HealthKit or a reachable watch. The running `HKWorkoutSession` is what makes watchOS re-foreground the app on every wrist raise. Starting also mints a `WorkoutDocument` → triggers **P3** as a separate exchange. T1 is a *fast-launch optimization*, not the only session source: the watch self-starts a session from any reconcile that finds an active run with none running, and recovers a crash-orphaned one at launch (BULLETPROOFING.md H1). |
|
||
| **P3 — Any phone cache mutation → full-state push** | `splits[]` (all `SplitDocument`, by `order`; each embeds its `ExerciseDocument`s) · `workouts[]` (every active [`notStarted`/`inProgress`] run **in full**, + ≤25 `completed` within 24 h; each embeds its `WorkoutLogDocument`s) · `restSeconds` · `doneCountdownSeconds` · `weightUnit` · `editingWorkoutID?` · `editingSplitID?` | **T2** (`pushAll` → `updateApplicationContext`), phone → watch | **Watch:** `WatchCacheApplier.apply` upserts every split/workout via `CacheMapper` **and prunes anything absent** → SwiftData cache; sets `lastSyncDate`; writes settings to `UserDefaults`; sets edit-lock state; fires `onWorkoutsChanged` → `WorkoutSessionCoordinator.reconcile()` (may end the `HKWorkoutSession`). `@Query` views refresh. | The workhorse. `onCacheChanged` fires on **every** cache change — local saves/deletes *and* remote iCloud deltas from `MetadataObserver` and `ingestFromWatch` (`SyncEngine.swift:290,301,307,323,378,465,725`). Active runs sent uncapped, so **"absent ⇒ no longer active"** — this is what prunes a discarded/deleted run and lets the coordinator end the session. Decode is **all-or-nothing**: one undecodable element → `nil` → `applyState` skips upsert/prune/reconcile entirely (schema-mismatch stale-cache trap, §5). Guarded on `activated && isPaired && isWatchAppInstalled`. |
|
||
| **P4 — Phone opens/closes an exercise editor** (`ExerciseView.swift:75,78`) | Same context as P3; salient field is `editingWorkoutID` (the workout's id, or absent = cleared) | **T2** (`setEditingWorkout` → immediate `pushAll`) | **Watch:** `editingWorkoutID` set; `ActiveWorkoutGateView` dims that run's row, blocks entry, and pops out if the user is inside it (`isLockedForEditing`). | Exclusive-edit lock: only one device drives a given run at a time, so the watch can't forward a stale optimistic write over a phone edit. Pushed immediately (doesn't wait for a cache change). |
|
||
| **P5 — Phone opens/closes a split detail/editor** (`SplitDetailView.swift:54,55`) | Same context; salient field is `editingSplitID` | **T2** (`setEditingSplit` → immediate `pushAll`) | **Watch:** `editingSplitID` set; any run whose `splitID` matches is parked (same UI treatment as P4). | Matches runs by source split rather than by run id. |
|
||
| **P6 — Settings changed** (rest / done-countdown / weight unit; `SettingsView.swift:188-190`) | Full P3 context; salient fields are `restSeconds`, `doneCountdownSeconds`, `weightUnit` | **T2** (`onChange` → `pushAll`) | **Watch:** `UserDefaults` updated — drives the watch's rest timer, the done countdown, and weight display units. | Settings otherwise ride along in every P3 push; these `onChange`s guarantee an immediate push even with no cache change. |
|
||
| **P7 — Live-run mirror frame** (phone is the *driver*; run-flow transitions in `RunFlowView`/`LiveRunCoverView`, wired at `WorkoutLogListView.swift:251-252`) | `LiveProgress { workoutID, logID, exerciseName, phase (ready/work/rest/finish), setIndex, setCount, detail, phaseStart, phaseEnd?, version }` | **T3** (`sendMessage`, reachable-only), staged depth-1 latest-wins with backoff retry + reconnect re-send | **Watch:** `applyIncomingLive` → `liveIncoming` (ephemeral, **never persisted**); presents a follower cover (`LiveRunCoverView`) or follows inline if `navigatedRunID` matches. `version` drops stale/out-of-order frames. | Ephemeral mirror — touches neither SwiftData nor HealthKit. Sent only on *human* transitions, not auto-advance ticks. `sendLiveEnded` (T3) tells the watch to stop mirroring. Bidirectional design — see W3. |
|
||
| **P8 — Activation / reachability regained** (`activationDidCompleteWith`, `sessionReachabilityDidChange` → reachable) | Full P3 context (+ re-flush of any staged live frame) | **T2** `pushAll` + **T3** `flushLive` | (Same as P3 / P7 on the watch) | Catch-up so a cold launch or a WC drop can't leave the watch stale. |
|
||
| **P9 — Phone answers a `requestSync`** (received from watch, `route` → `requestSyncType`) | Full P3 context | **T2** `pushAll` | (Same as P3) | Pull path: the watch asks, the phone re-pushes authoritative state. |
|
||
|
||
## 3. Watch → Phone exchanges
|
||
|
||
| Event / trigger | Datapoints on the wire | Transport & path | Internal structures updated | Notes |
|
||
|---|---|---|---|---|
|
||
| **W1 — Watch edits a workout** (check off a set, edit reps/weight, notes, complete/skip an exercise, complete the workout; `WorkoutLogListView.swift:124,166`, `LiveRunCoverView.swift:80`) | One full `WorkoutDocument` (the edited run, embedding all its `WorkoutLogDocument`s) | **T3** `sendMessage` if reachable, **else T4** `transferUserInfo`; on a T3 send *error* it also falls back to T4 | **Watch (first, optimistic):** `CacheMapper.upsertWorkout` into SwiftData + `save` + `onWorkoutsChanged` (coordinator reconcile). **Phone:** `route` → `SyncEngine.ingestFromWatch` → (a) tombstone/pending-delete **veto** → don't resurrect, re-push authoritative (P3); else (b) **per-log merge** (`WorkoutMergePlanner`) vs cache → if changed, `save(workout: merged)` → iCloud file → observer → cache → `onCacheChanged` → **P3 echoes the merged doc back**. | Optimistic-then-reconcile. T3/T4 are **unordered**, and both devices may edit the same run, so the phone merges **per log** (newest per-log `updatedAt` wins; phone-authored `deletedLogIDs` tombstones resolve absent logs) — edits to different exercises commute regardless of delivery order (H1 fix). A no-op merge still re-pushes so the watch corrects. |
|
||
| **W2 — Watch ends the `HKWorkoutSession`** (coordinator decides `.finish`; `WorkoutSessionCoordinator.swift:107-118`) | The completed `WorkoutDocument` with `metrics` attached: `WorkoutMetrics { activeEnergyKcal, avgHeartRate, maxHeartRate, minHeartRate, totalVolume (from logs), hrZoneSeconds, healthKitWorkoutUUID, source = .watch, recordedAt }` | **HealthKit** (local save to Health on the watch) **+ T3/T4** to the phone (via the W1 path) | **Watch:** `WorkoutSessionManager.finishAndSave` → `session.end()`, `builder.endCollection` + `finishWorkout` → **writes a real `HKWorkout` to Health** (HR, active energy); `isRunning = false`, session/builder cleared. Coordinator attaches metrics, then `bridge.update(workout:)` (W1). | **The only place a real `HKWorkout` is written to Health** (phone recording was made watch-only). Fires from durable cache state, not a view, so it runs even backgrounded. `.discard` variant: `session.end()` + `discardWorkout()` — nothing saved to Health, nothing forwarded. |
|
||
| **W3 — Live-run mirror frame** (watch is the *driver*; `WorkoutLogListView.swift:125-126`, `LiveRunCoverView.swift:56-58`) | `LiveProgress { … }` (identical shape to P7) | **T3** `sendMessage`, same staging/retry as P7 | **Phone:** `applyIncomingLive` → `LiveRunState.current` (ephemeral); a propped-up phone mirrors it (cover or inline). | Symmetric to P7 — whichever device drives sends, the other mirrors. (`LiveRunState`'s doc comment still says "watch-drives / phone-mirrors," but both directions are wired.) `sendLiveEnded` (T3) stops the phone mirror. |
|
||
| **W4 — Watch requests a sync** (`activate`, `activationDidCompleteWith`, `ActiveWorkoutGateView` appears with no active runs, manual **Refresh** button; `WatchConnectivityBridge.swift:94,321`, `ActiveWorkoutGateView.swift:72,112`) | `{ type: requestSync }` (no payload) | **T3** `sendMessage` (reachable-only) | **Phone:** answers with **P9** (`pushAll`). | Pull trigger. Skipped silently if the phone isn't reachable; the eager `receivedApplicationContext` read + P8 cover the cold-launch case. |
|
||
|
||
---
|
||
|
||
## 4. Internal-state reference (what lives where, and who mutates it)
|
||
|
||
### Apple Watch
|
||
|
||
| Structure | Type | Fed by | Notes |
|
||
|---|---|---|---|
|
||
| SwiftData cache (`Split`, `Workout`, `WorkoutLog`) | Rebuildable local cache | P3 (authoritative upsert **+ prune**) and W1 (optimistic self-edit) | The watch never originates a split/workout; pruning can't lose local-only data. This is the set `ActiveWorkoutGateView` renders. |
|
||
| `HKWorkoutSession` + `HKLiveWorkoutBuilder` | HealthKit runtime | Started by P1/T1 **or self-started by the coordinator** (active run with no session); recovered at launch after a crash/reboot; ended by W2 (or salvaged into a Health save on a system-driven end) | `isRunning == true` is what makes watchOS re-foreground the app on wrist raise. Collects HR + active energy, accumulates `hrZoneSeconds`. |
|
||
| `WorkoutSessionCoordinator.previouslyActiveIDs` | Baseline set | Recomputed every `reconcile()` (each P3/W1 mutation + bootstrap) | The non-empty→empty transition of the active set is what ends the session. Empty baseline at launch is what makes the launch race safe. |
|
||
| `UserDefaults` (`restSeconds`, `doneCountdownSeconds`, `weightUnit`) | Settings | P3/P6 | Drives the watch's rest timer, done countdown, weight units. |
|
||
| Edit locks (`editingWorkoutID`, `editingSplitID`) | Published lock | P4/P5 | Park a run and block re-entry so the phone owns the edit. |
|
||
| Live-mirror consumer (`liveIncoming`, `mutedLogID`, `navigatedRunID`) | Ephemeral | P7 | Never persisted; drives the follower cover. |
|
||
|
||
### iPhone
|
||
|
||
| Structure | Type | Fed by | Notes |
|
||
|---|---|---|---|
|
||
| iCloud Drive JSON files (`Splits/…`, `Workouts/YYYY/MM/…`, `Stubs/…`) | **Source of truth** | `SyncEngine.save`/`delete`, including W1's merged echo | The phone is the sole writer. Deletes leave a `Tombstone` stub (veto against resurrection). |
|
||
| SwiftData cache | Rebuildable read-through | `MetadataObserver` → `CacheMapper` (one-way: files → observer → cache) | Never written directly by views; W1's inbound path writes a *file*, not the cache. |
|
||
| `HKHealthStore` auth | Workout-share scope | P1 (`startWatchApp` requires it) | The phone does **not** write `HKWorkout`s — that's watch-only (W2). |
|
||
| `LiveRunState` | Ephemeral mirror | W3 | Phone-side follower for a watch-driven run. |
|
||
| `editingWorkoutID` / `editingSplitID` | Published lock | P4/P5 (also re-sent in every `pushAll`) | Latest-wins context replaces wholesale, so they're included in every push to avoid clearing the lock prematurely. |
|
||
| Live-mirror staging (`pendingLive`, retry task) | Ephemeral | P7 | Depth-1 latest-wins; re-sent on reconnect. |
|
||
|
||
---
|
||
|
||
## 5. End-to-end sequences (putting it together)
|
||
|
||
**Start a workout on the phone**
|
||
1. `start()` mints a `notStarted` `WorkoutDocument`, `sync.save` writes the iCloud file.
|
||
2. **T1**: `launchWatchWorkout` → `startWatchApp` → watch launches, `WorkoutSessionManager.start` runs an `HKWorkoutSession` (`isRunning = true`).
|
||
3. File write → observer → phone cache → `onCacheChanged` → **P3/T2** pushes the new run.
|
||
4. Watch upserts it; the run appears in `ActiveWorkoutGateView`.
|
||
|
||
**Complete a set on the watch**
|
||
1. Watch optimistically upserts the `WorkoutLog` change (SwiftData) and — **W1/T3** (or T4) — forwards the whole `WorkoutDocument`.
|
||
2. Phone `ingestFromWatch` → per-log merge → `save` → iCloud file → observer → cache → `onCacheChanged`.
|
||
3. **P3/T2** echoes the authoritative merged doc back; watch reconciles to it.
|
||
|
||
**Finish the workout**
|
||
1. The final completion flips the run to `.completed` (on whichever device drove it) and propagates (P3 or W1).
|
||
2. On the watch, `reconcile()` sees active set non-empty→empty with a completed survivor → **W2**: `finishAndSave` writes the `HKWorkout` to Health, `isRunning = false` → app stops re-surfacing.
|
||
3. Metrics-laden doc goes back to the phone (**W1**) → iCloud → echo.
|
||
|
||
**Delete a workout on the phone**
|
||
1. `sync.delete` writes a `Stubs/<id>.json` tombstone and removes the live file → cache row removed → `onCacheChanged`.
|
||
2. **P3/T2**: the run is now **absent** from `workouts[]` → `WatchCacheApplier` prunes it → active set empties → coordinator ends any running session.
|
||
3. If the watch had queued a stale edit for it (W1), `ingestFromWatch` sees the tombstone, **vetoes** resurrection, and re-pushes authoritative state.
|
||
|
||
---
|
||
|
||
## 6. Failure modes & gotchas
|
||
|
||
- **Schema-mismatch stale cache.** If the installed watch binary is older than the phone's, it can't decode the phone's `WorkoutDocument`/`SplitDocument` payloads. `WCPayload.decodeWorkouts` is all-or-nothing → `nil` → `applyState` **skips upsert, prune, and reconcile** (`WatchConnectivityBridge.swift`) — the watch's cache freezes at the last successful decode. This *is* production-reachable (the phone app can update before the watch app auto-updates), so the state is surfaced: `schemaMismatch` drives an "Update both apps to resume sync" banner in `ActiveWorkoutGateView`. **Recovery:** update/rebuild the watch app so both are on the same document schema (currently `WorkoutDocument` v5, `SplitDocument` v3); the `VersionedDocument` forward-gate covers files.
|
||
- **`updateApplicationContext` is single-slot.** Only the *latest* state is delivered; intermediate pushes coalesce. Fine because the context is authoritative full state, not a delta. A push that fails outright (realistically payload-too-large) retries with the recently-completed tail dropped, so an oversized context degrades instead of freezing the watch.
|
||
- **T3 is reachable-only.** Live frames (P7/W3) and `requestSync` (W4) silently no-op when unreachable; durable state (T2) and forwarded edits (W1's T4 fallback) still get through.
|
||
- **Unordered edit delivery.** T3 vs T4 can arrive out of order, and both devices may edit at once — handled by the per-log merge (W1), not by whole-document `updatedAt`.
|
||
- **The launch race is deliberately safe.** A phone-launched session can be `isRunning` before its run document has synced; `SessionEndPlanner.decide` guards with `!previouslyActiveIDs.isEmpty` so a not-yet-synced run is never mistaken for a finished one.
|
||
|
||
---
|
||
|
||
*Source anchors — Phone: `Workouts/Connectivity/PhoneConnectivityBridge.swift`, `Workouts/HealthKit/WorkoutLauncher.swift`, `Workouts/Sync/SyncEngine.swift`, `Workouts/Sync/WorkoutMergePlanner.swift`, `Workouts/Connectivity/LiveRunState.swift`. Watch: `Workouts Watch App/Connectivity/WatchConnectivityBridge.swift`, `Workouts Watch App/WorkoutSessionManager.swift`, `Workouts Watch App/WorkoutSessionCoordinator.swift`, `Workouts Watch App/WatchAppDelegate.swift`. Shared: `Shared/Connectivity/WCPayload.swift`, `Shared/Connectivity/LiveProgress.swift`.*
|