DEVICE-COMMUNICATION.md: exhaustive reference for every phone<->watch exchange - triggers, wire datapoints, transports (T1-T4), and the internal structures each side mutates - plus end-to-end sequences and failure modes. BULLETPROOFING.md: ranked gap analysis (H1-H3 session lifecycle, M1-M4 stranding/stale-UI traps, L-tier) with code evidence, failure scenarios, fix directions, and a recommended fix order. Claude-Session: https://claude.ai/code/session_01PVNBVKp5bcq52X722uMjwT
19 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. |
| 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; ended by W2 | 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 (the big one). 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; a runningHKWorkoutSessioncan then never be ended (its terminating signal — active set → empty — can't arrive), so the app re-surfaces indefinitely. Recovery: rebuild/reinstall the watch app so both are on the same document schema (currentlyWorkoutDocumentv5,SplitDocumentv3). Cannot happen to end users — both apps ship at one version, and 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.- 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.