Files
workouts/Workouts Watch App/Connectivity/WatchConnectivityBridge.swift
T
rzen ccd5194c6c Surface phone/watch schema mismatch on the watch; log dropped pulls
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
2026-07-09 23:12:39 -04:00

387 lines
20 KiB
Swift

import Foundation
import Observation
import os
import SwiftData
import WatchConnectivity
/// Watch side of the iPhone↔Watch bridge. The watch never touches iCloud — it
/// keeps a local SwiftData cache fed only by application-context pushes from the
/// phone, updates it optimistically on local edits, and forwards changed workouts
/// to the phone (which is the sole writer of iCloud Drive).
@Observable
@MainActor
final class WatchConnectivityBridge: NSObject {
private nonisolated static let log = Logger(subsystem: "dev.rzen.indie.Workouts", category: "watch-bridge")
private let container: ModelContainer
private var session: WCSession?
/// Last time state was received from the phone (for a sync indicator).
private(set) var lastSyncDate: Date?
/// True while the phone's pushes fail to decode — the two apps are on different
/// document schemas (the phone updated first; the watch app hasn't yet). The cache is
/// deliberately left untouched in that state, so surface it (`ActiveWorkoutGateView`)
/// instead of silently showing stale workouts until the watch app updates.
private(set) var schemaMismatch = false
/// Fired after every authoritative cache mutation (a phone push applied, or the watch's
/// own optimistic `update(workout:)`), once the write is committed. The
/// `WorkoutSessionCoordinator` hangs off this to end the `HKWorkoutSession` from the
/// authoritative data rather than a view observer — see `WorkoutSessionCoordinator`.
var onWorkoutsChanged: (() -> Void)?
/// Exclusive-edit lock pushed by the phone. While set, the watch parks the matching
/// run (popping out of its progress view) and blocks re-entry, so the phone owns the
/// edit and the watch can't clobber it with a stale optimistic write. `editingWorkoutID`
/// matches a run by its workout id; `editingSplitID` matches any run by its `splitID`.
private(set) var editingWorkoutID: String?
private(set) var editingSplitID: String?
/// Monotonic sequence stamped on each live-run frame we send. Bumped to stay ahead of any
/// frame we *receive*, so the two devices share one increasing per-run sequence and either
/// side can drop a stale / out-of-order delivery (see `LiveProgress.version`).
private var liveVersion = 0
/// The latest live-run message we haven't confirmed reached the phone (depth 1, latest-wins).
/// Staged regardless of reachability and re-sent by `flushLive()` when reachability/activation
/// returns, so a brief WatchConnectivity drop doesn't desync the mirror. A newer frame — or the
/// terminal `.ended` — replaces it, so we never deliver stale state. Frames carry an absolute
/// wall-clock anchor, so a late re-send self-corrects on arrival rather than reading as stale.
private var pendingLive: PendingLive?
/// In-flight backoff retry for a staged live message whose send *failed* (as opposed to
/// never being attempted because the peer was unreachable — that case is re-flushed by the
/// reachability/activation delegates). See `scheduleLiveRetry`.
private var liveRetryTask: Task<Void, Never>?
private var liveRetryAttempt = 0
/// The latest live-run frame the *phone* sent, for the run we currently have open to apply
/// (ephemeral; nil when the phone isn't driving). The watch's `ExerciseProgressView` reads
/// this to follow a phone-driven transition; it's never persisted.
private(set) var liveIncoming: LiveProgress?
/// The run currently open in the watch's navigated driver. When the incoming frame is for
/// it, the watch follows inline there and suppresses the follower cover (so it never stacks
/// on top of a run the user already has open).
var navigatedRunID: String?
/// A run the user dismissed the follower cover for; suppressed until that run ends.
private var mutedLogID: String?
/// The frame to present as a follower cover when the phone drives a run the watch isn't
/// already showing: the latest, unless the user dismissed it or has that run open inline.
var presentable: LiveProgress? {
guard let f = liveIncoming, f.logID != mutedLogID, f.logID != navigatedRunID else { return nil }
return f
}
/// The user dismissed the follower cover; don't re-present this run until it ends.
func muteLive() { mutedLogID = liveIncoming?.logID }
private var context: ModelContext { container.mainContext }
init(container: ModelContainer) {
self.container = container
super.init()
}
func activate() {
guard WCSession.isSupported() else { return }
let session = WCSession.default
session.delegate = self
session.activate()
self.session = session
// Apply whatever the phone last pushed, then ask for a fresh push. On real
// hardware activation completes asynchronously and this eager read returns an
// empty context — `activationDidCompleteWith` re-applies it once it's valid;
// this read is just the simulator/warm-launch fast path.
applyReceivedContext()
requestSync()
}
/// Apply the last application context the system holds for us (settings, edit
/// locks, and the authoritative splits + workouts sets). Idempotent, so it's safe
/// to call both eagerly at launch and again when activation completes.
private func applyReceivedContext() {
guard let session else { return }
let ctx = session.receivedApplicationContext
Self.log.info("applyReceivedContext: activation=\(session.activationState.rawValue) keys=\(ctx.keys.sorted().joined(separator: ","), privacy: .public)")
applyState(WCPayload.decodeSplits(ctx), workouts: WCPayload.decodeWorkouts(ctx))
applySettings(ctx)
editingWorkoutID = WCPayload.decodeEditingWorkoutID(ctx)
editingSplitID = WCPayload.decodeEditingSplitID(ctx)
}
/// Apply a decoded state push. `nil` (decode failure — the phone runs a build with
/// a different document schema) is logged and skipped so we neither prune the cache
/// against a bogus empty set nor silently show stale data forever. The upsert/prune
/// itself is delegated to the pure, session-free `WatchCacheApplier` seam so the
/// apply/prune contract is unit-testable.
private func applyState(_ splits: [SplitDocument]?, workouts: [WorkoutDocument]?) {
guard WatchCacheApplier.apply(splits: splits, workouts: workouts, into: context) else {
Self.log.error("applyState: payload failed to decode (splits=\(splits == nil ? "failed" : "ok", privacy: .public), workouts=\(workouts == nil ? "failed" : "ok", privacy: .public)) — phone/watch build mismatch?")
schemaMismatch = true
return
}
schemaMismatch = false
Self.log.info("applyState: applied \(splits?.count ?? 0) splits, \(workouts?.count ?? 0) workouts")
lastSyncDate = Date()
onWorkoutsChanged?()
}
func requestSync() {
guard let session, session.activationState == .activated, session.isReachable else {
Self.log.info("requestSync skipped: activation=\(self.session?.activationState.rawValue ?? -1) reachable=\(self.session?.isReachable ?? false)")
return
}
session.sendMessage(WCPayload.requestSyncMessage(), replyHandler: nil, errorHandler: { @Sendable error in
// No retry — the activation/reachability edges re-pull, and the durable
// context slot delivers regardless — but a dropped pull must be visible.
Self.log.warning("requestSync send failed: \(error, privacy: .public)")
})
}
/// Optimistically applies a workout edit to the local cache and forwards it to
/// the phone for durable persistence in iCloud Drive.
func update(workout doc: WorkoutDocument) {
CacheMapper.upsertWorkout(doc, relativePath: doc.relativePath, into: context)
try? context.save()
onWorkoutsChanged?()
sendToPhone(doc)
}
// MARK: - Live run mirror (ephemeral; coalesced redelivery)
/// Broadcast where the run flow currently is, so a propped-up iPhone can mirror it. Staged as
/// the latest pending frame and sent when the phone is reachable; if it's unreachable the frame
/// is held and re-sent on reconnect (`flushLive`). Because frames are full state snapshots with
/// a wall-clock anchor, holding only the newest one (depth 1) and self-correcting its timers on
/// arrival means a re-send is never stale.
func sendLiveProgress(_ frame: LiveProgress) {
guard let session, session.activationState == .activated else { return }
liveVersion += 1
var stamped = frame
stamped.version = liveVersion
pendingLive = .progress(stamped)
liveRetryAttempt = 0
flushLive()
}
/// Tell the phone to stop mirroring this run (the user left the progress flow). Staged like a
/// frame so a drop at the moment the run ends doesn't strand the phone's follower cover — the
/// terminal marker supersedes any pending progress and is re-sent on reconnect.
func sendLiveEnded(workoutID: String, logID: String) {
guard let session, session.activationState == .activated else { return }
pendingLive = .ended(workoutID: workoutID, logID: logID)
liveRetryAttempt = 0
flushLive()
}
/// (Re)send the staged live-run message if the phone is reachable. Called on each new frame and
/// whenever reachability/activation is restored. Leaves it staged on failure; a newer frame or
/// `.ended` supersedes it, so we never deliver stale state. The error handler runs on
/// WatchConnectivity's background queue, so it must be nonisolated (@Sendable) — an
/// inherited-@MainActor closure would trap (swift_task_checkIsolated) there.
private func flushLive() {
liveRetryTask?.cancel()
liveRetryTask = nil
guard let session, let pending = pendingLive,
session.activationState == .activated, session.isReachable else { return }
let payload: [String: Any]
switch pending {
case .progress(let frame):
payload = WCPayload.encodeLiveProgress(frame)
case .ended(let workoutID, let logID):
payload = WCPayload.encodeLiveEnded(workoutID: workoutID, logID: logID)
}
session.sendMessage(payload, replyHandler: nil, errorHandler: { @Sendable [weak self] _ in
Task { @MainActor in self?.scheduleLiveRetry() }
})
}
/// A send failed while the phone was nominally reachable. Without this the staged message
/// would sit until the next reachability/activation edge — which may never come mid-workout —
/// so back off briefly and re-flush, a handful of times per staged message (the edges still
/// re-flush after the retries are spent). Whatever is staged *when the retry fires* is sent,
/// so a newer frame staged meanwhile supersedes the failed one here too.
private func scheduleLiveRetry() {
guard pendingLive != nil, liveRetryTask == nil, liveRetryAttempt < 5 else { return }
liveRetryAttempt += 1
let delay = min(8.0, 0.5 * pow(2.0, Double(liveRetryAttempt - 1)))
liveRetryTask = Task { [weak self] in
try? await Task.sleep(for: .seconds(delay))
guard let self, !Task.isCancelled else { return }
self.liveRetryTask = nil
self.flushLive()
}
}
/// Apply a live-run frame the phone sent. Catches our send counter up first (shared per-run
/// sequence), then arbitrates against our own staged outbound frame for the same run: if the
/// incoming frame outranks it, drop the staged one (so a later reconnect can't re-send stale
/// state and yank the run backwards); if the *staged* frame outranks the delivery — a late or
/// version-collided arrival predating our own latest action — ignore the incoming frame.
/// Finally drops anything not newer than what we're already showing (redeliveries included).
private func applyIncomingLive(_ frame: LiveProgress) {
liveVersion = max(liveVersion, frame.version)
if case .progress(let staged) = pendingLive, staged.logID == frame.logID {
guard !staged.isNewer(than: frame) else { return }
pendingLive = nil
liveRetryTask?.cancel()
liveRetryTask = nil
}
if let current = liveIncoming, current.logID == frame.logID, !frame.isNewer(than: current) { return }
liveIncoming = frame
}
/// The phone left the run — stop following it (and clear any dismiss for it).
private func endIncomingLive(logID: String) {
if liveIncoming?.logID == logID { liveIncoming = nil }
if mutedLogID == logID { mutedLogID = nil }
}
// MARK: - Internal
private func sendToPhone(_ doc: WorkoutDocument) {
guard let session, session.activationState == .activated else { return }
let payload = WCPayload.encodeWorkoutUpdate(doc)
if session.isReachable {
// The error handler runs on WatchConnectivity's background queue, so it must be
// nonisolated (@Sendable) or it would trap (swift_task_checkIsolated). Hop back to the
// MainActor to fall back to guaranteed delivery; `doc` is Sendable, the payload isn't.
session.sendMessage(payload, replyHandler: nil, errorHandler: { @Sendable _ in
Task { @MainActor in
_ = self.session?.transferUserInfo(WCPayload.encodeWorkoutUpdate(doc))
}
})
} else {
session.transferUserInfo(payload)
}
}
private func applySettings(_ dict: [String: Any]) {
if let rest = WCPayload.decodeRestSeconds(dict) {
UserDefaults.standard.set(rest, forKey: WCPayload.restSecondsKey)
}
if let done = WCPayload.decodeDoneCountdownSeconds(dict) {
UserDefaults.standard.set(done, forKey: WCPayload.doneCountdownSecondsKey)
}
if let unit = WCPayload.decodeWeightUnit(dict) {
UserDefaults.standard.set(unit, forKey: WCPayload.weightUnitKey)
}
}
}
// MARK: - Cache apply/prune seam
/// The pure, session-free core of the phone→watch state apply: it upserts the authoritative
/// splits/workouts into the watch's SwiftData cache and prunes anything the phone no longer
/// sends. Split out of `WatchConnectivityBridge` (which wraps `WCSession`) so the apply/prune
/// contract — including the authoritative-empty prune and the nil-decode skip — is unit-testable
/// against an in-memory `ModelContext` without a live WatchConnectivity session.
enum WatchCacheApplier {
/// Entry point mirroring the wire decode: `nil` for *either* set means the phone's payload
/// failed to decode (a build/schema mismatch). We skip entirely — no upsert, no prune — so a
/// bogus empty set can never wipe real rows. Returns `true` when an authoritative push was
/// applied, `false` when skipped, so the caller can log / stamp `lastSyncDate` accordingly.
@MainActor
@discardableResult
static func apply(splits: [SplitDocument]?, workouts: [WorkoutDocument]?, into context: ModelContext) -> Bool {
guard let splits, let workouts else { return false }
apply(splits: splits, workouts: workouts, into: context)
return true
}
/// Upsert every split/workout the phone sent, then prune anything it *didn't*. Both sets are
/// authoritative, so an authoritative empty push clears rows the phone no longer has (a deleted
/// split; a run discarded/deleted on the phone, completed-and-aged-out, or otherwise dropped
/// from the ~24h window). On first launch the cache is empty, so the prune is a harmless no-op.
/// The watch never originates a split/workout, so pruning can't lose local-only data.
@MainActor
static func apply(splits: [SplitDocument], workouts: [WorkoutDocument], into context: ModelContext) {
var liveSplitIDs = Set<String>()
for s in splits {
CacheMapper.upsertSplit(s, relativePath: s.relativePath, into: context)
liveSplitIDs.insert(s.id)
}
var liveWorkoutIDs = Set<String>()
for w in workouts {
CacheMapper.upsertWorkout(w, relativePath: w.relativePath, into: context)
liveWorkoutIDs.insert(w.id)
}
if let allSplits = try? context.fetch(FetchDescriptor<Split>()) {
for s in allSplits where !liveSplitIDs.contains(s.id) { context.delete(s) }
}
if let allWorkouts = try? context.fetch(FetchDescriptor<Workout>()) {
for w in allWorkouts where !liveWorkoutIDs.contains(w.id) { context.delete(w) }
}
try? context.save()
}
}
// MARK: - WCSessionDelegate
extension WatchConnectivityBridge: WCSessionDelegate {
nonisolated func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {
if let error { Self.log.error("activation failed: \(error, privacy: .public)") }
Task { @MainActor in
// Activation is async on real hardware, so the eager context read in
// `activate()` saw an empty dictionary — re-apply now that it's valid.
self.applyReceivedContext()
self.requestSync()
self.flushLive() // deliver any frame staged before the session was ready
}
}
nonisolated func sessionReachabilityDidChange(_ session: WCSession) {
if session.isReachable {
Task { @MainActor in self.flushLive() } // catch the phone up on the latest run state after a reconnect
}
}
/// Live-run frames arrive as messages (reachable-only), distinct from the latest-wins
/// application context that carries durable state.
nonisolated func session(_ session: WCSession, didReceiveMessage message: [String: Any]) {
switch message[WCPayload.typeKey] as? String {
case WCPayload.liveProgressType:
if let frame = WCPayload.decodeLiveProgress(message) {
Task { @MainActor in self.applyIncomingLive(frame) }
}
case WCPayload.liveEndedType:
if let logID = message[WCPayload.lpLogIDKey] as? String {
Task { @MainActor in self.endIncomingLive(logID: logID) }
}
default:
break
}
}
nonisolated func session(_ session: WCSession, didReceiveApplicationContext applicationContext: [String: Any]) {
let splits = WCPayload.decodeSplits(applicationContext)
let workouts = WCPayload.decodeWorkouts(applicationContext)
let rest = WCPayload.decodeRestSeconds(applicationContext)
let done = WCPayload.decodeDoneCountdownSeconds(applicationContext)
let unit = WCPayload.decodeWeightUnit(applicationContext)
let editingWorkoutID = WCPayload.decodeEditingWorkoutID(applicationContext)
let editingSplitID = WCPayload.decodeEditingSplitID(applicationContext)
Task { @MainActor in
self.applyState(splits, workouts: workouts)
if let rest { UserDefaults.standard.set(rest, forKey: WCPayload.restSecondsKey) }
if let done { UserDefaults.standard.set(done, forKey: WCPayload.doneCountdownSecondsKey) }
if let unit { UserDefaults.standard.set(unit, forKey: WCPayload.weightUnitKey) }
// Absent keys mean "not editing" — set unconditionally so the lock clears.
self.editingWorkoutID = editingWorkoutID
self.editingSplitID = editingSplitID
}
}
}
/// The single staged live-run message awaiting (re)delivery to the phone — see `pendingLive`.
/// One slot, latest-wins: a newer progress frame or the terminal `.ended` replaces whatever's held.
private enum PendingLive {
case progress(LiveProgress)
case ended(workoutID: String, logID: String)
}