Foreground Connection Delay Adjustment
Nucleic-Session: CB658C4F-A19F-447C-B313-7CB21783BD94 Co-authored-by: Nucleic <[email protected]>
This commit is contained in:
@@ -203,6 +203,121 @@ final class RemoteStore: ObservableObject {
|
|||||||
for conn in live { conn.send(.approvalRespond(id, decision)) }
|
for conn in live { conn.send(.approvalRespond(id, decision)) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Optimistic connect window
|
||||||
|
//
|
||||||
|
// iOS suspends the app on background and silently kills its sockets; the phone can also wake with
|
||||||
|
// a socket the OS already tore down but that still reads `.connected`. Either way the link takes a
|
||||||
|
// beat to re-handshake (fast — see `HostConnection.revalidate` — but not instant). Flashing
|
||||||
|
// "Disconnected" and graying every action for that second reads as jank. So for a few seconds
|
||||||
|
// after launch/foreground the store *pretends* it's still connected (see `rebuildAggregate`): the
|
||||||
|
// chip stays green and the composer/controls stay live, presenting the last known-good state.
|
||||||
|
// Actions the user takes meanwhile are held in `pendingActions` and replayed the instant a real
|
||||||
|
// link comes up — or discarded if the window lapses without one (UX_IOS §6, §11.5).
|
||||||
|
|
||||||
|
/// How long to keep presenting the last-live state after a launch/foreground before revealing the
|
||||||
|
/// truth. The fast foreground reconnect is typically sub-second, so this is a safe ceiling.
|
||||||
|
private static let optimisticWindow: TimeInterval = 5
|
||||||
|
|
||||||
|
/// Deadline of the current pretend-connected window; nil when not pretending.
|
||||||
|
private var optimisticUntil: Date?
|
||||||
|
private var optimisticExpiryTask: Task<Void, Never>?
|
||||||
|
/// User intents taken during the optimistic window while no link was live yet — replayed on
|
||||||
|
/// connect, discarded on expiry. Each carries the time it was queued as a staleness guard.
|
||||||
|
private var pendingActions: [(msg: ClientMsg, at: Date)] = []
|
||||||
|
|
||||||
|
/// The last known-good live projection, kept so the optimistic overlay (and a cold launch, where
|
||||||
|
/// no connection has re-formed yet) can present "connected" before the socket actually
|
||||||
|
/// re-handshakes. Only transport + granted scope are needed: capabilities/catalog are already
|
||||||
|
/// retained on the `HostConnection` across a drop, and it's connectivity + scope that gate the UI.
|
||||||
|
private struct LastLiveSnapshot: Codable, Equatable {
|
||||||
|
var transport: SyncTransportHint
|
||||||
|
var grantedScope: DeviceScope
|
||||||
|
}
|
||||||
|
private var lastLive: LastLiveSnapshot? = RemoteStore.loadLastLive()
|
||||||
|
private static let lastLiveKey = "nucleic.lastLiveProjection"
|
||||||
|
private static func loadLastLive() -> LastLiveSnapshot? {
|
||||||
|
guard let data = UserDefaults.standard.data(forKey: lastLiveKey) else { return nil }
|
||||||
|
return try? JSONDecoder().decode(LastLiveSnapshot.self, from: data)
|
||||||
|
}
|
||||||
|
private static func saveLastLive(_ snap: LastLiveSnapshot) {
|
||||||
|
guard let data = try? JSONEncoder().encode(snap) else { return }
|
||||||
|
UserDefaults.standard.set(data, forKey: lastLiveKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether we're currently inside the pretend-connected window.
|
||||||
|
private var isOptimisticActive: Bool {
|
||||||
|
guard let until = optimisticUntil else { return false }
|
||||||
|
return Date() < until
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Snapshot the current known-good live projection so the overlay can present it later. Cheap: a
|
||||||
|
/// no-op unless transport or scope actually changed, and persisted so even a cold launch paints
|
||||||
|
/// connected before the first handshake.
|
||||||
|
private func captureLastLive(transport: SyncTransportHint, scope: DeviceScope) {
|
||||||
|
let snap = LastLiveSnapshot(transport: transport, grantedScope: scope)
|
||||||
|
guard snap != lastLive else { return }
|
||||||
|
lastLive = snap
|
||||||
|
Self.saveLastLive(snap)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enter the pretend-connected window (launch / foreground). No-op in demo or when unpaired —
|
||||||
|
/// there's nothing to pretend a connection to. Safe to call when already live: the overlay only
|
||||||
|
/// engages if the link reads not-live within the window (the woken-zombie-socket case), and the
|
||||||
|
/// window simply expires harmlessly if the connection stays up.
|
||||||
|
private func beginOptimisticWindow() {
|
||||||
|
guard !demoMode, isPaired else { return }
|
||||||
|
optimisticUntil = Date().addingTimeInterval(Self.optimisticWindow)
|
||||||
|
optimisticExpiryTask?.cancel()
|
||||||
|
optimisticExpiryTask = Task { [weak self] in
|
||||||
|
try? await Task.sleep(for: .seconds(Self.optimisticWindow))
|
||||||
|
guard let self, !Task.isCancelled else { return }
|
||||||
|
self.expireOptimisticWindow()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The window lapsed. If a link came up we send whatever was queued; if not, we throw the queued
|
||||||
|
/// actions away (never fire them late — UX_IOS §11.5) and re-render the true, honest state so the
|
||||||
|
/// chip and composer stop pretending.
|
||||||
|
private func expireOptimisticWindow() {
|
||||||
|
optimisticUntil = nil
|
||||||
|
optimisticExpiryTask = nil
|
||||||
|
if hasLiveConnection {
|
||||||
|
flushPendingActions()
|
||||||
|
} else {
|
||||||
|
pendingActions.removeAll()
|
||||||
|
}
|
||||||
|
guard !demoMode else { return }
|
||||||
|
rebuildAggregate()
|
||||||
|
refreshAggregate()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replay everything queued during the optimistic window now that a link is live, oldest first.
|
||||||
|
/// Called from `didUpdate` on every connectivity change and on window expiry. A stale entry (older
|
||||||
|
/// than the window plus slack, e.g. the link flapped up-and-down) is dropped rather than fired late.
|
||||||
|
private func flushPendingActions() {
|
||||||
|
guard hasLiveConnection, !pendingActions.isEmpty else { return }
|
||||||
|
let queued = pendingActions
|
||||||
|
pendingActions.removeAll()
|
||||||
|
for (msg, at) in queued where Date().timeIntervalSince(at) < Self.optimisticWindow + 5 {
|
||||||
|
send(msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether an intent should be held during the optimistic window (a genuine user write/control
|
||||||
|
/// action worth replaying) versus dropped (host-agnostic pulls and connection-internal traffic,
|
||||||
|
/// which the reconnect re-issues on its own — subscribe/list/fetch are re-sent on `.ready`).
|
||||||
|
private func isQueueableIntent(_ msg: ClientMsg) -> Bool {
|
||||||
|
switch msg {
|
||||||
|
case .sendInput, .startChat, .captureTodo, .dispatchTodo, .setTodoStatus, .deleteTodo,
|
||||||
|
.renameSession, .setFavorite, .setArchived, .deleteSession, .discard, .integrate,
|
||||||
|
.interrupt, .cancelQueuedMessage, .setSessionModel, .setSessionEffort, .setSessionAuto,
|
||||||
|
.setSessionAutoShip, .setSessionShipBranch, .approvalRespond:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static let lastOpenedKey = "nucleic.lastOpenedAt"
|
private static let lastOpenedKey = "nucleic.lastOpenedAt"
|
||||||
private static func loadLastOpened() -> [SessionID: Date] {
|
private static func loadLastOpened() -> [SessionID: Date] {
|
||||||
guard let raw = UserDefaults.standard.dictionary(forKey: lastOpenedKey) else { return [:] }
|
guard let raw = UserDefaults.standard.dictionary(forKey: lastOpenedKey) else { return [:] }
|
||||||
|
|||||||
Reference in New Issue
Block a user