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:
@@ -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))
|
||||||
|
|||||||
Reference in New Issue
Block a user