Harden watch sync against schema mismatches and surface log-row settings

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
This commit is contained in:
2026-07-06 20:10:36 -04:00
parent 669ecf1259
commit 6521de8f17
3 changed files with 53 additions and 12 deletions
+8 -4
View File
@@ -39,14 +39,18 @@ enum WCPayload {
return dict 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 [] } 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 [] } 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 } static func decodeRestSeconds(_ dict: [String: Any]) -> Int? { dict[restSecondsKey] as? Int }
@@ -1,5 +1,6 @@
import Foundation import Foundation
import Observation import Observation
import os
import SwiftData import SwiftData
import WatchConnectivity import WatchConnectivity
@@ -10,6 +11,8 @@ import WatchConnectivity
@Observable @Observable
@MainActor @MainActor
final class WatchConnectivityBridge: NSObject { final class WatchConnectivityBridge: NSObject {
private nonisolated static let log = Logger(subsystem: "dev.rzen.indie.Workouts", category: "watch-bridge")
private let container: ModelContainer private let container: ModelContainer
private var session: WCSession? private var session: WCSession?
@@ -71,17 +74,43 @@ final class WatchConnectivityBridge: NSObject {
session.delegate = self session.delegate = self
session.activate() session.activate()
self.session = session 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 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)) applyState(WCPayload.decodeSplits(ctx), workouts: WCPayload.decodeWorkouts(ctx))
applySettings(ctx) applySettings(ctx)
editingWorkoutID = WCPayload.decodeEditingWorkoutID(ctx) editingWorkoutID = WCPayload.decodeEditingWorkoutID(ctx)
editingSplitID = WCPayload.decodeEditingSplitID(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() { 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) session.sendMessage(WCPayload.requestSyncMessage(), replyHandler: nil, errorHandler: nil)
} }
@@ -182,6 +211,7 @@ final class WatchConnectivityBridge: NSObject {
} }
private func applyState(_ splits: [SplitDocument], workouts: [WorkoutDocument]) { private func applyState(_ splits: [SplitDocument], workouts: [WorkoutDocument]) {
Self.log.info("applyState: \(splits.count) splits, \(workouts.count) workouts")
guard !splits.isEmpty || !workouts.isEmpty else { return } guard !splits.isEmpty || !workouts.isEmpty else { return }
var liveSplitIDs = Set<String>() var liveSplitIDs = Set<String>()
for s in splits { for s in splits {
@@ -213,7 +243,11 @@ final class WatchConnectivityBridge: NSObject {
extension WatchConnectivityBridge: WCSessionDelegate { extension WatchConnectivityBridge: WCSessionDelegate {
nonisolated func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) { nonisolated func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {
if let error { Self.log.error("activation failed: \(error, privacy: .public)") }
Task { @MainActor in 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.requestSync()
self.flushLive() // deliver any frame staged before the session was ready self.flushLive() // deliver any frame staged before the session was ready
} }
@@ -434,11 +434,14 @@ private struct WorkoutLogRow: View {
.foregroundStyle(.primary) .foregroundStyle(.primary)
} }
// Machine comfort settings shown for any machine-based log // Machine comfort settings shown when the log carries settings, or
// (`machineSettings != nil`), independent of load type. Tapping opens // for any exercise the authored library identifies as machine-based
// the editor sheet; `.plain` keeps the tap from firing the row's // (same rule as ExerciseView), so older logs offer the affordance
// NavigationLink (same trick as the checkbox button). // before settings are first recorded. Tapping opens the editor sheet;
if log.machineSettings != nil { // `.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) { Button(action: onSettingsTap) {
Label("Settings", systemImage: "slider.horizontal.3") Label("Settings", systemImage: "slider.horizontal.3")
.font(.footnote.weight(.medium)) .font(.footnote.weight(.medium))