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
20 KiB
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:
- 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. - 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.
- 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→pushAllthat 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.
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 Dates 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 ExerciseDocuments) · workouts[] (every active [notStarted/inProgress] run in full, + ≤25 completed within 24 h; each embeds its WorkoutLogDocuments) · 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 onChanges 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 WorkoutLogDocuments) |
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 HKWorkouts — 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
start()mints anotStartedWorkoutDocument,sync.savewrites the iCloud file.- T1:
launchWatchWorkout→startWatchApp→ watch launches,WorkoutSessionManager.startruns anHKWorkoutSession(isRunning = true). - File write → observer → phone cache →
onCacheChanged→ P3/T2 pushes the new run. - Watch upserts it; the run appears in
ActiveWorkoutGateView.
Complete a set on the watch
- Watch optimistically upserts the
WorkoutLogchange (SwiftData) and — W1/T3 (or T4) — forwards the wholeWorkoutDocument. - Phone
ingestFromWatch→ per-log merge →save→ iCloud file → observer → cache →onCacheChanged. - P3/T2 echoes the authoritative merged doc back; watch reconciles to it.
Finish the workout
- The final completion flips the run to
.completed(on whichever device drove it) and propagates (P3 or W1). - On the watch,
reconcile()sees active set non-empty→empty with a completed survivor → W2:finishAndSavewrites theHKWorkoutto Health,isRunning = false→ app stops re-surfacing. - Metrics-laden doc goes back to the phone (W1) → iCloud → echo.
Delete a workout on the phone
sync.deletewrites aStubs/<id>.jsontombstone and removes the live file → cache row removed →onCacheChanged.- P3/T2: the run is now absent from
workouts[]→WatchCacheApplierprunes it → active set empties → coordinator ends any running session. - If the watch had queued a stale edit for it (W1),
ingestFromWatchsees 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/SplitDocumentpayloads.WCPayload.decodeWorkoutsis all-or-nothing →nil→applyStateskips 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:schemaMismatchdrives an "Update both apps to resume sync" banner inActiveWorkoutGateView. Recovery: update/rebuild the watch app so both are on the same document schema (currentlyWorkoutDocumentv5,SplitDocumentv3); theVersionedDocumentforward-gate covers files. updateApplicationContextis 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
isRunningbefore its run document has synced;SessionEndPlanner.decideguards with!previouslyActiveIDs.isEmptyso 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.