New Workouts Watch AppTests bundle wired into the watch scheme. Extracts the phone-to-watch cache apply/prune into a pure, session-free WatchCacheApplier seam and makes the HR-zone bucketing a nonisolated static, so both can be unit-tested off the main actor without a live WatchConnectivity session. Claude-Session: https://claude.ai/code/session_01HJDQQDA9QdP8zByg43H5v3
328 lines
16 KiB
Swift
328 lines
16 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?
|
|
|
|
/// 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. 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 splits + 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.decodeSplits(ctx), workouts: WCPayload.decodeWorkouts(ctx))
|
|
applySettings(ctx)
|
|
editingWorkoutID = WCPayload.decodeEditingWorkoutID(ctx)
|
|
editingSplitID = WCPayload.decodeEditingSplitID(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(_ splits: [SplitDocument]?, workouts: [WorkoutDocument]?) {
|
|
guard WatchCacheApplier.apply(splits: splits, workouts: workouts, into: context) else {
|
|
Self.log.error("applyState: payload failed to decode (splits=\(splits == nil ? "failed" : "ok", privacy: .public), workouts=\(workouts == nil ? "failed" : "ok", privacy: .public)) — phone/watch build mismatch?")
|
|
return
|
|
}
|
|
Self.log.info("applyState: applied \(splits?.count ?? 0) splits, \(workouts?.count ?? 0) workouts")
|
|
lastSyncDate = Date()
|
|
}
|
|
|
|
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: 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)
|
|
}
|
|
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
|
|
/// splits/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(splits: [SplitDocument]?, workouts: [WorkoutDocument]?, into context: ModelContext) -> Bool {
|
|
guard let splits, let workouts else { return false }
|
|
apply(splits: splits, workouts: workouts, into: context)
|
|
return true
|
|
}
|
|
|
|
/// Upsert every split/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
|
|
/// split; 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 split/workout, so pruning can't lose local-only data.
|
|
@MainActor
|
|
static func apply(splits: [SplitDocument], workouts: [WorkoutDocument], into context: ModelContext) {
|
|
var liveSplitIDs = Set<String>()
|
|
for s in splits {
|
|
CacheMapper.upsertSplit(s, relativePath: s.relativePath, into: context)
|
|
liveSplitIDs.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 allSplits = try? context.fetch(FetchDescriptor<Split>()) {
|
|
for s in allSplits where !liveSplitIDs.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 splits = WCPayload.decodeSplits(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 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) }
|
|
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.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)
|
|
}
|