Replaces the small target-HR pill on the iPhone run screen with a glanceable big-digit strip between the timer flow and the figure — heart rate (band-tinted with the cue arrow when the run carries a target), HR zone, active calories, and the workout stopwatch. A horizontal band in portrait, a vertical column in landscape (the inverse of the half-and-half split, so it always sits between them). The watch now rides the running calorie total and the HR zone (1-5, computed watch-side where max HR is known) along with each live HR sample; absent keys keep older builds wire-compatible both ways. LiveRunState holds all three under the shared staleness expiry. The screenshot rig seeds believable values so the run capture shows the panel populated.
415 lines
21 KiB
Swift
415 lines
21 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; `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<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 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<String>()
|
|
for s in routines {
|
|
CacheMapper.upsertRoutine(s, relativePath: s.relativePath, into: context)
|
|
liveRoutineIDs.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 allRoutines = try? context.fetch(FetchDescriptor<Routine>()) {
|
|
for s in allRoutines where !liveRoutineIDs.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 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)
|
|
}
|