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; `editingRoutineID` matches any run by its `routineID`. private(set) var editingWorkoutID: String? private(set) var editingRoutineID: 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? 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 routines + 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.decodeRoutines(ctx), workouts: WCPayload.decodeWorkouts(ctx)) applySettings(ctx) editingWorkoutID = WCPayload.decodeEditingWorkoutID(ctx) editingRoutineID = WCPayload.decodeEditingRoutineID(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(_ routines: [RoutineDocument]?, workouts: [WorkoutDocument]?) { guard WatchCacheApplier.apply(routines: routines, workouts: workouts, into: context) else { Self.log.error("applyState: payload failed to decode (routines=\(routines == 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 \(routines?.count ?? 0) routines, \(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() } } // MARK: - Live heart rate (ephemeral; best-effort) /// Rounded bpm last sent and when, to throttle the forward: HR samples land every few /// seconds, but an unchanged reading only needs an occasional keep-alive so the phone's /// staleness window doesn't blank the readout mid-run. A zone flip breaks the throttle /// so the panel's zone can't lag a boundary crossing by the keep-alive interval. private var lastHRSentBpm: Int? private var lastHRSentZone: Int? private var lastHRSentAt = Date.distantPast /// Forward a live sample (heart rate + running calories + zone) to a mirroring phone. /// Unlike the run frames this is pure best-effort — reachable-only, no staging, no /// retry: a gauge, not a record. The durable summary still lands in `WorkoutMetrics` /// when the session finishes. func sendLiveSample(_ sample: WorkoutSessionManager.LiveSample) { guard let session, session.activationState == .activated, session.isReachable else { return } let rounded = Int(sample.bpm.rounded()) let now = Date() if rounded == lastHRSentBpm, sample.zone == lastHRSentZone, now.timeIntervalSince(lastHRSentAt) < 10 { return } lastHRSentBpm = rounded lastHRSentZone = sample.zone lastHRSentAt = now session.sendMessage( WCPayload.encodeLiveHeartRate(bpm: sample.bpm, at: now, kcal: sample.kcal, zone: sample.zone), replyHandler: nil, errorHandler: nil) } /// 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 /// routines/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(routines: [RoutineDocument]?, workouts: [WorkoutDocument]?, into context: ModelContext) -> Bool { guard let routines, let workouts else { return false } apply(routines: routines, workouts: workouts, into: context) return true } /// Upsert every routine/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 /// routine; 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 routine/workout, so pruning can't lose local-only data. @MainActor static func apply(routines: [RoutineDocument], workouts: [WorkoutDocument], into context: ModelContext) { var liveRoutineIDs = Set() for s in routines { CacheMapper.upsertRoutine(s, relativePath: s.relativePath, into: context) liveRoutineIDs.insert(s.id) } var liveWorkoutIDs = Set() for w in workouts { CacheMapper.upsertWorkout(w, relativePath: w.relativePath, into: context) liveWorkoutIDs.insert(w.id) } if let allRoutines = try? context.fetch(FetchDescriptor()) { for s in allRoutines where !liveRoutineIDs.contains(s.id) { context.delete(s) } } if let allWorkouts = try? context.fetch(FetchDescriptor()) { 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 routines = WCPayload.decodeRoutines(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 editingRoutineID = WCPayload.decodeEditingRoutineID(applicationContext) Task { @MainActor in self.applyState(routines, 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.editingRoutineID = editingRoutineID } } } /// 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) }