A gear on the watch root opens a small Settings sheet whose stepper edits the shared restSeconds default (10-180s, 1s steps). Edits ride a new settingsUpdate message to the phone - debounced per stepper burst, falling back to transferUserInfo when unreachable - which clamps the value, writes the shared default, and re-echoes it to every device through the application context. While an edit is pending on the watch, an in-flight context's stale rest value is skipped so it can't yank the stepper back mid-edit.
335 lines
16 KiB
Swift
335 lines
16 KiB
Swift
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<Void, Never>?
|
|
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<Routine>(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<Workout>(
|
|
predicate: #Predicate<Workout> { $0.statusRaw == inProgressRaw || $0.statusRaw == notStartedRaw },
|
|
sortBy: [SortDescriptor(\.start, order: .reverse)]
|
|
)
|
|
var completedDesc = FetchDescriptor<Workout>(
|
|
predicate: #Predicate<Workout> { $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)
|
|
}
|
|
|
|
/// A setting edited on the watch. The phone owns the durable value: clamp it, write
|
|
/// the shared default (the same store Settings' `@AppStorage` reads), and re-push the
|
|
/// application context so every device converges on it.
|
|
private func applySettingsUpdate(restSeconds: Int) {
|
|
let clamped = min(max(restSeconds, 10), 180)
|
|
UserDefaults.standard.set(clamped, forKey: WCPayload.restSecondsKey)
|
|
pushAll()
|
|
}
|
|
|
|
/// 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.settingsUpdateType:
|
|
if let rest = WCPayload.decodeRestSeconds(dict) {
|
|
Task { @MainActor in self.applySettingsUpdate(restSeconds: rest) }
|
|
}
|
|
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)
|
|
}
|