# 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
· other devices ·")] subgraph phone ["iPhone — sole iCloud writer"] direction TB SE["SyncEngine"] WL["WorkoutLauncher"] Files["iCloud JSON files
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
· HKWorkoutSession ·"] WB --> WCA --> WCache --> OWC --> Coord --> WSM end iCloud <--> SE PB ==>|"T2 · updateApplicationContext
splits · workouts · settings · edit locks"| WB WSM ==>|"T3 / T4 · workoutUpdate
one WorkoutDocument"| IFW WL -.->|"T1 · startWatchApp(toHandle:)
HKWorkoutConfiguration"| WSM WB -.->|"T3 · requestSync"| PB PB -.->|"T3 · live-run frame
phone drives → watch mirrors"| WB WB -.->|"T3 · live-run frame
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/.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`.*