Merge branch 'dev' into canary
This commit is contained in:
@@ -38,6 +38,11 @@ final class RemoteStore: ObservableObject {
|
|||||||
if case .connected = self { return true }
|
if case .connected = self { return true }
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
/// The live transport when connected — for the optimistic overlay's "Connected · …" label.
|
||||||
|
var transport: SyncTransportHint? {
|
||||||
|
if case .connected(let t) = self { return t }
|
||||||
|
return nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Published private(set) var connectivity: Connectivity = .unpaired
|
@Published private(set) var connectivity: Connectivity = .unpaired
|
||||||
@@ -198,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 [:] }
|
||||||
@@ -324,6 +444,9 @@ final class RemoteStore: ObservableObject {
|
|||||||
if isPaired {
|
if isPaired {
|
||||||
// Show the saved chat history immediately, before any host connects.
|
// Show the saved chat history immediately, before any host connects.
|
||||||
if sessions.isEmpty { sessions = cachedSummaries }
|
if sessions.isEmpty { sessions = cachedSummaries }
|
||||||
|
// Assume connected while the first handshake completes, so launch doesn't open on a
|
||||||
|
// grayed-out "Disconnected" shell for the second it takes to come up.
|
||||||
|
beginOptimisticWindow()
|
||||||
reconnect()
|
reconnect()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -359,6 +482,11 @@ final class RemoteStore: ObservableObject {
|
|||||||
func onForeground() {
|
func onForeground() {
|
||||||
endBackgroundHold()
|
endBackgroundHold()
|
||||||
guard !demoMode, isPaired else { return }
|
guard !demoMode, isPaired else { return }
|
||||||
|
// Pretend we're still connected for a few seconds while the (possibly OS-killed) sockets
|
||||||
|
// re-handshake — the reconnect below is fast, and flashing "Disconnected" in the meantime
|
||||||
|
// just feels janky. Set before `reconnect()` so the connecting/reconnecting states it
|
||||||
|
// produces are overlaid with the last-live projection (see `rebuildAggregate`).
|
||||||
|
beginOptimisticWindow()
|
||||||
// Foreground again: the app self-creates its own Live Activity now, so tell the hosts to stop
|
// Foreground again: the app self-creates its own Live Activity now, so tell the hosts to stop
|
||||||
// push-to-starting it (UX_IOS §5.3). Sent to already-live hosts; ones still reconnecting get
|
// push-to-starting it (UX_IOS §5.3). Sent to already-live hosts; ones still reconnecting get
|
||||||
// it via `didUpdate` once live.
|
// it via `didUpdate` once live.
|
||||||
@@ -641,6 +769,9 @@ final class RemoteStore: ObservableObject {
|
|||||||
self.rebuildAggregate()
|
self.rebuildAggregate()
|
||||||
self.refreshAggregate()
|
self.refreshAggregate()
|
||||||
self.flushPendingNotificationDecision()
|
self.flushPendingNotificationDecision()
|
||||||
|
// A link just came up — replay any intents the user took while we were optimistically
|
||||||
|
// pretending to be connected.
|
||||||
|
self.flushPendingActions()
|
||||||
// A host that just connected (or reconnected) needs the current Live Activity token
|
// A host that just connected (or reconnected) needs the current Live Activity token
|
||||||
// so it can push while the phone is away — and the push-to-start token so it can create
|
// so it can push while the phone is away — and the push-to-start token so it can create
|
||||||
// the glance cold when work starts before the app is opened.
|
// the glance cold when work starts before the app is opened.
|
||||||
@@ -796,18 +927,35 @@ final class RemoteStore: ObservableObject {
|
|||||||
?? WireCapabilities(canModifyToolInput: false, allowAlwaysScopes: []))
|
?? WireCapabilities(canModifyToolInput: false, allowAlwaysScopes: []))
|
||||||
setIfChanged(\.modelCatalog, ctx?.modelCatalog ?? .empty)
|
setIfChanged(\.modelCatalog, ctx?.modelCatalog ?? .empty)
|
||||||
|
|
||||||
|
var targetConnectivity: Connectivity
|
||||||
|
var targetScope: DeviceScope
|
||||||
if let id = openSessionHostID, let conn = connections[id] {
|
if let id = openSessionHostID, let conn = connections[id] {
|
||||||
// While a transcript is open, the composer/controls act on *that* Mac — its connectivity
|
// While a transcript is open, the composer/controls act on *that* Mac — its connectivity
|
||||||
// gates send and its scope drives the control affordances.
|
// gates send and its scope drives the control affordances.
|
||||||
setIfChanged(\.connectivity, conn.connectivity)
|
targetConnectivity = conn.connectivity
|
||||||
setIfChanged(\.grantedScope, conn.grantedScope)
|
targetScope = conn.grantedScope
|
||||||
} else {
|
} else {
|
||||||
setIfChanged(\.connectivity, aggregateConnectivity())
|
targetConnectivity = aggregateConnectivity()
|
||||||
// Optimistic new-chat gating: enabled if *any* Mac grants control (the owning Mac still
|
// Optimistic new-chat gating: enabled if *any* Mac grants control (the owning Mac still
|
||||||
// enforces scope when the intent lands there).
|
// enforces scope when the intent lands there).
|
||||||
setIfChanged(\.grantedScope, connections.values
|
targetScope = connections.values
|
||||||
.filter { $0.connectivity.isLive }.map(\.grantedScope).max() ?? .approve)
|
.filter { $0.connectivity.isLive }.map(\.grantedScope).max() ?? .approve
|
||||||
}
|
}
|
||||||
|
if targetConnectivity.isLive {
|
||||||
|
// Truly live — remember this projection so the optimistic overlay can present it across
|
||||||
|
// the next foreground/relaunch.
|
||||||
|
captureLastLive(transport: targetConnectivity.transport ?? .lan, scope: targetScope)
|
||||||
|
} else if isOptimisticActive, let last = lastLive {
|
||||||
|
// Right after launch/foreground the link is re-handshaking (or a suspended socket woke up
|
||||||
|
// looking dead). Rather than flash "Disconnected" for the beat it takes to reconnect, keep
|
||||||
|
// presenting the last known-good live state — the app assumes it's connected. Actions
|
||||||
|
// taken now are queued in `send` and replayed once a link is up (or discarded when the
|
||||||
|
// window lapses without one).
|
||||||
|
targetConnectivity = .connected(last.transport)
|
||||||
|
targetScope = last.grantedScope
|
||||||
|
}
|
||||||
|
setIfChanged(\.connectivity, targetConnectivity)
|
||||||
|
setIfChanged(\.grantedScope, targetScope)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Assign a `@Published` property only when the value actually differs, so a no-op rebuild
|
/// Assign a `@Published` property only when the value actually differs, so a no-op rebuild
|
||||||
@@ -1348,7 +1496,10 @@ final class RemoteStore: ObservableObject {
|
|||||||
if demoMode {
|
if demoMode {
|
||||||
demoHandle(.approvalRespond(approval.id, decision))
|
demoHandle(.approvalRespond(approval.id, decision))
|
||||||
} else {
|
} else {
|
||||||
connection(owningSession: approval.sessionID)?.send(.approvalRespond(approval.id, decision))
|
// Through `send` so an Allow/Deny tapped during the optimistic window is queued and
|
||||||
|
// replayed on connect (a live host resolves it; others no-op on the unknown id), rather
|
||||||
|
// than dropped because the owning Mac's socket is mid-reconnect.
|
||||||
|
send(.approvalRespond(approval.id, decision))
|
||||||
}
|
}
|
||||||
openApprovals.removeAll { $0.id == approval.id } // optimistic dismiss; host confirms
|
openApprovals.removeAll { $0.id == approval.id } // optimistic dismiss; host confirms
|
||||||
}
|
}
|
||||||
@@ -1476,6 +1627,13 @@ final class RemoteStore: ObservableObject {
|
|||||||
/// to the first live Mac. Demo has no connections and mutates the seeded aggregate directly.
|
/// to the first live Mac. Demo has no connections and mutates the seeded aggregate directly.
|
||||||
private func send(_ msg: ClientMsg) {
|
private func send(_ msg: ClientMsg) {
|
||||||
if demoMode { demoHandle(msg); return }
|
if demoMode { demoHandle(msg); return }
|
||||||
|
// Optimistic window (post launch/foreground): if no Mac is live yet but we're presenting a
|
||||||
|
// pretend-connected UI, hold the user's intent and replay it the instant a link comes up
|
||||||
|
// rather than dropping it silently. Discarded if the window lapses without a connection.
|
||||||
|
if !hasLiveConnection, isOptimisticActive, isQueueableIntent(msg) {
|
||||||
|
pendingActions.append((msg, Date()))
|
||||||
|
return
|
||||||
|
}
|
||||||
switch msg {
|
switch msg {
|
||||||
// Session-owning intents → the Mac that has this session.
|
// Session-owning intents → the Mac that has this session.
|
||||||
case .subscribe(let s):
|
case .subscribe(let s):
|
||||||
|
|||||||
@@ -153,7 +153,7 @@ struct SettingsView: View {
|
|||||||
Button {
|
Button {
|
||||||
showScanner = true
|
showScanner = true
|
||||||
} label: {
|
} label: {
|
||||||
Label("Join an Orbital mesh", systemImage: "qrcode.viewfinder")
|
Label("Join an Antimatter mesh", systemImage: "qrcode.viewfinder")
|
||||||
}
|
}
|
||||||
Button {
|
Button {
|
||||||
showManualPair = true
|
showManualPair = true
|
||||||
@@ -454,7 +454,7 @@ struct AddDeviceView: View {
|
|||||||
} header: {
|
} header: {
|
||||||
Text("Scan to join")
|
Text("Scan to join")
|
||||||
} footer: {
|
} footer: {
|
||||||
Text("On a new iPhone or iPad, open Nucleic Remote ▸ Join an Orbital mesh and scan this. "
|
Text("On a new iPhone or iPad, open Nucleic Remote ▸ Join an Antimatter mesh and scan this. "
|
||||||
+ "It joins the whole group — every Mac and device here.")
|
+ "It joins the whole group — every Mac and device here.")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user