Files
workouts/Workouts Watch App/Connectivity/WatchConnectivityBridge.swift
rzen f29e35e667 Re-deliver live-run frames across a WatchConnectivity drop
Stage the latest live-run frame (and the terminal .ended marker) in a
depth-1 latest-wins slot on both bridges, and flush it on reachability/
activation restore. A brief connectivity drop no longer desyncs the
iPhone/Watch run mirror; frames carry a wall-clock anchor so a late
re-send self-corrects rather than reading as stale.

Claude-Session: https://claude.ai/code/session_01A9CfUa4E9Zd5swfoNsYPs7
2026-06-21 09:10:16 -04:00

259 lines
12 KiB
Swift

import Foundation
import Observation
import SwiftData
import WatchConnectivity
/// Watch side of the iPhoneWatch 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 let container: ModelContainer
private var session: WCSession?
/// Last time state was received from the phone (for a sync indicator).
private(set) var lastSyncDate: Date?
/// 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?
/// 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.
let ctx = session.receivedApplicationContext
applyState(WCPayload.decodeSplits(ctx), workouts: WCPayload.decodeWorkouts(ctx))
applySettings(ctx)
editingWorkoutID = WCPayload.decodeEditingWorkoutID(ctx)
editingSplitID = WCPayload.decodeEditingSplitID(ctx)
requestSync()
}
func requestSync() {
guard let session, session.activationState == .activated, session.isReachable else { return }
session.sendMessage(WCPayload.requestSyncMessage(), replyHandler: nil, errorHandler: nil)
}
/// 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()
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)
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)
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() {
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 _ in })
}
/// Apply a live-run frame the phone sent. Drops a stale one for the same run, and catches
/// our send counter up so the next frame we send outranks it (shared per-run sequence).
private func applyIncomingLive(_ frame: LiveProgress) {
liveVersion = max(liveVersion, frame.version)
if let current = liveIncoming, current.logID == frame.logID, frame.version < current.version { 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)
}
}
private func applyState(_ splits: [SplitDocument], workouts: [WorkoutDocument]) {
guard !splits.isEmpty || !workouts.isEmpty else { return }
var liveSplitIDs = Set<String>()
for s in splits {
CacheMapper.upsertSplit(s, relativePath: s.relativePath, into: context)
liveSplitIDs.insert(s.id)
}
for w in workouts {
CacheMapper.upsertWorkout(w, relativePath: w.relativePath, into: context)
}
// Splits are sent in full prune any the phone no longer has. Workouts are
// sent as a recent window, so they're upserted but never pruned (avoids a
// race deleting a workout just created on the watch).
if let allSplits = try? context.fetch(FetchDescriptor<Split>()) {
for s in allSplits where !liveSplitIDs.contains(s.id) { context.delete(s) }
}
try? context.save()
lastSyncDate = Date()
}
}
// MARK: - WCSessionDelegate
extension WatchConnectivityBridge: WCSessionDelegate {
nonisolated func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {
Task { @MainActor in
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 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) }
// 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)
}