import Foundation import os import SwiftData import WatchConnectivity /// Phone side of the iPhone↔Watch bridge. The phone owns iCloud Drive; the watch /// is a thin remote that round-trips through it: /// • Phone → Watch: pushes all routines + recent workouts as the latest /// application context whenever the cache changes (local or remote). /// • Watch → Phone: receives an updated `WorkoutDocument` and applies it via the /// SyncEngine write path (file → observer → cache → push back). @MainActor final class PhoneConnectivityBridge: NSObject { private nonisolated static let log = Logger(subsystem: "dev.rzen.indie.Workouts", category: "phone-bridge") private let container: ModelContainer private let syncEngine: SyncEngine private let liveRunState: LiveRunState private var session: WCSession? /// Exclusive-edit lock published to the watch. While the phone has a workout's /// exercise (or a routine) open in an editor, the watch parks any matching run and /// blocks re-entry — so the two devices never drive the same run at once. Included in /// every `pushAll` (the latest-wins context replaces wholesale, so a push that omitted /// them would clear the lock prematurely). private(set) var editingWorkoutID: String? private(set) var editingRoutineID: String? /// While true (scene backgrounded), the edit locks are *published* to the watch as /// cleared without being forgotten locally. An editor left open in a pocketed — or /// force-quit — phone never fires `onDisappear`, and a lock that outlives its editor /// parks the watch's run indefinitely ("Editing on iPhone…"). Restored on re-activate, /// so returning to the still-open editor re-asserts the lock. private var locksSuspended = false /// 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 sequence per run 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 watch (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 private var context: ModelContext { container.mainContext } init(container: ModelContainer, syncEngine: SyncEngine, liveRunState: LiveRunState) { self.container = container self.syncEngine = syncEngine self.liveRunState = liveRunState super.init() } func activate() { guard WCSession.isSupported() else { return } let session = WCSession.default session.delegate = self session.activate() self.session = session // Push fresh state to the watch whenever the cache changes. syncEngine.onCacheChanged = { [weak self] in self?.pushAll() } } /// Sends the current routines + most-recent workouts to the watch. func pushAll() { guard let session, session.activationState == .activated, session.isPaired, session.isWatchAppInstalled else { Self.log.info("pushAll skipped: activation=\(self.session?.activationState.rawValue ?? -1) paired=\(self.session?.isPaired ?? false) installed=\(self.session?.isWatchAppInstalled ?? false)") return } let routines = (try? context.fetch(FetchDescriptor(sortBy: [SortDescriptor(\.order)]))) ?? [] // The watch only needs what it can act on: every active run (in-progress / // not-started) plus recently-completed ones, kept ~24h so a run that just // finished still renders before the watch prunes it. The watch treats this as an // authoritative set and prunes anything absent — that's what ends its session on a // Discard/Delete. Active runs are sent in full (no cap): there are only ever a // handful, so "absent" unambiguously means "no longer active". let inProgressRaw = WorkoutStatus.inProgress.rawValue let notStartedRaw = WorkoutStatus.notStarted.rawValue let completedRaw = WorkoutStatus.completed.rawValue let cutoff = Date(timeIntervalSinceNow: -86_400) let activeDesc = FetchDescriptor( predicate: #Predicate { $0.statusRaw == inProgressRaw || $0.statusRaw == notStartedRaw }, sortBy: [SortDescriptor(\.start, order: .reverse)] ) var completedDesc = FetchDescriptor( predicate: #Predicate { $0.statusRaw == completedRaw }, sortBy: [SortDescriptor(\.start, order: .reverse)] ) completedDesc.fetchLimit = 25 let active = (try? context.fetch(activeDesc)) ?? [] let recentCompleted = ((try? context.fetch(completedDesc)) ?? []).filter { ($0.end ?? $0.start) > cutoff } let workouts = active + recentCompleted let restSeconds = UserDefaults.standard.object(forKey: WCPayload.restSecondsKey) as? Int ?? 45 let doneCountdownSeconds = UserDefaults.standard.object(forKey: WCPayload.doneCountdownSecondsKey) as? Int ?? 5 let weightUnit = UserDefaults.standard.string(forKey: WCPayload.weightUnitKey) ?? WeightUnit.lb.rawValue let routineDocs = routines.map(RoutineDocument.init(from:)) func encode(_ included: [Workout]) -> [String: Any] { WCPayload.encodeState( routines: routineDocs, workouts: included.map(WorkoutDocument.init(from:)), restSeconds: restSeconds, doneCountdownSeconds: doneCountdownSeconds, weightUnit: weightUnit, editingWorkoutID: locksSuspended ? nil : editingWorkoutID, editingRoutineID: locksSuspended ? nil : editingRoutineID ) } do { let payload = encode(workouts) try session.updateApplicationContext(payload) let bytes = ((payload[WCPayload.routinesKey] as? Data)?.count ?? 0) + ((payload[WCPayload.workoutsKey] as? Data)?.count ?? 0) Self.log.info("pushAll: \(routines.count) routines, \(workouts.count) workouts (\(bytes) bytes)") } catch { // Realistically payload-too-large. The context is the watch's lifeline — a // dropped push means it silently never hears about this state again — so // degrade rather than freeze: the recently-completed tail is display-only // on the watch, drop it and retry with just the active runs. do { try session.updateApplicationContext(encode(active)) Self.log.warning("pushAll degraded to \(active.count) active workouts (dropped \(recentCompleted.count) completed): \(error, privacy: .public)") } catch { Self.log.error("updateApplicationContext failed: \(error, privacy: .public)") } } } /// Scene-phase hook: suspend the published edit locks while the app is backgrounded, /// restore them when it returns to the foreground (see `locksSuspended`). Pushes only /// when a lock is actually set — otherwise the flip changes nothing on the wire. func setLocksSuspended(_ suspended: Bool) { guard locksSuspended != suspended else { return } locksSuspended = suspended if editingWorkoutID != nil || editingRoutineID != nil { pushAll() } } /// Mark (or clear, with `nil`) the workout currently open in a phone exercise editor. /// The watch parks that run and blocks re-entry until it clears. Pushes immediately so /// the lock takes effect without waiting on a cache change. func setEditingWorkout(_ id: String?) { guard editingWorkoutID != id else { return } editingWorkoutID = id pushAll() } /// Mark (or clear, with `nil`) the routine currently open in a phone editor. The watch /// parks any active run sourced from that routine (matched by `routineID`). func setEditingRoutine(_ id: String?) { guard editingRoutineID != id else { return } editingRoutineID = id pushAll() } // MARK: - Live run mirror (ephemeral; coalesced redelivery) /// Broadcast where the run flow currently is, so the watch (if it has this run open) can follow /// it live. Staged as the latest pending frame and sent when reachable; if the watch is /// unreachable it's 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. Mirrors the watch's `sendLiveProgress`; /// only *human* transitions are sent. 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 watch we left the run flow (the cover closed / the run finished). Staged like a /// frame so a drop at the moment the run ends doesn't strand the watch'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 watch 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 watch 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 frame the watch sent. Catch our send counter up to it first, so the next frame /// we send outranks it and the shared per-run sequence keeps increasing across devices. /// Then arbitrate 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. 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 } liveRunState.apply(frame) } /// Parse the (non-Sendable) WC dictionary in the nonisolated delegate context, /// then hop to the MainActor with only Sendable values. nonisolated private func route(_ dict: [String: Any]) { switch dict[WCPayload.typeKey] as? String { case WCPayload.requestSyncType: Task { @MainActor in self.pushAll() } case WCPayload.workoutUpdateType: if let doc = WCPayload.decodeWorkoutUpdate(dict) { Task { @MainActor in await self.syncEngine.ingestFromWatch(doc) } } case WCPayload.liveProgressType: if let frame = WCPayload.decodeLiveProgress(dict) { Task { @MainActor in self.applyIncomingLive(frame) } } case WCPayload.liveEndedType: if let logID = dict[WCPayload.lpLogIDKey] as? String { Task { @MainActor in self.liveRunState.end(logID: logID) } } case WCPayload.liveHeartRateType: if let sample = WCPayload.decodeLiveHeartRate(dict) { Task { @MainActor in self.liveRunState.applyLiveSample(bpm: sample.bpm, kcal: sample.kcal, zone: sample.zone) } } default: break } } } // MARK: - WCSessionDelegate extension PhoneConnectivityBridge: WCSessionDelegate { nonisolated func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) { Task { @MainActor in self.pushAll() self.flushLive() // deliver any frame staged before the session was ready } } nonisolated func sessionDidBecomeInactive(_ session: WCSession) {} nonisolated func sessionDidDeactivate(_ session: WCSession) { session.activate() // reactivate for a switched watch } nonisolated func sessionReachabilityDidChange(_ session: WCSession) { if session.isReachable { Task { @MainActor in self.pushAll() self.flushLive() // catch the watch up on the latest run state after a reconnect } } } nonisolated func session(_ session: WCSession, didReceiveMessage message: [String: Any]) { route(message) } nonisolated func session(_ session: WCSession, didReceiveUserInfo userInfo: [String: Any] = [:]) { route(userInfo) } } /// The single staged live-run message awaiting (re)delivery to the watch — 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) }