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