From 6521de8f17cf64f041df25d7c0585fc03fde5b90 Mon Sep 17 00:00:00 2001 From: rzen Date: Mon, 6 Jul 2026 20:10:36 -0400 Subject: [PATCH] Harden watch sync against schema mismatches and surface log-row settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The watch now re-applies the phone's application context once activation completes (real hardware activates asynchronously, so the eager launch read sees an empty context), and a state push that fails to decode — a phone/watch build running different document schemas — is logged and skipped instead of pruning the cache against a bogus empty set. Workout log rows offer the machine-settings editor for any machine-based library exercise, not just logs that already carry settings. Claude-Session: https://claude.ai/code/session_01LEoff8bXGBS83tK1c55Mf7 --- Shared/Connectivity/WCPayload.swift | 12 ++++-- .../WatchConnectivityBridge.swift | 40 +++++++++++++++++-- .../WorkoutLogs/WorkoutLogListView.swift | 13 +++--- 3 files changed, 53 insertions(+), 12 deletions(-) diff --git a/Shared/Connectivity/WCPayload.swift b/Shared/Connectivity/WCPayload.swift index 61e732d..281e8f7 100644 --- a/Shared/Connectivity/WCPayload.swift +++ b/Shared/Connectivity/WCPayload.swift @@ -39,14 +39,18 @@ enum WCPayload { return dict } - static func decodeSplits(_ dict: [String: Any]) -> [SplitDocument] { + /// `nil` means the payload carried splits that failed to decode (a schema mismatch + /// between the two builds) — distinct from an absent key or a legitimately empty + /// list, so the receiver can surface it instead of silently applying nothing. + static func decodeSplits(_ dict: [String: Any]) -> [SplitDocument]? { guard let data = dict[splitsKey] as? Data else { return [] } - return (try? DocumentCoder.decoder.decode([SplitDocument].self, from: data)) ?? [] + return try? DocumentCoder.decoder.decode([SplitDocument].self, from: data) } - static func decodeWorkouts(_ dict: [String: Any]) -> [WorkoutDocument] { + /// See `decodeSplits` — `nil` is a decode failure, not an empty list. + static func decodeWorkouts(_ dict: [String: Any]) -> [WorkoutDocument]? { guard let data = dict[workoutsKey] as? Data else { return [] } - return (try? DocumentCoder.decoder.decode([WorkoutDocument].self, from: data)) ?? [] + return try? DocumentCoder.decoder.decode([WorkoutDocument].self, from: data) } static func decodeRestSeconds(_ dict: [String: Any]) -> Int? { dict[restSecondsKey] as? Int } diff --git a/Workouts Watch App/Connectivity/WatchConnectivityBridge.swift b/Workouts Watch App/Connectivity/WatchConnectivityBridge.swift index 25c1033..978db92 100644 --- a/Workouts Watch App/Connectivity/WatchConnectivityBridge.swift +++ b/Workouts Watch App/Connectivity/WatchConnectivityBridge.swift @@ -1,5 +1,6 @@ import Foundation import Observation +import os import SwiftData import WatchConnectivity @@ -10,6 +11,8 @@ import WatchConnectivity @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? @@ -71,17 +74,43 @@ final class WatchConnectivityBridge: NSObject { session.delegate = self session.activate() self.session = session - // Apply whatever the phone last pushed, then ask for a fresh push. + // 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) - requestSync() + } + + /// 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. + private func applyState(_ splits: [SplitDocument]?, workouts: [WorkoutDocument]?) { + guard let splits, let workouts 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 + } + applyState(splits, workouts: workouts) } func requestSync() { - guard let session, session.activationState == .activated, session.isReachable else { return } + 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) } @@ -182,6 +211,7 @@ final class WatchConnectivityBridge: NSObject { } private func applyState(_ splits: [SplitDocument], workouts: [WorkoutDocument]) { + Self.log.info("applyState: \(splits.count) splits, \(workouts.count) workouts") guard !splits.isEmpty || !workouts.isEmpty else { return } var liveSplitIDs = Set() for s in splits { @@ -213,7 +243,11 @@ final class WatchConnectivityBridge: NSObject { 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 } diff --git a/Workouts/Views/WorkoutLogs/WorkoutLogListView.swift b/Workouts/Views/WorkoutLogs/WorkoutLogListView.swift index c465712..7d76591 100644 --- a/Workouts/Views/WorkoutLogs/WorkoutLogListView.swift +++ b/Workouts/Views/WorkoutLogs/WorkoutLogListView.swift @@ -434,11 +434,14 @@ private struct WorkoutLogRow: View { .foregroundStyle(.primary) } - // Machine comfort settings — shown for any machine-based log - // (`machineSettings != nil`), independent of load type. Tapping opens - // the editor sheet; `.plain` keeps the tap from firing the row's - // NavigationLink (same trick as the checkbox button). - if log.machineSettings != nil { + // Machine comfort settings — shown when the log carries settings, or + // for any exercise the authored library identifies as machine-based + // (same rule as ExerciseView), so older logs offer the affordance + // before settings are first recorded. Tapping opens the editor sheet; + // `.plain` keeps the tap from firing the row's NavigationLink (same + // trick as the checkbox button). + if log.machineSettings != nil + || ExerciseInfoLibrary.info(for: log.exerciseName)?.isMachineBased == true { Button(action: onSettingsTap) { Label("Settings", systemImage: "slider.horizontal.3") .font(.footnote.weight(.medium))