Files
workouts/WATCH-SYNC.md
T
rzen a8d90b638b Anchor auto-advanced phases at the boundary, not at tick time
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.
2026-07-09 05:58:32 -04:00

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.
  • SettingsrestSeconds, doneCountdownSeconds, weightUnit (from the phone's UserDefaults; the watch writes them into its own).
  • Edit lockseditingWorkoutID / 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:

  1. 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.
  2. Pending-delete veto — same veto for a queued delete whose stub hasn't landed yet.
  3. updatedAt intake 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 at ingestFromWatch (durable fix would be per-log merge).
  4. An accepted document is written to iCloud Drive and upserted into the cache, which fires onCacheChangedpushAll() — 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 sendMessage only (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 liveEnded replaces 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 currentStateIndex against 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 — DocumentCoder is 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.