The rest/timed-work countdown deadline is shared by both devices, but the page flip crossing it is a local ticker event — and stamping the *next* phase's anchor at Date() when that event finally ran baked a sleeping watch's lateness (throttled wrist-down ticker) into its next count-up, leaving the two devices permanently offset with nothing on the wire to correct. Auto-advances now chain the anchor instead: the finished phase's computed end (passed out of CountdownPhaseView) becomes the next page's PageAnchor, with the next window derived from it via liveSnapshot(for:at:). A device arbitrarily late to a boundary shows exactly what the on-time device shows, and a stack of missed boundaries fast-forwards itself — each chained page lands already-elapsed and advances on its own next tick, skipping the start/stop haptics for boundaries that passed while asleep (only a just-crossed boundary buzzes). The remoteAnchor* fields are generalized into one PageAnchor (remote frames and chained auto-advances are the same concept: a page whose timer counts from a known instant); the phone's Live Activity emit honors it unchanged.
9.8 KiB
Phone ↔ Watch Communication
How the iPhone app and the Watch app talk to each other. The governing rule: the iPhone is the sole writer of iCloud Drive. The watch never touches iCloud — it keeps a local SwiftData cache fed only by phone pushes, edits optimistically, and round-trips every durable change through the phone.
For a per-scenario walkthrough (phone/watch starts a split, drives an exercise, ends a run, edit locks, cold launch, …) as sequence + state diagrams — including where the watch's HealthKit session leaks — see
WATCH-SYNC-SCENARIOS.md.
Source files:
- Wire format:
Shared/Connectivity/WCPayload.swift - Phone side:
Workouts/Connectivity/PhoneConnectivityBridge.swift - Watch side:
Workouts Watch App/Connectivity/WatchConnectivityBridge.swift - Phone ingest:
SyncEngine.ingestFromWatch(Workouts/Sync/SyncEngine.swift) - Watch app launch:
Workouts/HealthKit/WorkoutLauncher.swift+Workouts Watch App/WorkoutSessionManager.swift
Channels at a glance
| Channel | Direction | WatchConnectivity API | Semantics | Payload |
|---|---|---|---|---|
| State push | Phone → Watch | updateApplicationContext |
Latest-wins, delivered even if watch is asleep | All splits + active/recent workouts (JSON Data blobs), settings, edit locks |
| Workout update | Watch → Phone | sendMessage, falling back to transferUserInfo |
Immediate when reachable; queued-guaranteed otherwise (unordered mix) | One WorkoutDocument |
| Sync request | Watch → Phone | sendMessage |
Reachable-only, best-effort | Type marker only |
| Live-run mirror | Both ways | sendMessage only |
Reachable-only + a depth-1 staged re-send on reconnect | LiveProgress frame / liveEnded marker |
| Watch app launch | Phone → Watch | — (HealthKit, not WC) | Best-effort | HKWorkoutConfiguration via startWatchApp(toHandle:) |
flowchart LR
subgraph iPhone
Views[SwiftUI views] --> SE[SyncEngine]
SE -->|writes files| ICD[(iCloud Drive)]
ICD -->|NSMetadataQuery deltas| SE
SE -->|upserts| PC[(SwiftData cache)]
SE -->|onCacheChanged| PB[PhoneConnectivityBridge]
PB --- LRS[LiveRunState]
WL[WorkoutLauncher]
end
subgraph Watch
WB[WatchConnectivityBridge]
WB -->|upsert + prune| WC[(SwiftData cache)]
WC -->|"@Query"| WViews[Watch views]
WViews -->|"update(workout:)"| WB
WSM[WorkoutSessionManager]
end
PB -->|"application context: splits + workouts + settings + edit locks"| WB
WB -->|"workoutUpdate / requestSync"| PB
PB <-->|"live-run frames (ephemeral)"| WB
WL -->|"HealthKit startWatchApp"| WSM
1. Phone → Watch: the state push
PhoneConnectivityBridge.pushAll() serializes the phone's cache into one
updateApplicationContext dictionary. Application context is latest-state-wins:
watchOS keeps only the newest dictionary and delivers it when the watch app runs, so
every push must be complete — a push that omitted a key would read as that state being
cleared.
What's in it:
- Splits — all of them, as
[SplitDocument]JSON. - Workouts — only what the watch can act on: every active run (in-progress /
not-started, uncapped) plus up to 25 completed ones from the last ~24 h, as
[WorkoutDocument]JSON. - Settings —
restSeconds,doneCountdownSeconds,weightUnit(from the phone'sUserDefaults; the watch writes them into its own). - Edit locks —
editingWorkoutID/editingSplitID: while the phone has a workout or split open in an editor, the watch parks any matching run and blocks re-entry, so only one device drives a run at a time. Absent keys mean "not editing".
When it fires: on every SyncEngine.onCacheChanged (local edits and changes
arriving from iCloud), on session activation, on reachability restored, on a watch
requestSync, and immediately on any edit-lock change.
How the watch applies it: WatchCacheApplier upserts every document sent, then
prunes anything absent — the pushed sets are authoritative, which is how a delete
on the phone propagates (the deleted workout simply stops being sent). A payload that
fails to decode (phone and watch running different document schemas) is skipped
entirely — no upsert, no prune — so a bogus empty set can never wipe real rows.
2. Watch → Phone: the workout round trip
The watch edits its local cache optimistically (so its UI is instant), then
forwards the whole updated WorkoutDocument to the phone. Transport is
sendMessage when the phone is reachable, with a transferUserInfo fallback
(guaranteed, queued, survives the watch app dying) when it isn't or the send fails.
The two are unordered relative to each other, which is why the phone gates intake
by updatedAt.
SyncEngine.ingestFromWatch on the phone:
- Tombstone veto — a workout deleted on the phone is never resurrected by a stale watch copy; the phone just re-pushes authoritative state so the watch drops it.
- Pending-delete veto — same veto for a queued delete whose stub hasn't landed yet.
updatedAtintake gate — strictly newer than the cache is accepted; strictly older means the watch is behind (re-push state to correct it); equal is a duplicate/echo (ignored). Note: this arbitrates timestamps, not content — see the known limit documented atingestFromWatch(durable fix would be per-log merge).- An accepted document is written to iCloud Drive and upserted into the cache, which
fires
onCacheChanged→pushAll()— so the watch always gets an authoritative echo.
sequenceDiagram
participant WV as Watch view
participant WB as WatchConnectivityBridge
participant PB as PhoneConnectivityBridge
participant SE as SyncEngine (phone)
participant IC as iCloud Drive
WV->>WB: update(workout doc)
WB->>WB: optimistic upsert into watch cache
alt phone reachable
WB->>PB: sendMessage(workoutUpdate)
else unreachable or send failed
WB->>PB: transferUserInfo(workoutUpdate) — queued
end
PB->>SE: ingestFromWatch(doc)
alt tombstoned or pending delete
SE-->>PB: veto — onCacheChanged only
else updatedAt strictly newer
SE->>IC: write Workouts/YYYY/MM/id.json
SE->>SE: upsert phone cache
SE-->>PB: onCacheChanged
end
PB->>WB: updateApplicationContext (authoritative echo)
WB->>WB: upsert + prune watch cache
A cold-starting watch sends requestSync (and re-applies the last received context
eagerly); the phone answers with a fresh pushAll().
3. Live-run mirror (ephemeral, both directions)
While a run's exercise flow is open on one device, that device broadcasts
LiveProgress frames — workout/log IDs, exercise name, phase, set index/count, and a
wall-clock phase anchor — so the other device can follow along (the phone's
follower cover, or the watch's, unless that run is already open there or the user
dismissed it). liveEnded tells the peer to drop the follower.
This channel is deliberately not persistence:
- Frames go over
sendMessageonly (reachable-only); they are never written anywhere. - Each side stages one pending frame (latest-wins) and re-sends it when
reachability or activation returns — a newer frame or the terminal
liveEndedreplaces whatever is staged, so a re-send is never stale. Because frames carry an absolute wall-clock anchor, a late arrival self-corrects its timers. - A send that fails while the peer is nominally reachable (no reachability edge to re-flush on) is retried with a short backoff, a handful of times per staged message.
- Receiving a frame that outranks the staged outbound one for the same run drops the staged frame — the peer has moved past it, and a reconnect re-send would yank the run backwards. Symmetrically, a delivery the staged frame outranks (late or version-collided) is ignored.
- Both sides stamp frames from a shared monotonic
version(each bumps its counter past anything it receives), so either side can drop an out-of-order delivery. The sequence isn't collision-free — after a lost frame both devices can mint the same version — so every staleness comparison tie-breaks on the frame's wall-clock anchor (LiveProgress.isNewer): the later human action wins. - Durable repair: if a frame is lost outright (retries spent, no reconnect), the
transition's durable write still lands via its own channel. The run screens compare
the absorbed doc's
currentStateIndexagainst everything they've recorded or followed, and jump forward to the first unfinished set's work page — so a lost frame degrades to a briefly-stale page, never a stuck one. - Auto-advances are never sent — both devices cross countdown boundaries independently off the shared deadline. Each crossing anchors the next phase at the finished phase's computed end, not at tick time, so a device whose ticker slept through the boundary (wrist-down throttling) shows the same next-phase timer as one that crossed on time — and a stack of missed boundaries fast-forwards itself, one tick per phase, silently (catch-up hops skip the haptics).
- Date anchors ride as native plist values, not JSON —
DocumentCoderis ISO-8601, which would round off the sub-second precision the timers need.
4. Launching the Watch app (HealthKit, not WatchConnectivity)
An iPhone app cannot foreground its Watch app via WatchConnectivity. When a workout
starts on the phone, WorkoutLauncher uses the one sanctioned path — HealthKit's
startWatchApp(toHandle:) with an HKWorkoutConfiguration — which wakes the Watch
app; there, WorkoutSessionManager starts a matching HKWorkoutSession so the watch
stays foregrounded for the duration of the run. Best-effort: it silently tolerates no
paired watch, and the session is runtime-only — it plays no part in data sync.