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
@@ -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<String>()
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
}