Make the live-run mirror survive lost phone-to-watch frames
Live frames ride sendMessage, which is reachable-only — and phone→watch reachability drops exactly when the user is swiping on the phone (wrist down). A frame that failed to send was staged but never retried until a reachability edge, and a frame lost outright desynced the run until the next human transition — sometimes forever, since a reconnect could even re-send the stale staged frame and yank the peer backwards. Four fixes, symmetric on both bridges and both run screens: - A send that fails while nominally reachable now retries with a short backoff (a few times per staged message) instead of being swallowed. - Receiving a frame that outranks the staged outbound one drops the staged frame, so a reconnect re-send can't move the run backwards; a delivery the staged frame outranks is ignored as stale. - Every staleness comparison now tie-breaks the shared version sequence on the frame's wall-clock anchor (LiveProgress.isNewer) — after a lost frame both devices can mint the same version, and the later human action must win. - Durable repair: when the absorbed workout doc shows sets completed beyond anything the open run screen recorded or followed, it jumps forward to the first unfinished set's work page — a lost frame now degrades to a briefly-stale page instead of a stuck one.
This commit is contained in:
@@ -1,5 +1,7 @@
|
|||||||
**July 2026**
|
**July 2026**
|
||||||
|
|
||||||
|
Swiping through sets on the iPhone now reliably moves the watch along too, instead of the two drifting apart mid-workout.
|
||||||
|
|
||||||
On Apple Watch, the workout screen now gives its whole display to the set and rest timer, without the form-guide figure shown on iPhone.
|
On Apple Watch, the workout screen now gives its whole display to the set and rest timer, without the form-guide figure shown on iPhone.
|
||||||
|
|
||||||
Barbells and pull-up bars in the animated form guide now turn with the figure as the view circles, instead of hovering fixed on screen.
|
Barbells and pull-up bars in the animated form guide now turn with the figure as the view circles, instead of hovering fixed on screen.
|
||||||
|
|||||||
@@ -48,3 +48,19 @@ struct LiveProgress: Sendable, Equatable {
|
|||||||
/// Monotonic per-run sequence, so the mirror can drop a stale / out-of-order delivery.
|
/// Monotonic per-run sequence, so the mirror can drop a stale / out-of-order delivery.
|
||||||
var version: Int
|
var version: Int
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension LiveProgress {
|
||||||
|
/// Strict "newer than" over the shared per-run sequence, tie-broken by the phase anchor.
|
||||||
|
/// The tie-break matters because the sequence isn't collision-free: each side catches its
|
||||||
|
/// counter up only from frames it *receives*, so after a lost frame both devices can mint
|
||||||
|
/// the same version independently. On a collision the later wall-clock action wins —
|
||||||
|
/// paired-device clock skew is sub-second, so the anchor orders genuine collisions
|
||||||
|
/// correctly. Equal version *and* equal anchor is the same frame redelivered: not newer.
|
||||||
|
func isNewer(thanVersion version: Int, start: Date) -> Bool {
|
||||||
|
self.version != version ? self.version > version : phaseStart > start
|
||||||
|
}
|
||||||
|
|
||||||
|
func isNewer(than other: LiveProgress) -> Bool {
|
||||||
|
isNewer(thanVersion: other.version, start: other.phaseStart)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -38,6 +38,12 @@ final class WatchConnectivityBridge: NSObject {
|
|||||||
/// wall-clock anchor, so a late re-send self-corrects on arrival rather than reading as stale.
|
/// wall-clock anchor, so a late re-send self-corrects on arrival rather than reading as stale.
|
||||||
private var pendingLive: PendingLive?
|
private var pendingLive: PendingLive?
|
||||||
|
|
||||||
|
/// In-flight backoff retry for a staged live message whose send *failed* (as opposed to
|
||||||
|
/// never being attempted because the peer was unreachable — that case is re-flushed by the
|
||||||
|
/// reachability/activation delegates). See `scheduleLiveRetry`.
|
||||||
|
private var liveRetryTask: Task<Void, Never>?
|
||||||
|
private var liveRetryAttempt = 0
|
||||||
|
|
||||||
/// The latest live-run frame the *phone* sent, for the run we currently have open to apply
|
/// The latest live-run frame the *phone* sent, for the run we currently have open to apply
|
||||||
/// (ephemeral; nil when the phone isn't driving). The watch's `ExerciseProgressView` reads
|
/// (ephemeral; nil when the phone isn't driving). The watch's `ExerciseProgressView` reads
|
||||||
/// this to follow a phone-driven transition; it's never persisted.
|
/// this to follow a phone-driven transition; it's never persisted.
|
||||||
@@ -138,6 +144,7 @@ final class WatchConnectivityBridge: NSObject {
|
|||||||
var stamped = frame
|
var stamped = frame
|
||||||
stamped.version = liveVersion
|
stamped.version = liveVersion
|
||||||
pendingLive = .progress(stamped)
|
pendingLive = .progress(stamped)
|
||||||
|
liveRetryAttempt = 0
|
||||||
flushLive()
|
flushLive()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,6 +154,7 @@ final class WatchConnectivityBridge: NSObject {
|
|||||||
func sendLiveEnded(workoutID: String, logID: String) {
|
func sendLiveEnded(workoutID: String, logID: String) {
|
||||||
guard let session, session.activationState == .activated else { return }
|
guard let session, session.activationState == .activated else { return }
|
||||||
pendingLive = .ended(workoutID: workoutID, logID: logID)
|
pendingLive = .ended(workoutID: workoutID, logID: logID)
|
||||||
|
liveRetryAttempt = 0
|
||||||
flushLive()
|
flushLive()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,6 +164,8 @@ final class WatchConnectivityBridge: NSObject {
|
|||||||
/// WatchConnectivity's background queue, so it must be nonisolated (@Sendable) — an
|
/// WatchConnectivity's background queue, so it must be nonisolated (@Sendable) — an
|
||||||
/// inherited-@MainActor closure would trap (swift_task_checkIsolated) there.
|
/// inherited-@MainActor closure would trap (swift_task_checkIsolated) there.
|
||||||
private func flushLive() {
|
private func flushLive() {
|
||||||
|
liveRetryTask?.cancel()
|
||||||
|
liveRetryTask = nil
|
||||||
guard let session, let pending = pendingLive,
|
guard let session, let pending = pendingLive,
|
||||||
session.activationState == .activated, session.isReachable else { return }
|
session.activationState == .activated, session.isReachable else { return }
|
||||||
let payload: [String: Any]
|
let payload: [String: Any]
|
||||||
@@ -165,14 +175,43 @@ final class WatchConnectivityBridge: NSObject {
|
|||||||
case .ended(let workoutID, let logID):
|
case .ended(let workoutID, let logID):
|
||||||
payload = WCPayload.encodeLiveEnded(workoutID: workoutID, logID: logID)
|
payload = WCPayload.encodeLiveEnded(workoutID: workoutID, logID: logID)
|
||||||
}
|
}
|
||||||
session.sendMessage(payload, replyHandler: nil, errorHandler: { @Sendable _ in })
|
session.sendMessage(payload, replyHandler: nil, errorHandler: { @Sendable [weak self] _ in
|
||||||
|
Task { @MainActor in self?.scheduleLiveRetry() }
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Apply a live-run frame the phone sent. Drops a stale one for the same run, and catches
|
/// A send failed while the phone was nominally reachable. Without this the staged message
|
||||||
/// our send counter up so the next frame we send outranks it (shared per-run sequence).
|
/// would sit until the next reachability/activation edge — which may never come mid-workout —
|
||||||
|
/// so back off briefly and re-flush, a handful of times per staged message (the edges still
|
||||||
|
/// re-flush after the retries are spent). Whatever is staged *when the retry fires* is sent,
|
||||||
|
/// so a newer frame staged meanwhile supersedes the failed one here too.
|
||||||
|
private func scheduleLiveRetry() {
|
||||||
|
guard pendingLive != nil, liveRetryTask == nil, liveRetryAttempt < 5 else { return }
|
||||||
|
liveRetryAttempt += 1
|
||||||
|
let delay = min(8.0, 0.5 * pow(2.0, Double(liveRetryAttempt - 1)))
|
||||||
|
liveRetryTask = Task { [weak self] in
|
||||||
|
try? await Task.sleep(for: .seconds(delay))
|
||||||
|
guard let self, !Task.isCancelled else { return }
|
||||||
|
self.liveRetryTask = nil
|
||||||
|
self.flushLive()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply a live-run frame the phone sent. Catches our send counter up first (shared per-run
|
||||||
|
/// sequence), then arbitrates against our own staged outbound frame for the same run: if the
|
||||||
|
/// incoming frame outranks it, drop the staged one (so a later reconnect can't re-send stale
|
||||||
|
/// state and yank the run backwards); if the *staged* frame outranks the delivery — a late or
|
||||||
|
/// version-collided arrival predating our own latest action — ignore the incoming frame.
|
||||||
|
/// Finally drops anything not newer than what we're already showing (redeliveries included).
|
||||||
private func applyIncomingLive(_ frame: LiveProgress) {
|
private func applyIncomingLive(_ frame: LiveProgress) {
|
||||||
liveVersion = max(liveVersion, frame.version)
|
liveVersion = max(liveVersion, frame.version)
|
||||||
if let current = liveIncoming, current.logID == frame.logID, frame.version < current.version { return }
|
if case .progress(let staged) = pendingLive, staged.logID == frame.logID {
|
||||||
|
guard !staged.isNewer(than: frame) else { return }
|
||||||
|
pendingLive = nil
|
||||||
|
liveRetryTask?.cancel()
|
||||||
|
liveRetryTask = nil
|
||||||
|
}
|
||||||
|
if let current = liveIncoming, current.logID == frame.logID, !frame.isNewer(than: current) { return }
|
||||||
liveIncoming = frame
|
liveIncoming = frame
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -61,8 +61,15 @@ struct ExerciseProgressView: View {
|
|||||||
/// A human swipe leaves this `nil` (→ treated as human); programmatic moves set it.
|
/// A human swipe leaves this `nil` (→ treated as human); programmatic moves set it.
|
||||||
@State private var pageChangeCause: PageChangeCause?
|
@State private var pageChangeCause: PageChangeCause?
|
||||||
|
|
||||||
/// Highest remote frame version we've applied, so a redelivery doesn't re-jump.
|
/// Highest remote frame we've applied — version plus its anchor tie-break (see
|
||||||
|
/// `LiveProgress.isNewer`) — so a redelivery or collided stale frame doesn't re-jump.
|
||||||
@State private var lastAppliedVersion = 0
|
@State private var lastAppliedVersion = 0
|
||||||
|
@State private var lastAppliedStart = Date.distantPast
|
||||||
|
|
||||||
|
/// Highest completed-set count this screen has itself recorded or followed via a live
|
||||||
|
/// frame — the baseline `repairFromDurable` compares an absorbed doc against, so a
|
||||||
|
/// durable echo of our own progress (or of a frame we followed) never triggers a jump.
|
||||||
|
@State private var knownStateIndex: Int
|
||||||
|
|
||||||
/// When a remote frame drove us to a page, that page's timer anchors to the *sender's*
|
/// When a remote frame drove us to a page, that page's timer anchors to the *sender's*
|
||||||
/// wall clock (`remoteAnchorStart`/`End`) instead of local `now` — so delivery latency
|
/// wall clock (`remoteAnchorStart`/`End`) instead of local `now` — so delivery latency
|
||||||
@@ -100,6 +107,7 @@ struct ExerciseProgressView: View {
|
|||||||
let log = doc.wrappedValue.logs.first { $0.id == logID }
|
let log = doc.wrappedValue.logs.first { $0.id == logID }
|
||||||
let sets = max(1, log?.sets ?? 1)
|
let sets = max(1, log?.sets ?? 1)
|
||||||
_setCount = State(initialValue: sets)
|
_setCount = State(initialValue: sets)
|
||||||
|
_knownStateIndex = State(initialValue: max(0, log?.currentStateIndex ?? 0))
|
||||||
|
|
||||||
let notStarted = (log?.status ?? WorkoutStatus.notStarted.rawValue) == WorkoutStatus.notStarted.rawValue
|
let notStarted = (log?.status ?? WorkoutStatus.notStarted.rawValue) == WorkoutStatus.notStarted.rawValue
|
||||||
// The Ready page always leads the flow (except in the screenshot host). A
|
// The Ready page always leads the flow (except in the screenshot host). A
|
||||||
@@ -141,6 +149,12 @@ struct ExerciseProgressView: View {
|
|||||||
return base + completed * 2
|
return base + completed * 2
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Completed-set count a given page implies — the same math `recordProgress` records
|
||||||
|
/// (capped at `setCount − 1`; only an explicit Done marks the final set).
|
||||||
|
private func completedSets(for pageIndex: Int) -> Int {
|
||||||
|
min(max(0, (pageIndex - base + 1) / 2), setCount - 1)
|
||||||
|
}
|
||||||
|
|
||||||
/// Position within the work/rest cycle for the current page — `nil` on the Ready
|
/// Position within the work/rest cycle for the current page — `nil` on the Ready
|
||||||
/// and Finish pages (which show no dots).
|
/// and Finish pages (which show no dots).
|
||||||
private var currentCycleIndex: Int? {
|
private var currentCycleIndex: Int? {
|
||||||
@@ -257,6 +271,9 @@ struct ExerciseProgressView: View {
|
|||||||
.onChange(of: incomingFrame) { _, frame in
|
.onChange(of: incomingFrame) { _, frame in
|
||||||
if let frame { applyIncoming(frame) }
|
if let frame { applyIncoming(frame) }
|
||||||
}
|
}
|
||||||
|
.onChange(of: log?.currentStateIndex) { _, _ in
|
||||||
|
repairFromDurable()
|
||||||
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
guard !didRestorePage else { return }
|
guard !didRestorePage else { return }
|
||||||
if startsResumed {
|
if startsResumed {
|
||||||
@@ -305,10 +322,15 @@ struct ExerciseProgressView: View {
|
|||||||
/// for a remote One More) without re-broadcasting or re-recording it — the phone owns the
|
/// for a remote One More) without re-broadcasting or re-recording it — the phone owns the
|
||||||
/// durable write, which arrives separately over the document sync.
|
/// durable write, which arrives separately over the document sync.
|
||||||
private func applyIncoming(_ frame: LiveProgress) {
|
private func applyIncoming(_ frame: LiveProgress) {
|
||||||
guard didRestorePage, frame.logID == logID, frame.version > lastAppliedVersion else { return }
|
guard didRestorePage, frame.logID == logID,
|
||||||
|
frame.isNewer(thanVersion: lastAppliedVersion, start: lastAppliedStart) else { return }
|
||||||
lastAppliedVersion = frame.version
|
lastAppliedVersion = frame.version
|
||||||
|
lastAppliedStart = frame.phaseStart
|
||||||
if frame.setCount != setCount { setCount = frame.setCount }
|
if frame.setCount != setCount { setCount = frame.setCount }
|
||||||
let target = page(forPhase: frame.phase, setIndex: frame.setIndex)
|
let target = page(forPhase: frame.phase, setIndex: frame.setIndex)
|
||||||
|
// A followed frame is progress this screen now *knows*, so a durable echo of the
|
||||||
|
// same transition can't re-trigger the repair jump (a remote reset re-baselines).
|
||||||
|
knownStateIndex = frame.phase == .ready ? 0 : max(knownStateIndex, completedSets(for: target))
|
||||||
// Anchor the target page's timer to the sender's wall clock, not local now, so the
|
// Anchor the target page's timer to the sender's wall clock, not local now, so the
|
||||||
// displayed countdown matches regardless of how long the frame took to arrive.
|
// displayed countdown matches regardless of how long the frame took to arrive.
|
||||||
remoteAnchorPage = target
|
remoteAnchorPage = target
|
||||||
@@ -319,6 +341,27 @@ struct ExerciseProgressView: View {
|
|||||||
withAnimation { currentPage = target }
|
withAnimation { currentPage = target }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Durable-state safety net for the ephemeral mirror. A peer's swipe frame can be lost
|
||||||
|
/// (`sendMessage` is reachable-only and the retry can run dry), but its durable write still
|
||||||
|
/// arrives via the application context and is absorbed into `doc` by the parent list. When
|
||||||
|
/// the absorbed doc shows sets completed beyond anything this screen has recorded or
|
||||||
|
/// followed, jump forward to the first unfinished set's work page so the flow can't stay
|
||||||
|
/// stuck on a stale phase. Forward-only — progress is monotonic, so a same-or-behind doc
|
||||||
|
/// is our own write echoing back — and only while the run is still in progress.
|
||||||
|
private func repairFromDurable() {
|
||||||
|
guard didRestorePage, let log,
|
||||||
|
log.status == WorkoutStatus.inProgress.rawValue else { return }
|
||||||
|
let completed = log.currentStateIndex
|
||||||
|
guard completed > knownStateIndex else { return }
|
||||||
|
knownStateIndex = completed
|
||||||
|
if log.sets > setCount { setCount = log.sets }
|
||||||
|
let target = base + min(max(0, completed), setCount - 1) * 2
|
||||||
|
guard target > currentPage else { return }
|
||||||
|
clearRemoteAnchor()
|
||||||
|
pageChangeCause = .remote
|
||||||
|
withAnimation { currentPage = target }
|
||||||
|
}
|
||||||
|
|
||||||
/// Drop the remote anchor when a local transition moves us off the remote-driven page,
|
/// Drop the remote anchor when a local transition moves us off the remote-driven page,
|
||||||
/// so the next phase counts off local `now` (and a swipe back doesn't reuse a stale anchor).
|
/// so the next phase counts off local `now` (and a swipe back doesn't reuse a stale anchor).
|
||||||
private func clearRemoteAnchor() {
|
private func clearRemoteAnchor() {
|
||||||
@@ -478,6 +521,7 @@ struct ExerciseProgressView: View {
|
|||||||
/// plan, and slide forward into the bonus set's work phase.
|
/// plan, and slide forward into the bonus set's work phase.
|
||||||
private func addSet() {
|
private func addSet() {
|
||||||
let newCount = setCount + 1
|
let newCount = setCount + 1
|
||||||
|
knownStateIndex = max(knownStateIndex, newCount - 1)
|
||||||
|
|
||||||
if let i = doc.logs.firstIndex(where: { $0.id == logID }) {
|
if let i = doc.logs.firstIndex(where: { $0.id == logID }) {
|
||||||
doc.logs[i].sets = newCount
|
doc.logs[i].sets = newCount
|
||||||
@@ -505,8 +549,8 @@ struct ExerciseProgressView: View {
|
|||||||
/// swiping back — or a transient TabView snap to page 0 — never un-counts a set.
|
/// swiping back — or a transient TabView snap to page 0 — never un-counts a set.
|
||||||
private func recordProgress(for pageIndex: Int) {
|
private func recordProgress(for pageIndex: Int) {
|
||||||
if showsReady && pageIndex == 0 { return } // Ready page records nothing
|
if showsReady && pageIndex == 0 { return } // Ready page records nothing
|
||||||
let cycleIndex = pageIndex - base
|
let reached = completedSets(for: pageIndex)
|
||||||
let reached = min(max(0, (cycleIndex + 1) / 2), setCount - 1)
|
knownStateIndex = max(knownStateIndex, reached)
|
||||||
|
|
||||||
guard let i = doc.logs.firstIndex(where: { $0.id == logID }) else { return }
|
guard let i = doc.logs.firstIndex(where: { $0.id == logID }) else { return }
|
||||||
guard reached > doc.logs[i].currentStateIndex else { return }
|
guard reached > doc.logs[i].currentStateIndex else { return }
|
||||||
@@ -527,6 +571,7 @@ struct ExerciseProgressView: View {
|
|||||||
guard let i = doc.logs.firstIndex(where: { $0.id == logID }) else { return }
|
guard let i = doc.logs.firstIndex(where: { $0.id == logID }) else { return }
|
||||||
let log = doc.logs[i]
|
let log = doc.logs[i]
|
||||||
// Skip the write if it's already pristine (e.g. landing on Ready before any set).
|
// Skip the write if it's already pristine (e.g. landing on Ready before any set).
|
||||||
|
knownStateIndex = 0
|
||||||
guard log.currentStateIndex != 0
|
guard log.currentStateIndex != 0
|
||||||
|| log.status != WorkoutStatus.notStarted.rawValue else { return }
|
|| log.status != WorkoutStatus.notStarted.rawValue else { return }
|
||||||
doc.logs[i].currentStateIndex = 0
|
doc.logs[i].currentStateIndex = 0
|
||||||
@@ -537,6 +582,7 @@ struct ExerciseProgressView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func completeExercise() {
|
private func completeExercise() {
|
||||||
|
knownStateIndex = max(knownStateIndex, setCount)
|
||||||
guard let i = doc.logs.firstIndex(where: { $0.id == logID }) else { return }
|
guard let i = doc.logs.firstIndex(where: { $0.id == logID }) else { return }
|
||||||
doc.logs[i].currentStateIndex = setCount
|
doc.logs[i].currentStateIndex = setCount
|
||||||
doc.logs[i].transition(to: .completed)
|
doc.logs[i].transition(to: .completed)
|
||||||
|
|||||||
@@ -34,9 +34,9 @@ final class LiveRunState {
|
|||||||
return c
|
return c
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Apply an incoming frame, dropping a stale one for the same run.
|
/// Apply an incoming frame, dropping a stale (or redelivered) one for the same run.
|
||||||
func apply(_ frame: LiveProgress) {
|
func apply(_ frame: LiveProgress) {
|
||||||
if let c = current, c.logID == frame.logID, frame.version < c.version { return }
|
if let c = current, c.logID == frame.logID, !frame.isNewer(than: c) { return }
|
||||||
current = frame
|
current = frame
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,12 @@ final class PhoneConnectivityBridge: NSObject {
|
|||||||
/// wall-clock anchor, so a late re-send self-corrects on arrival rather than reading as stale.
|
/// wall-clock anchor, so a late re-send self-corrects on arrival rather than reading as stale.
|
||||||
private var pendingLive: PendingLive?
|
private var pendingLive: PendingLive?
|
||||||
|
|
||||||
|
/// In-flight backoff retry for a staged live message whose send *failed* (as opposed to
|
||||||
|
/// never being attempted because the peer was unreachable — that case is re-flushed by the
|
||||||
|
/// reachability/activation delegates). See `scheduleLiveRetry`.
|
||||||
|
private var liveRetryTask: Task<Void, Never>?
|
||||||
|
private var liveRetryAttempt = 0
|
||||||
|
|
||||||
private var context: ModelContext { container.mainContext }
|
private var context: ModelContext { container.mainContext }
|
||||||
|
|
||||||
init(container: ModelContainer, syncEngine: SyncEngine, liveRunState: LiveRunState) {
|
init(container: ModelContainer, syncEngine: SyncEngine, liveRunState: LiveRunState) {
|
||||||
@@ -146,6 +152,7 @@ final class PhoneConnectivityBridge: NSObject {
|
|||||||
var stamped = frame
|
var stamped = frame
|
||||||
stamped.version = liveVersion
|
stamped.version = liveVersion
|
||||||
pendingLive = .progress(stamped)
|
pendingLive = .progress(stamped)
|
||||||
|
liveRetryAttempt = 0
|
||||||
flushLive()
|
flushLive()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,6 +162,7 @@ final class PhoneConnectivityBridge: NSObject {
|
|||||||
func sendLiveEnded(workoutID: String, logID: String) {
|
func sendLiveEnded(workoutID: String, logID: String) {
|
||||||
guard let session, session.activationState == .activated else { return }
|
guard let session, session.activationState == .activated else { return }
|
||||||
pendingLive = .ended(workoutID: workoutID, logID: logID)
|
pendingLive = .ended(workoutID: workoutID, logID: logID)
|
||||||
|
liveRetryAttempt = 0
|
||||||
flushLive()
|
flushLive()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,6 +172,8 @@ final class PhoneConnectivityBridge: NSObject {
|
|||||||
/// WatchConnectivity's background queue, so it must be nonisolated (@Sendable) — an
|
/// WatchConnectivity's background queue, so it must be nonisolated (@Sendable) — an
|
||||||
/// inherited-@MainActor closure would trap (swift_task_checkIsolated) there.
|
/// inherited-@MainActor closure would trap (swift_task_checkIsolated) there.
|
||||||
private func flushLive() {
|
private func flushLive() {
|
||||||
|
liveRetryTask?.cancel()
|
||||||
|
liveRetryTask = nil
|
||||||
guard let session, let pending = pendingLive,
|
guard let session, let pending = pendingLive,
|
||||||
session.activationState == .activated, session.isReachable else { return }
|
session.activationState == .activated, session.isReachable else { return }
|
||||||
let payload: [String: Any]
|
let payload: [String: Any]
|
||||||
@@ -173,13 +183,42 @@ final class PhoneConnectivityBridge: NSObject {
|
|||||||
case .ended(let workoutID, let logID):
|
case .ended(let workoutID, let logID):
|
||||||
payload = WCPayload.encodeLiveEnded(workoutID: workoutID, logID: logID)
|
payload = WCPayload.encodeLiveEnded(workoutID: workoutID, logID: logID)
|
||||||
}
|
}
|
||||||
session.sendMessage(payload, replyHandler: nil, errorHandler: { @Sendable _ in })
|
session.sendMessage(payload, replyHandler: nil, errorHandler: { @Sendable [weak self] _ in
|
||||||
|
Task { @MainActor in self?.scheduleLiveRetry() }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A send failed while the watch was nominally reachable. Without this the staged message
|
||||||
|
/// would sit until the next reachability/activation edge — which may never come mid-workout —
|
||||||
|
/// so back off briefly and re-flush, a handful of times per staged message (the edges still
|
||||||
|
/// re-flush after the retries are spent). Whatever is staged *when the retry fires* is sent,
|
||||||
|
/// so a newer frame staged meanwhile supersedes the failed one here too.
|
||||||
|
private func scheduleLiveRetry() {
|
||||||
|
guard pendingLive != nil, liveRetryTask == nil, liveRetryAttempt < 5 else { return }
|
||||||
|
liveRetryAttempt += 1
|
||||||
|
let delay = min(8.0, 0.5 * pow(2.0, Double(liveRetryAttempt - 1)))
|
||||||
|
liveRetryTask = Task { [weak self] in
|
||||||
|
try? await Task.sleep(for: .seconds(delay))
|
||||||
|
guard let self, !Task.isCancelled else { return }
|
||||||
|
self.liveRetryTask = nil
|
||||||
|
self.flushLive()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Apply a frame the watch sent. Catch our send counter up to it first, so the next frame
|
/// Apply a frame the watch sent. Catch our send counter up to it first, so the next frame
|
||||||
/// we send outranks it and the shared per-run sequence keeps increasing across devices.
|
/// we send outranks it and the shared per-run sequence keeps increasing across devices.
|
||||||
|
/// Then arbitrate against our own staged outbound frame for the same run: if the incoming
|
||||||
|
/// frame outranks it, drop the staged one (so a later reconnect can't re-send stale state
|
||||||
|
/// and yank the run backwards); if the *staged* frame outranks the delivery — a late or
|
||||||
|
/// version-collided arrival predating our own latest action — ignore the incoming frame.
|
||||||
private func applyIncomingLive(_ frame: LiveProgress) {
|
private func applyIncomingLive(_ frame: LiveProgress) {
|
||||||
liveVersion = max(liveVersion, frame.version)
|
liveVersion = max(liveVersion, frame.version)
|
||||||
|
if case .progress(let staged) = pendingLive, staged.logID == frame.logID {
|
||||||
|
guard !staged.isNewer(than: frame) else { return }
|
||||||
|
pendingLive = nil
|
||||||
|
liveRetryTask?.cancel()
|
||||||
|
liveRetryTask = nil
|
||||||
|
}
|
||||||
liveRunState.apply(frame)
|
liveRunState.apply(frame)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -82,8 +82,15 @@ struct ExerciseProgressView: View {
|
|||||||
/// A human swipe leaves this `nil` (→ treated as human); programmatic moves set it.
|
/// A human swipe leaves this `nil` (→ treated as human); programmatic moves set it.
|
||||||
@State private var pageChangeCause: PageChangeCause?
|
@State private var pageChangeCause: PageChangeCause?
|
||||||
|
|
||||||
/// Highest remote frame version we've applied, so a redelivery doesn't re-jump.
|
/// Highest remote frame we've applied — version plus its anchor tie-break (see
|
||||||
|
/// `LiveProgress.isNewer`) — so a redelivery or collided stale frame doesn't re-jump.
|
||||||
@State private var lastAppliedVersion = 0
|
@State private var lastAppliedVersion = 0
|
||||||
|
@State private var lastAppliedStart = Date.distantPast
|
||||||
|
|
||||||
|
/// Highest completed-set count this screen has itself recorded or followed via a live
|
||||||
|
/// frame — the baseline `repairFromDurable` compares an absorbed doc against, so a
|
||||||
|
/// durable echo of our own progress (or of a frame we followed) never triggers a jump.
|
||||||
|
@State private var knownStateIndex: Int
|
||||||
|
|
||||||
/// When a remote frame drove us to a page, that page's timer anchors to the *sender's*
|
/// When a remote frame drove us to a page, that page's timer anchors to the *sender's*
|
||||||
/// wall clock (`remoteAnchorStart`/`End`) instead of local `now` — so delivery latency
|
/// wall clock (`remoteAnchorStart`/`End`) instead of local `now` — so delivery latency
|
||||||
@@ -125,6 +132,7 @@ struct ExerciseProgressView: View {
|
|||||||
let log = doc.wrappedValue.logs.first { $0.id == logID }
|
let log = doc.wrappedValue.logs.first { $0.id == logID }
|
||||||
let sets = max(1, log?.sets ?? 1)
|
let sets = max(1, log?.sets ?? 1)
|
||||||
_setCount = State(initialValue: sets)
|
_setCount = State(initialValue: sets)
|
||||||
|
_knownStateIndex = State(initialValue: max(0, log?.currentStateIndex ?? 0))
|
||||||
|
|
||||||
// The Ready page always leads the flow. A not-started run opens on it; an
|
// The Ready page always leads the flow. A not-started run opens on it; an
|
||||||
// in-progress run opens on its first unfinished set and re-asserts that page past
|
// in-progress run opens on its first unfinished set and re-asserts that page past
|
||||||
@@ -165,6 +173,12 @@ struct ExerciseProgressView: View {
|
|||||||
return base + completed * 2
|
return base + completed * 2
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Completed-set count a given page implies — the same math `recordProgress` records
|
||||||
|
/// (capped at `setCount − 1`; only an explicit Done marks the final set).
|
||||||
|
private func completedSets(for pageIndex: Int) -> Int {
|
||||||
|
min(max(0, (pageIndex - base + 1) / 2), setCount - 1)
|
||||||
|
}
|
||||||
|
|
||||||
/// Position within the work/rest cycle for the current page — `nil` on the Ready
|
/// Position within the work/rest cycle for the current page — `nil` on the Ready
|
||||||
/// and Finish pages (which show no dots).
|
/// and Finish pages (which show no dots).
|
||||||
private var currentCycleIndex: Int? {
|
private var currentCycleIndex: Int? {
|
||||||
@@ -301,6 +315,9 @@ struct ExerciseProgressView: View {
|
|||||||
.onChange(of: incomingFrame) { _, frame in
|
.onChange(of: incomingFrame) { _, frame in
|
||||||
if let frame { applyIncoming(frame) }
|
if let frame { applyIncoming(frame) }
|
||||||
}
|
}
|
||||||
|
.onChange(of: log?.currentStateIndex) { _, _ in
|
||||||
|
repairFromDurable()
|
||||||
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
guard !didRestorePage else { return }
|
guard !didRestorePage else { return }
|
||||||
if startsResumed {
|
if startsResumed {
|
||||||
@@ -375,10 +392,15 @@ struct ExerciseProgressView: View {
|
|||||||
/// for a remote One More) without re-broadcasting or re-recording it — the watch owns the
|
/// for a remote One More) without re-broadcasting or re-recording it — the watch owns the
|
||||||
/// durable write, which arrives separately over the document sync.
|
/// durable write, which arrives separately over the document sync.
|
||||||
private func applyIncoming(_ frame: LiveProgress) {
|
private func applyIncoming(_ frame: LiveProgress) {
|
||||||
guard didRestorePage, frame.logID == logID, frame.version > lastAppliedVersion else { return }
|
guard didRestorePage, frame.logID == logID,
|
||||||
|
frame.isNewer(thanVersion: lastAppliedVersion, start: lastAppliedStart) else { return }
|
||||||
lastAppliedVersion = frame.version
|
lastAppliedVersion = frame.version
|
||||||
|
lastAppliedStart = frame.phaseStart
|
||||||
if frame.setCount != setCount { setCount = frame.setCount }
|
if frame.setCount != setCount { setCount = frame.setCount }
|
||||||
let target = page(forPhase: frame.phase, setIndex: frame.setIndex)
|
let target = page(forPhase: frame.phase, setIndex: frame.setIndex)
|
||||||
|
// A followed frame is progress this screen now *knows*, so a durable echo of the
|
||||||
|
// same transition can't re-trigger the repair jump (a remote reset re-baselines).
|
||||||
|
knownStateIndex = frame.phase == .ready ? 0 : max(knownStateIndex, completedSets(for: target))
|
||||||
// Anchor the target page's timer to the sender's wall clock, not local now, so the
|
// Anchor the target page's timer to the sender's wall clock, not local now, so the
|
||||||
// displayed countdown matches regardless of how long the frame took to arrive.
|
// displayed countdown matches regardless of how long the frame took to arrive.
|
||||||
remoteAnchorPage = target
|
remoteAnchorPage = target
|
||||||
@@ -389,6 +411,27 @@ struct ExerciseProgressView: View {
|
|||||||
withAnimation { currentPage = target }
|
withAnimation { currentPage = target }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Durable-state safety net for the ephemeral mirror. A peer's swipe frame can be lost
|
||||||
|
/// (`sendMessage` is reachable-only and the retry can run dry), but its durable write still
|
||||||
|
/// arrives via the document sync and is absorbed into `doc` by the parent. When the
|
||||||
|
/// absorbed doc shows sets completed beyond anything this screen has recorded or
|
||||||
|
/// followed, jump forward to the first unfinished set's work page so the flow can't stay
|
||||||
|
/// stuck on a stale phase. Forward-only — progress is monotonic, so a same-or-behind doc
|
||||||
|
/// is our own write echoing back — and only while the run is still in progress.
|
||||||
|
private func repairFromDurable() {
|
||||||
|
guard didRestorePage, let log,
|
||||||
|
log.status == WorkoutStatus.inProgress.rawValue else { return }
|
||||||
|
let completed = log.currentStateIndex
|
||||||
|
guard completed > knownStateIndex else { return }
|
||||||
|
knownStateIndex = completed
|
||||||
|
if log.sets > setCount { setCount = log.sets }
|
||||||
|
let target = base + min(max(0, completed), setCount - 1) * 2
|
||||||
|
guard target > currentPage else { return }
|
||||||
|
clearRemoteAnchor()
|
||||||
|
pageChangeCause = .remote
|
||||||
|
withAnimation { currentPage = target }
|
||||||
|
}
|
||||||
|
|
||||||
/// Drop the remote anchor when a local transition moves us off the remote-driven page,
|
/// Drop the remote anchor when a local transition moves us off the remote-driven page,
|
||||||
/// so the next phase counts off local `now` (and a swipe back doesn't reuse a stale anchor).
|
/// so the next phase counts off local `now` (and a swipe back doesn't reuse a stale anchor).
|
||||||
private func clearRemoteAnchor() {
|
private func clearRemoteAnchor() {
|
||||||
@@ -596,6 +639,7 @@ struct ExerciseProgressView: View {
|
|||||||
/// plan, and slide forward into the bonus set's work phase.
|
/// plan, and slide forward into the bonus set's work phase.
|
||||||
private func addSet() {
|
private func addSet() {
|
||||||
let newCount = setCount + 1
|
let newCount = setCount + 1
|
||||||
|
knownStateIndex = max(knownStateIndex, newCount - 1)
|
||||||
|
|
||||||
if let i = doc.logs.firstIndex(where: { $0.id == logID }) {
|
if let i = doc.logs.firstIndex(where: { $0.id == logID }) {
|
||||||
doc.logs[i].sets = newCount
|
doc.logs[i].sets = newCount
|
||||||
@@ -623,8 +667,8 @@ struct ExerciseProgressView: View {
|
|||||||
/// TabView snap to page 0 — never un-counts a set.
|
/// TabView snap to page 0 — never un-counts a set.
|
||||||
private func recordProgress(for pageIndex: Int) {
|
private func recordProgress(for pageIndex: Int) {
|
||||||
if showsReady && pageIndex == 0 { return } // Ready page records nothing
|
if showsReady && pageIndex == 0 { return } // Ready page records nothing
|
||||||
let cycleIndex = pageIndex - base
|
let reached = completedSets(for: pageIndex)
|
||||||
let reached = min(max(0, (cycleIndex + 1) / 2), setCount - 1)
|
knownStateIndex = max(knownStateIndex, reached)
|
||||||
|
|
||||||
guard let i = doc.logs.firstIndex(where: { $0.id == logID }) else { return }
|
guard let i = doc.logs.firstIndex(where: { $0.id == logID }) else { return }
|
||||||
guard reached > doc.logs[i].currentStateIndex else { return }
|
guard reached > doc.logs[i].currentStateIndex else { return }
|
||||||
@@ -644,6 +688,7 @@ struct ExerciseProgressView: View {
|
|||||||
private func resetExercise() {
|
private func resetExercise() {
|
||||||
guard let i = doc.logs.firstIndex(where: { $0.id == logID }) else { return }
|
guard let i = doc.logs.firstIndex(where: { $0.id == logID }) else { return }
|
||||||
let log = doc.logs[i]
|
let log = doc.logs[i]
|
||||||
|
knownStateIndex = 0
|
||||||
// Skip the write if it's already pristine (e.g. landing on Ready before any set).
|
// Skip the write if it's already pristine (e.g. landing on Ready before any set).
|
||||||
guard log.currentStateIndex != 0
|
guard log.currentStateIndex != 0
|
||||||
|| log.status != WorkoutStatus.notStarted.rawValue else { return }
|
|| log.status != WorkoutStatus.notStarted.rawValue else { return }
|
||||||
@@ -655,6 +700,7 @@ struct ExerciseProgressView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func completeExercise() {
|
private func completeExercise() {
|
||||||
|
knownStateIndex = max(knownStateIndex, setCount)
|
||||||
guard let i = doc.logs.firstIndex(where: { $0.id == logID }) else { return }
|
guard let i = doc.logs.firstIndex(where: { $0.id == logID }) else { return }
|
||||||
doc.logs[i].currentStateIndex = setCount
|
doc.logs[i].currentStateIndex = setCount
|
||||||
doc.logs[i].transition(to: .completed)
|
doc.logs[i].transition(to: .completed)
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
@testable import Workouts
|
||||||
|
|
||||||
|
/// Pins the live-mirror staleness ordering (`LiveProgress.isNewer`): the shared per-run
|
||||||
|
/// version sequence decides, and the phase anchor breaks ties. The tie-break exists
|
||||||
|
/// because the sequence isn't collision-free — each side catches its counter up only from
|
||||||
|
/// frames it *receives*, so after a lost frame both devices can mint the same version
|
||||||
|
/// independently, and the later wall-clock action must win. Equal version *and* equal
|
||||||
|
/// anchor is the same frame redelivered, which must NOT rank as newer (a redelivery would
|
||||||
|
/// otherwise re-jump the follower's page).
|
||||||
|
struct LiveProgressOrderingTests {
|
||||||
|
private static let t0 = Date(timeIntervalSince1970: 1_700_000_000)
|
||||||
|
|
||||||
|
private func frame(version: Int, start: Date) -> LiveProgress {
|
||||||
|
LiveProgress(workoutID: "W", logID: "L", exerciseName: "Bench", phase: .work,
|
||||||
|
setIndex: 0, setCount: 3, detail: "8 reps",
|
||||||
|
phaseStart: start, phaseEnd: nil, version: version)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func higherVersionWinsRegardlessOfAnchor() {
|
||||||
|
let older = frame(version: 3, start: Self.t0.addingTimeInterval(10))
|
||||||
|
let newer = frame(version: 4, start: Self.t0) // earlier anchor, higher version
|
||||||
|
#expect(newer.isNewer(than: older))
|
||||||
|
#expect(!older.isNewer(than: newer))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func collidedVersionsTieBreakOnAnchor() {
|
||||||
|
let phoneFrame = frame(version: 4, start: Self.t0)
|
||||||
|
let watchFrame = frame(version: 4, start: Self.t0.addingTimeInterval(2))
|
||||||
|
#expect(watchFrame.isNewer(than: phoneFrame))
|
||||||
|
#expect(!phoneFrame.isNewer(than: watchFrame))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func redeliveryIsNotNewer() {
|
||||||
|
let sent = frame(version: 4, start: Self.t0)
|
||||||
|
#expect(!sent.isNewer(than: sent))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func versionAndStartOverloadMatchesFrameOverload() {
|
||||||
|
let f = frame(version: 4, start: Self.t0)
|
||||||
|
#expect(f.isNewer(thanVersion: 3, start: Self.t0.addingTimeInterval(99)))
|
||||||
|
#expect(f.isNewer(thanVersion: 4, start: Self.t0.addingTimeInterval(-1)))
|
||||||
|
#expect(!f.isNewer(thanVersion: 4, start: Self.t0))
|
||||||
|
#expect(!f.isNewer(thanVersion: 5, start: .distantPast))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user