Suspend watch edit locks on background; degrade oversized pushes
Two watch-stranding fixes (BULLETPROOFING.md M1, M4):
The exclusive-edit lock rode in the latest-wins context and was cleared
only by onDisappear, so an editor left open in a pocketed (or
force-quit) phone parked the watch's run indefinitely ("Editing on
iPhone..."). The scene-phase hook now publishes the locks as cleared
while the app is backgrounded - without forgetting them locally - and
re-asserts them on return to the foreground.
pushAll treated a failed updateApplicationContext as log-only, so a
payload past WatchConnectivity's size ceiling silently froze the watch
out of all future state. A failed push now retries with the
recently-completed tail dropped (display-only on the watch); only a
failure of the slim push too remains an error.
Claude-Session: https://claude.ai/code/session_01PVNBVKp5bcq52X722uMjwT
This commit is contained in:
@@ -26,6 +26,13 @@ final class PhoneConnectivityBridge: NSObject {
|
||||
private(set) var editingWorkoutID: String?
|
||||
private(set) var editingSplitID: String?
|
||||
|
||||
/// While true (scene backgrounded), the edit locks are *published* to the watch as
|
||||
/// cleared without being forgotten locally. An editor left open in a pocketed — or
|
||||
/// force-quit — phone never fires `onDisappear`, and a lock that outlives its editor
|
||||
/// parks the watch's run indefinitely ("Editing on iPhone…"). Restored on re-activate,
|
||||
/// so returning to the still-open editor re-asserts the lock.
|
||||
private var locksSuspended = false
|
||||
|
||||
/// Monotonic sequence stamped on each live-run frame we send. Bumped to stay ahead of
|
||||
/// any frame we *receive*, so the two devices share one increasing sequence per run and
|
||||
/// either side can drop a stale / out-of-order delivery (see `LiveProgress.version`).
|
||||
@@ -100,27 +107,47 @@ final class PhoneConnectivityBridge: NSObject {
|
||||
let restSeconds = UserDefaults.standard.object(forKey: WCPayload.restSecondsKey) as? Int ?? 45
|
||||
let doneCountdownSeconds = UserDefaults.standard.object(forKey: WCPayload.doneCountdownSecondsKey) as? Int ?? 5
|
||||
let weightUnit = UserDefaults.standard.string(forKey: WCPayload.weightUnitKey) ?? WeightUnit.lb.rawValue
|
||||
let payload = WCPayload.encodeState(
|
||||
splits: splits.map(SplitDocument.init(from:)),
|
||||
workouts: workouts.map(WorkoutDocument.init(from:)),
|
||||
restSeconds: restSeconds,
|
||||
doneCountdownSeconds: doneCountdownSeconds,
|
||||
weightUnit: weightUnit,
|
||||
editingWorkoutID: editingWorkoutID,
|
||||
editingSplitID: editingSplitID
|
||||
)
|
||||
let splitDocs = splits.map(SplitDocument.init(from:))
|
||||
func encode(_ included: [Workout]) -> [String: Any] {
|
||||
WCPayload.encodeState(
|
||||
splits: splitDocs,
|
||||
workouts: included.map(WorkoutDocument.init(from:)),
|
||||
restSeconds: restSeconds,
|
||||
doneCountdownSeconds: doneCountdownSeconds,
|
||||
weightUnit: weightUnit,
|
||||
editingWorkoutID: locksSuspended ? nil : editingWorkoutID,
|
||||
editingSplitID: locksSuspended ? nil : editingSplitID
|
||||
)
|
||||
}
|
||||
do {
|
||||
let payload = encode(workouts)
|
||||
try session.updateApplicationContext(payload)
|
||||
let bytes = ((payload[WCPayload.splitsKey] as? Data)?.count ?? 0)
|
||||
+ ((payload[WCPayload.workoutsKey] as? Data)?.count ?? 0)
|
||||
Self.log.info("pushAll: \(splits.count) splits, \(workouts.count) workouts (\(bytes) bytes)")
|
||||
} catch {
|
||||
// Payload-too-large / not-activated etc. — must be visible, or the watch
|
||||
// just silently never hears about this state.
|
||||
Self.log.error("updateApplicationContext failed: \(error, privacy: .public)")
|
||||
// Realistically payload-too-large. The context is the watch's lifeline — a
|
||||
// dropped push means it silently never hears about this state again — so
|
||||
// degrade rather than freeze: the recently-completed tail is display-only
|
||||
// on the watch, drop it and retry with just the active runs.
|
||||
do {
|
||||
try session.updateApplicationContext(encode(active))
|
||||
Self.log.warning("pushAll degraded to \(active.count) active workouts (dropped \(recentCompleted.count) completed): \(error, privacy: .public)")
|
||||
} catch {
|
||||
Self.log.error("updateApplicationContext failed: \(error, privacy: .public)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Scene-phase hook: suspend the published edit locks while the app is backgrounded,
|
||||
/// restore them when it returns to the foreground (see `locksSuspended`). Pushes only
|
||||
/// when a lock is actually set — otherwise the flip changes nothing on the wire.
|
||||
func setLocksSuspended(_ suspended: Bool) {
|
||||
guard locksSuspended != suspended else { return }
|
||||
locksSuspended = suspended
|
||||
if editingWorkoutID != nil || editingSplitID != nil { pushAll() }
|
||||
}
|
||||
|
||||
/// Mark (or clear, with `nil`) the workout currently open in a phone exercise editor.
|
||||
/// The watch parks that run and blocks re-entry until it clears. Pushes immediately so
|
||||
/// the lock takes effect without waiting on a cache change.
|
||||
|
||||
@@ -31,6 +31,10 @@ struct WorkoutsApp: App {
|
||||
// the app is backgrounded, so re-assert it each time the scene becomes active.
|
||||
.onChange(of: scenePhase, initial: true) { _, phase in
|
||||
UIApplication.shared.isIdleTimerDisabled = (phase == .active)
|
||||
// An editor left open when the app leaves the foreground must not keep the
|
||||
// watch's run parked ("Editing on iPhone…") — suspend the published edit
|
||||
// locks while backgrounded; returning to the editor re-asserts them.
|
||||
services.watchBridge.setLocksSuspended(phase != .active)
|
||||
// Last chance before suspension: push any queued document writes out
|
||||
// now rather than waiting out a retry backoff we may not live to see.
|
||||
if phase == .background {
|
||||
|
||||
Reference in New Issue
Block a user