Files
nucleic-remote-ios/NucleicRemote/NucleicRemote/Models/RemoteStore.swift
T
abkslmandClaude Fable 5 272039cca5 Tailnet: interactive browser login + LAN↔tailnet fallback
Browser login — the auth key is now optional on both platforms. When the
embedded node starts with no key, TailnetNode asks the backend (LocalAPI
backendStatus — IPN state, deliberately not filesystem heuristics: tsnet
writes logs and a machine key on every start, registered or not) whether a
login is needed, triggers login-interactive, surfaces the auth URL through a
new statusStream()/.needsLogin, and waits for Running (4-minute deadline,
generation-fenced against stop/restart). The Mac auto-opens the login page
from Settings ▸ Remote and shows a re-open button; the iPhone auto-opens only
during user-initiated pairing (a background reconnect that suddenly needs a
login must not eject the user to Safari — Settings ▸ Tailscale carries the
link). The remote-access toggle now reflects the in-flight start instead of
snapping off for the whole login window, and toggling off mid-start is
honored at both commit points (before and after host.start).

LAN↔tailnet fallback — picking Tailnet now keeps LAN on too: the host runs
both listeners under a new CompositeSyncListener (merged accept stream; one
child ending doesn't end the rest) and the pairing QR carries both hints.
The phone builds an ordered candidate chain — LAN first (QR hint or Bonjour),
tailnet second — and walks it on pair and reconnect, so a phone that leaves
the Mac's Wi‑Fi rolls over to the tailnet and rolls back when it returns.
A per-channel 4s connect guard (readiness-checking, bound to exactly its
channel) keeps a stale LAN hint from hanging the chain; a stale-client guard
in consume() keeps a replaced client's tail events from advancing it; chain
exhaustion during pairing lands in a terminal failure instead of spinning on
"Connecting…"; routine pre-fallback handshake errors no longer flash the red
error bubble. "Connected · LAN / Tailnet" shows whichever transport won.

Multi-agent review: 11 confirmed findings (incl. the login gate being dead
code via tsnet's eager state-dir writes, and two connect-timeout races), all
fixed and re-verified. Suite green (24 sync-related tests incl. 3 new
CompositeSyncListener tests); macOS + iOS builds clean.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-03 16:14:45 -07:00

1143 lines
58 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Foundation
import Network
import SwiftUI
import NucleicProtocol
import NucleicTailnet
/// On the phone there's no app-side `SessionSummary` view-model to collide with, so the wire
/// type *is* the model. Alias it under the host's name so the shared vocabulary reads the same.
typealias WireSessionSummary = NucleicProtocol.SessionSummary
/// The phone's single source of UI state — a pure projection of the host (UX_IOS §1.2). Owns
/// the `SyncClient`, reflects connectivity truth, and exposes the session list + the open
/// session's transcript/approvals. No canonical state is invented on-device.
@MainActor
final class RemoteStore: ObservableObject {
enum Connectivity: Equatable {
case unpaired
case connecting
case reconnecting
case connected(SyncTransportHint)
case hostOffline
case failed(String)
var label: String {
switch self {
case .unpaired: "Not paired"
case .connecting: "Connecting…"
case .reconnecting: "Reconnecting…"
case .connected(let transport): "Connected · \(transport.label)"
case .hostOffline: "Mac offline"
case .failed(let m): m
}
}
var isLive: Bool {
if case .connected = self { return true }
return false
}
}
@Published private(set) var connectivity: Connectivity = .unpaired
@Published private(set) var hostName: String = ""
@Published private(set) var sessions: [WireSessionSummary] = []
@Published private(set) var capabilities = WireCapabilities(canModifyToolInput: false, allowAlwaysScopes: [])
@Published private(set) var grantedScope: DeviceScope = .approve
/// The host's model/effort catalog (SYNC §5.2), driving the composer + session-header pickers.
/// `.empty` until `Welcome` arrives; the pickers fall back to the built-in effort list.
@Published private(set) var modelCatalog: WireModelCatalog = .empty
/// Home / Projects / To-Dos state — the dashboard projection.
@Published private(set) var dashboard = DashboardSnapshot.empty
// Open session projection.
@Published private(set) var openSessionID: SessionID?
@Published private(set) var openEvents: [AgentEvent] = []
@Published private(set) var openApprovals: [ApprovalRequest] = []
/// The open session's full diff (the Mac Diff tab's patch), fetched on demand when the
/// user opens the Diff tab and the host advertises `canFetchDiff`. Nil until it arrives.
@Published private(set) var openDiff: WireSessionDiff?
@Published private(set) var diffLoading = false
/// A transient host-reported error (the mobile echo of the Mac's last-error bubble):
/// shown as a red bubble at the bottom of the screen, auto-dismissed after a few seconds.
struct LastError: Equatable, Identifiable {
let id = UUID()
let message: String
let sessionID: SessionID?
}
@Published var lastError: LastError?
private var errorDismissTask: Task<Void, Never>?
/// When each session was last opened on this device, for the green "finished while you
/// weren't looking" wash on the session list (the Mac's unseen-completion marker; the wire
/// doesn't carry the host's flag, so the phone tracks its own view locally).
@Published private(set) var lastOpenedAt: [SessionID: Date] = RemoteStore.loadLastOpened()
/// A navigation request from outside the view hierarchy (notification tap → this session).
/// `RootView` switches to the Sessions tab; `SessionsView` pushes it and clears.
@Published var pendingRoute: SessionID?
/// Whether the app is foreground-active (scene phase), mirrored here so the store can
/// decide which transitions deserve a notification.
private(set) var isActive = true
func setScenePhaseActive(_ active: Bool) { isActive = active }
/// Route to a session from a notification tap (deep link).
func route(to sessionID: SessionID) { pendingRoute = sessionID }
/// An Allow/Deny straight from a notification action (UX_IOS §5.1). Requires a live
/// channel; if the socket dropped while backgrounded, reconnect and send once ready —
/// but only briefly (a stale queued approval must never fire minutes later; see
/// UX_IOS §11.5, and the host dedupes/`alreadyResolved`s a lost race anyway).
func respondFromNotification(_ id: ApprovalID, allow: Bool) {
let decision: Decision = allow ? .allow(updatedInput: nil) : .deny(reason: nil)
if connectivity.isLive {
send(.approvalRespond(id, decision))
} else {
pendingNotificationDecision = (id, decision, Date())
reconnect()
}
}
/// At most one decision waits for reconnect, and it expires after 30s.
private var pendingNotificationDecision: (ApprovalID, Decision, Date)?
private func flushPendingNotificationDecision() {
guard let (id, decision, at) = pendingNotificationDecision else { return }
pendingNotificationDecision = nil
guard Date().timeIntervalSince(at) < 30 else { return } // stale — require the app
send(.approvalRespond(id, decision))
}
private static let lastOpenedKey = "nucleic.lastOpenedAt"
private static func loadLastOpened() -> [SessionID: Date] {
guard let raw = UserDefaults.standard.dictionary(forKey: lastOpenedKey) else { return [:] }
return raw.reduce(into: [:]) { result, entry in
if let date = entry.value as? Date { result[SessionID(rawValue: entry.key)] = date }
}
}
private func persistLastOpened() {
let raw = lastOpenedAt.reduce(into: [String: Date]()) { $0[$1.key.rawValue] = $1.value }
UserDefaults.standard.set(raw, forKey: Self.lastOpenedKey)
}
/// Whether `summary` completed its work after the user last looked at it on this device.
func unseenCompletion(_ summary: WireSessionSummary) -> Bool {
let done = summary.status == .finished
|| (summary.status == .awaitingInput && summary.disposition == .completed)
guard done, summary.sessionID != openSessionID else { return false }
guard let opened = lastOpenedAt[summary.sessionID] else { return true }
return summary.updatedAt > opened
}
/// Non-archived sessions (archived chats are hidden, matching the Mac sidebar).
var liveSessions: [WireSessionSummary] { sessions.filter { !$0.archived } }
/// Sessions needing a human (drives the app-icon badge + NEEDS YOU section). Uses turn
/// disposition so a finished-the-work session doesn't count, and excludes archived.
var needsYouCount: Int {
liveSessions.filter { $0.status.needsYou($0.disposition) }.count
}
var canControl: Bool { grantedScope >= .control }
private let identity = IdentityStore.loadOrCreateIdentity()
let discovery = LANDiscovery()
private var client: SyncClient?
private var eventTask: Task<Void, Never>?
/// In-flight async connection setup (tailnet node start + dial); cancelled on teardown.
private var connectTask: Task<Void, Never>?
/// The pending reconnect backoff timer; cancelled on teardown so a stale retry can't
/// tear down a newer in-flight attempt.
private var retryTask: Task<Void, Never>?
/// The transport the current connection attempt uses (drives the "Connected · …" chip).
private var activeTransport: SyncTransportHint = .lan
/// The phone's embedded Tailscale node state, for Settings (nil = not running).
@Published private(set) var tailnetStatus: String?
/// The Tailscale interactive-login URL while the node waits for a browser login (a
/// first tailnet start with no auth key). Auto-opened; Settings shows a re-open button.
@Published private(set) var tailnetLoginURL: URL?
private var seenSeq: Set<UInt64> = []
private var reconnectAttempts = 0
/// Offline demo mode: seeds mock state and simulates the agent locally so every surface
/// renders — and the core loops (send, approve, start chat, to-dos) actually respond —
/// without a paired Mac. Reachable in two ways: the `NUCLEIC_DEMO=1` env var (dev /
/// screenshots, forced on at launch) and a persisted in-app toggle
/// (`enterDemo()` / `exitDemo()`) so an App Store reviewer with no Mac can exercise the app
/// (App Review Guideline 2.1). `@Published` so the UI can show a DEMO indicator and the
/// "Leave demo" affordance.
private static let demoModeKey = "nucleic.demoMode"
@Published private(set) var demoMode = ProcessInfo.processInfo.environment["NUCLEIC_DEMO"] == "1"
|| UserDefaults.standard.bool(forKey: RemoteStore.demoModeKey)
/// In-flight simulated-run tasks (canned streaming), cancelled on `exitDemo()`.
private var demoTasks: [Task<Void, Never>] = []
var isPaired: Bool { demoMode || IdentityStore.loadPairedHost() != nil }
var deviceFingerprint: String { identity.fingerprint }
// MARK: - Lifecycle
func onAppear() {
if demoMode { seedDemo(); return }
discovery.start()
if isPaired { reconnect() }
}
private func seedDemo() {
connectivity = .connected(.lan)
hostName = "Andrew's Mac"
grantedScope = .control
capabilities = WireCapabilities(
canModifyToolInput: true, allowAlwaysScopes: [.session, .toolName], canFetchDiff: true)
modelCatalog = WireModelCatalog(
groups: [
[WireModelCatalog.Model(sku: "claude-opus-4-8[1m]", displayName: "Opus 4.8", backend: .claudeCode,
contextBadge: "1M", contextWindow: 1_000_000,
efforts: ["low", "medium", "high", "xhigh", "max"], effortNoun: "Effort"),
WireModelCatalog.Model(sku: "claude-sonnet-4-6", displayName: "Sonnet 4.6", backend: .claudeCode,
contextBadge: nil, contextWindow: 200_000,
efforts: ["low", "medium", "high", "xhigh", "max"], effortNoun: "Effort")],
[WireModelCatalog.Model(sku: "gpt-5.5", displayName: "GPT-5.5", backend: .codex,
contextBadge: nil, contextWindow: 350_000,
efforts: ["low", "medium", "high", "xhigh"], effortNoun: "Reasoning")],
[WireModelCatalog.Model(sku: "grok-build", displayName: "Grok Build", backend: .grok,
contextBadge: nil, contextWindow: 256_000,
efforts: ["auto"], effortNoun: "Reasoning")],
],
effortDisplayNames: ["auto": "Auto", "orchestra": "Orchestra"],
orchestraSentinel: "orchestra", orchestraRequiresControlNote: "Requires Nucleic Control",
fallbackModel: "claude-opus-4-8[1m]", fallbackEffort: "high")
let p1 = ProjectID(rawValue: "p1"), p2 = ProjectID(rawValue: "p2")
func sum(_ id: String, _ project: ProjectID, _ name: String, _ title: String,
_ status: SessionStatus, _ disp: TurnDisposition? = nil, approvals: Int = 0,
fav: Bool = false, arch: Bool = false, add: Int = 0, rem: Int = 0) -> WireSessionSummary {
WireSessionSummary(
sessionID: SessionID(rawValue: id), projectID: project.rawValue, projectName: name,
backend: .claudeCode, status: status, disposition: disp, title: title,
branch: "nucleic/\(id)", lastSeq: 10,
diffStat: add + rem > 0 ? DiffStat(filesChanged: 2, added: add, removed: rem) : nil,
pendingApprovalCount: approvals, favorite: fav, archived: arch,
updatedAt: Date())
}
sessions = [
sum("a1", p1, "nucleic", "auth-refactor", .awaitingApproval, approvals: 1, add: 312, rem: 40),
sum("a2", p1, "nucleic", "flaky-tests", .running, add: 88, rem: 12),
sum("a3", p2, "website", "graphql-migration", .awaitingInput, .awaitingInput, fav: true),
sum("a4", p2, "website", "docs-pass", .awaitingInput, .completed, add: 20, rem: 4),
sum("a5", p1, "nucleic", "old-experiment", .finished, arch: true),
]
let cal = Calendar.current
let today = cal.startOfDay(for: Date())
let activity = (0..<40).map { i -> ActivityDay in
let count = (i * 7) % 6
// Sample tokens roughly track messages so the preview grid shades by usage.
return ActivityDay(day: cal.date(byAdding: .day, value: -i, to: today)!,
count: count, tokens: count * 8_500 + (i * 137) % 4_000)
}
dashboard = DashboardSnapshot(
counts: DashboardCounts(
projects: 2, chats: 5, activeChats: 2, messages: 142, activeDays: 9, tokens: 1_284_000),
activity: activity,
projects: [
WireProject(id: p1, name: "nucleic", defaultBranch: "main", sessionCount: 3, activeCount: 2),
WireProject(id: p2, name: "website", defaultBranch: "main", sessionCount: 2, activeCount: 1),
],
todos: [
WireTodo(id: TodoID(rawValue: "t1"), text: "Add dark mode to settings", summary: "Dark mode in settings",
projectID: p2, projectName: "website", status: .open, dispatchedSessionID: nil,
triage: "high", updatedAt: Date()),
WireTodo(id: TodoID(rawValue: "t2"), text: "Investigate the memory leak in the sync server", summary: "Sync server memory leak",
projectID: p1, projectName: "nucleic", status: .open, dispatchedSessionID: nil,
triage: "critical", updatedAt: Date()),
WireTodo(id: TodoID(rawValue: "t3"), text: "Write release notes", summary: nil,
projectID: nil, projectName: nil, status: .open, dispatchedSessionID: nil,
triage: "low", updatedAt: Date()),
],
usage: WireSubscriptionUsage(
fiveHour: WireUsageWindow(utilization: 42, resetsAt: Date().addingTimeInterval(3 * 3600)),
sevenDay: WireUsageWindow(utilization: 78, resetsAt: Date().addingTimeInterval(2.4 * 86_400))),
statusFeeds: [
WireStatusFeed(provider: "claude", providerName: "Claude", incidents: []),
WireStatusFeed(provider: "openai", providerName: "OpenAI", incidents: [
WireStatusIncident(
id: "i1", title: "Elevated errors on Codex", url: URL(string: "https://status.openai.com"),
updatedAt: Date(), state: "Monitoring", isResolved: false, components: ["Codex"]),
]),
WireStatusFeed(provider: "xai", providerName: "xAI", incidents: []),
])
LiveActivityManager.shared.sync(hostName: hostName, sessions: sessions)
}
/// Offline diff fixture (NUCLEIC_DEMO) so the full-patch Diff tab renders without a host.
private func demoDiff(_ sessionID: SessionID) -> WireSessionDiff {
WireSessionDiff(
sessionID: sessionID,
stat: DiffStat(filesChanged: 2, added: 312, removed: 40),
files: [
WireFileDiff(path: "auth/middleware.ts", oldPath: nil, status: "modified", added: 290, removed: 38),
WireFileDiff(path: "auth/session.ts", oldPath: nil, status: "added", added: 22, removed: 2),
],
patch: """
diff --git a/auth/middleware.ts b/auth/middleware.ts
--- a/auth/middleware.ts
+++ b/auth/middleware.ts
@@ -10,7 +10,9 @@ export function requireSession(req: Request) {
- const token = req.headers.get("x-auth")
+ const header = req.headers.get("authorization") ?? ""
+ const token = header.replace(/^Bearer /, "")
+ if (!token) throw new AuthError("missing bearer token")
return verify(token)
}
""")
}
/// One dialable way to reach the Mac. A connect builds an ordered candidate list — LAN
/// first (cheapest when reachable), then the tailnet (SYNC §3.2's LAN-then-fallback
/// ordering) — and `attempt` walks it until one carries a session.
private enum TransportAttempt {
case lan(NWEndpoint)
case tailnet(host: String, port: UInt16)
}
/// The in-progress connect: remaining candidates plus everything needed to start a
/// client on whichever one succeeds. Cleared on `.ready` (chain done) and by teardown.
private struct ConnectPlan {
var remaining: [TransportAttempt]
let hostStaticKey: Data
let mode: SyncClient.Mode
let deviceID: String
let pairingPayload: PairingPayload?
}
private var connectPlan: ConnectPlan?
/// Kills a LAN attempt whose TCP connect just hangs (stale IP hint) so the chain can
/// move on — NWConnection's own timeout is far too slow for a fallback decision.
private var lanConnectTimeout: Task<Void, Never>?
/// Pair from a scanned QR (SYNC §4.2): try the QR's transports in order (LAN hint or
/// Bonjour first, then the Mac's tailnet IP via the phone's embedded node), run XXpsk0,
/// and on success pin the host key for future IK reconnects.
func pair(with payload: PairingPayload) {
teardown()
connectivity = .connecting
hostName = payload.hostName
guard let hint = payload.transportHint else {
connectivity = .failed("This pairing code needs a newer version of Nucleic Remote.")
return
}
guard hint != .relay else {
connectivity = .failed("Relay connections aren't supported yet.")
return
}
let candidates = buildCandidates(
fingerprint: payload.hostStaticKey.fingerprintHex,
lanHost: payload.lanHost, lanPort: payload.lanPort,
tailnet: hint == .tailnet ? (payload.tailnetHost, payload.tailnetPort) : nil)
guard !candidates.isEmpty else {
connectivity = .failed(hint == .tailnet && !TailnetSupport.isBuiltIn
? TailnetError.notBuiltIn.errorDescription ?? "Tailscale support isn't built in"
: "No Mac found on this network")
return
}
connectPlan = ConnectPlan(
remaining: candidates, hostStaticKey: payload.hostStaticKey,
mode: .pair(secret: payload.pairingSecret), deviceID: IdentityStore.deviceID(),
pairingPayload: payload)
_ = tryNextCandidate()
}
/// Reconnect to the already-paired host using IK against the pinned static key: LAN
/// when reachable, else the pairing's tailnet hint — so a phone that leaves the Mac's
/// Wi‑Fi rolls over to the tailnet and rolls back when it returns.
func reconnect() {
guard let host = IdentityStore.loadPairedHost() else { connectivity = .unpaired; return }
teardown()
connectivity = reconnectAttempts == 0 ? .connecting : .reconnecting
hostName = host.hostName
guard host.transportHint != .relay else {
connectivity = .failed("Relay connections aren't supported yet.")
return
}
let candidates = buildCandidates(
fingerprint: host.fingerprint,
lanHost: host.lanHost, lanPort: host.lanPort,
tailnet: host.transportHint == .tailnet ? (host.tailnetHost, host.tailnetPort) : nil)
guard !candidates.isEmpty else {
connectivity = .hostOffline
scheduleRetry()
return
}
connectPlan = ConnectPlan(
remaining: candidates, hostStaticKey: host.hostStaticKey,
mode: .reconnect, deviceID: host.deviceID, pairingPayload: nil)
_ = tryNextCandidate()
}
/// LAN first (explicit hint, else a Bonjour match), tailnet second when the pairing
/// carries one and this build can dial it.
private func buildCandidates(
fingerprint: String?, lanHost: String?, lanPort: UInt16?,
tailnet: (host: String?, port: UInt16?)?
) -> [TransportAttempt] {
var candidates: [TransportAttempt] = []
if let endpoint = resolveEndpoint(fingerprint: fingerprint, lanHost: lanHost, lanPort: lanPort) {
candidates.append(.lan(endpoint))
}
if let tailnet, let host = tailnet.host, let port = tailnet.port, TailnetSupport.isBuiltIn {
candidates.append(.tailnet(host: host, port: port))
}
return candidates
}
/// Pop and dial the next candidate. False when the plan is exhausted (or gone) — the
/// caller then applies its terminal failure handling.
private func tryNextCandidate() -> Bool {
guard var plan = connectPlan, !plan.remaining.isEmpty else { return false }
let next = plan.remaining.removeFirst()
connectPlan = plan
attempt(next, plan: plan)
return true
}
private func attempt(_ candidate: TransportAttempt, plan: ConnectPlan) {
teardownClient()
switch candidate {
case .lan(let endpoint):
activeTransport = .lan
let channel = makeChannel(endpoint)
// Close the channel if TCP isn't up within the window (a stale IP hint would
// otherwise hang the chain on NWConnection's slow timeout); the finished stream
// then advances to the next candidate. The timer checks readiness itself — it's
// bound to exactly this channel, so a stale timer can never hit a later attempt.
lanConnectTimeout?.cancel()
lanConnectTimeout = Task { [weak channel] in
try? await Task.sleep(for: .seconds(4))
guard !Task.isCancelled, let channel, !channel.isReady else { return }
channel.close()
}
startClient(
channel: channel, hostStaticKey: plan.hostStaticKey,
mode: plan.mode, deviceID: plan.deviceID, pairingPayload: plan.pairingPayload)
case .tailnet(let host, let port):
activeTransport = .tailnet
connectTask = Task { [weak self] in
guard let self else { return }
do {
let channel = try await self.tailnetChannel(host: host, port: port)
guard !Task.isCancelled else { channel.close(); return }
self.startClient(
channel: channel, hostStaticKey: plan.hostStaticKey,
mode: plan.mode, deviceID: plan.deviceID, pairingPayload: plan.pairingPayload)
} catch {
guard !Task.isCancelled else { return }
self.tailnetAttemptFailed(error, isPairing: plan.pairingPayload != nil)
}
}
}
}
/// The tailnet is always the last candidate, so its failure ends the chain: terminal
/// for pairing and for anything retrying can't fix; otherwise offline + backoff.
private func tailnetAttemptFailed(_ error: Error, isPairing: Bool) {
if tryNextCandidate() { return }
if isPairing {
connectivity = .failed(error.localizedDescription)
return
}
switch error {
case TailnetError.notBuiltIn, TailnetError.notConfigured:
connectivity = .failed(error.localizedDescription)
default:
connectivity = .hostOffline
scheduleRetry()
}
}
/// Create the `SyncClient` on an established channel and start consuming its events —
/// the tail of every connect path, LAN or tailnet, pair or reconnect.
private func startClient(
channel: any FrameChannel, hostStaticKey: Data, mode: SyncClient.Mode,
deviceID: String, pairingPayload: PairingPayload?
) {
let client = SyncClient(
channel: channel, identity: identity, hostStaticKey: hostStaticKey,
mode: mode, deviceID: deviceID,
deviceLabel: UIDevice.current.name, pushToken: PushRegistrar.shared.tokenHex,
releaseChannel: BuildInfo.current.channel.releaseChannel)
self.client = client
consume(client, pairingPayload: pairingPayload)
}
/// Bring the phone's embedded Tailscale node up (first run needs the auth key from
/// Settings ▸ Tailscale; afterwards the on-disk state carries the registration) and dial
/// the Mac's tailnet address.
private func tailnetChannel(host: String, port: UInt16) async throws -> FDFrameChannel {
guard TailnetSupport.isBuiltIn else { throw TailnetError.notBuiltIn }
let config = Self.phoneTailnetConfig()
tailnetStatus = "Starting…"
// Mirror node status while the start is in flight. A first start with no auth key
// goes through the interactive browser login; pairing usually runs from the scanner
// sheet — not Settings — so take the user straight to the approval page.
let watcher = Task { [weak self] in
for await status in await TailnetNode.shared.statusStream() {
guard let self, !Task.isCancelled else { break }
self.tailnetStatus = status.label
if case .needsLogin(let url) = status, let loginURL = URL(string: url) {
if self.tailnetLoginURL != loginURL {
self.tailnetLoginURL = loginURL
// Eject to Safari only for user-initiated pairing — the user is
// actively watching. A routine reconnect that suddenly needs a
// login (node revoked, state wiped) must not yank them out of the
// app; Settings ▸ Tailscale carries the login link instead.
if self.connectPlan?.pairingPayload != nil {
UIApplication.shared.open(loginURL, options: [:], completionHandler: nil)
}
}
} else {
self.tailnetLoginURL = nil
}
}
}
defer {
watcher.cancel()
tailnetLoginURL = nil
}
do {
try await TailnetNode.shared.ensureRunning(config: config)
} catch TailnetError.timedOut(let message) {
// A login/auth timeout won't fix itself — retrying would just block per lap.
// Rethrow as .notConfigured so reconnect() treats it as terminal, not offline.
tailnetStatus = await TailnetNode.shared.status.label
throw TailnetError.notConfigured(message)
} catch {
tailnetStatus = await TailnetNode.shared.status.label
throw error
}
tailnetStatus = await TailnetNode.shared.status.label
return try await TailnetNode.shared.dial(host: host, port: port)
}
/// The phone's embedded-node config. State lives in this app's sandboxed Application
/// Support (no cross-channel collision — each channel is its own app container).
private static func phoneTailnetConfig() -> TailnetConfig {
let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
.appendingPathComponent("Nucleic", isDirectory: true)
.appendingPathComponent("tailnet", isDirectory: true)
return TailnetConfig(
hostName: TailnetConfig.nodeName(for: UIDevice.current.name),
stateDirectory: base,
authKey: TailnetAuthStore.loadAuthKey())
}
func unpair() {
teardown()
IdentityStore.clearPairedHost()
connectivity = .unpaired
sessions = []
// Nothing left to dial — spin the embedded Tailscale node down if it was running.
Task { await TailnetNode.shared.stop() }
tailnetStatus = nil
LiveActivityManager.shared.end()
NotificationRouter.shared.updateBadge(0)
}
/// Enter the in-app demo (the "Explore a demo" button): drop any live connection, persist the
/// flag so it survives relaunch (a reviewer may relaunch), and seed the mock world. `isPaired`
/// then returns true, so `RootView` shows the full TabView.
func enterDemo() {
teardown()
demoMode = true
UserDefaults.standard.set(true, forKey: Self.demoModeKey)
reconnectAttempts = 0
seedDemo()
}
/// Leave the demo (Settings ▸ Leave demo): cancel any simulated runs, clear the mock world,
/// and return to the real state — reconnect if a Mac is actually paired, else the pairing intro.
func exitDemo() {
demoMode = false
UserDefaults.standard.set(false, forKey: Self.demoModeKey)
demoTasks.forEach { $0.cancel() }
demoTasks.removeAll()
openSessionID = nil
openEvents = []
openApprovals = []
openDiff = nil
diffLoading = false
sessions = []
dashboard = .empty
LiveActivityManager.shared.end()
NotificationRouter.shared.updateBadge(0)
if IdentityStore.loadPairedHost() != nil {
reconnect()
} else {
connectivity = .unpaired
}
}
// MARK: - Intents (UX_IOS §9)
func open(_ sessionID: SessionID) {
openSessionID = sessionID
openEvents = []
openApprovals = []
openDiff = nil
diffLoading = false
seenSeq.removeAll()
markOpened(sessionID)
if demoMode { seedDemoTranscript(sessionID); return }
send(.subscribe(Subscribe(sessionID: sessionID, sinceSeq: nil, verbosity: .full)))
}
/// Ask the host for the open session's full patch (Diff tab). No-op when the host
/// doesn't advertise the capability — the view falls back to the diffstat summary.
func fetchDiff(_ sessionID: SessionID) {
if demoMode { openDiff = demoDiff(sessionID); return }
guard capabilities.canFetchDiff else { return }
diffLoading = openDiff == nil
send(.fetchDiff(sessionID))
}
/// Record that the user looked at this session now (clears its unseen-completion wash).
func markOpened(_ sessionID: SessionID) {
lastOpenedAt[sessionID] = Date()
persistLastOpened()
}
/// Offline transcript fixture (NUCLEIC_DEMO) so the richer transcript surfaces — grouped
/// tools, Orchestra card, usage/cost, file changes, run outcome — render without a host.
private func seedDemoTranscript(_ sessionID: SessionID) {
func event(_ seq: UInt64, _ kind: AgentEvent.Kind) -> AgentEvent {
AgentEvent(sessionID: sessionID, seq: seq, at: Date(), backend: .claudeCode,
nativeType: nil, kind: kind)
}
openEvents = [
event(1, .sessionStarted(SessionStarted(
backendSessionID: "demo", model: "claude-opus-4-8[1m]", cwd: "~/code/nucleic", toolNames: []))),
event(2, .userText(TextChunk(messageID: "u1", text: "Refactor the auth middleware and run the tests.", isPartial: false))),
event(3, .assistantText(TextChunk(messageID: "a1", text: "I'll update the auth middleware, then run the suite.\n\n**Plan:**\n- extract `requireSession`\n- add a `Bearer` check", isPartial: false))),
event(4, .toolCallStarted(ToolCall(toolCallID: "t1", name: "Edit", input: ["file_path": "auth/middleware.ts"]))),
event(5, .toolCallCompleted(ToolCall(toolCallID: "t1", name: "Edit", input: ["file_path": "auth/middleware.ts"]))),
event(6, .fileChange(FileChange(path: "auth/middleware.ts", kind: .update, toolCallID: "t1"))),
event(7, .toolResult(ToolResult(toolCallID: "t1", content: "Applied 2 edits to auth/middleware.ts", isError: false))),
event(8, .toolCallStarted(ToolCall(toolCallID: "t2", name: "Bash", input: ["command": "npm test"]))),
event(9, .toolCallCompleted(ToolCall(toolCallID: "t2", name: "Bash", input: ["command": "npm test"]))),
event(10, .toolResult(ToolResult(toolCallID: "t2", content: "42 passing\n0 failing", isError: false))),
event(11, .toolCallStarted(ToolCall(toolCallID: "t3", name: "Task", input: ["description": "Audit other call sites", "prompt": "Find every caller of the old auth API."]))),
event(12, .toolCallCompleted(ToolCall(toolCallID: "t3", name: "Task", input: ["description": "Audit other call sites"]))),
event(13, .toolResult(ToolResult(toolCallID: "t3", content: "Checked 7 files; 1 stale caller updated.", isError: false))),
event(14, .usage(Usage(inputTokens: 84_300, outputTokens: 2_140, costUSD: 0.0421, contextInputTokens: 84_300))),
event(15, .runFinished(RunFinished(outcome: .completed, finalText: "Done."))),
]
// If this session is blocked on a human, surface a real approval card so the
// Allow/Deny loop is exercisable in the demo (the seeded `a1` session).
if sessions.first(where: { $0.sessionID == sessionID })?.status == .awaitingApproval {
openApprovals = [ApprovalRequest(
id: Self.demoApprovalID(for: sessionID),
sessionID: sessionID, toolCallID: "t-appr", toolName: "Bash",
input: ["command": "npm run deploy"], title: "Run npm run deploy",
risk: .execute, createdAt: Date())]
}
}
/// Deterministic approval id for a demo session's seeded approval, so re-opening the same
/// session doesn't stack duplicate cards.
private static func demoApprovalID(for sessionID: SessionID) -> ApprovalID {
ApprovalID(rawValue: "demo-appr-\(sessionID.rawValue)")
}
func closeOpen() {
if let id = openSessionID {
send(.unsubscribe(id))
markOpened(id) // everything up to now has been seen
}
openSessionID = nil
openEvents = []
openApprovals = []
openDiff = nil
diffLoading = false
}
func respond(_ approval: ApprovalRequest, _ decision: Decision) {
send(.approvalRespond(approval.id, decision))
openApprovals.removeAll { $0.id == approval.id } // optimistic dismiss; host confirms
}
func sendInput(_ text: String, to sessionID: SessionID) {
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
send(.sendInput(sessionID, AgentInput(text: trimmed)))
}
func refreshSessions() { send(.listSessions); send(.listDashboard) }
// MARK: - Control intents (control scope; the same actions the Mac can take)
func startChat(in projectID: ProjectID, message: String, model: String? = nil,
effort: String? = nil, baseBranch: String? = nil,
useWorktree: Bool = true, auto: Bool? = nil) {
let text = message.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty else { return }
send(.startChat(StartChatRequest(
projectID: projectID, message: text, model: model, effort: effort,
baseBranch: baseBranch, useWorktree: useWorktree, auto: auto)))
}
func captureTodo(_ text: String, projectID: ProjectID?) {
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
send(.captureTodo(CaptureTodoRequest(text: trimmed, projectID: projectID)))
}
func dispatchTodo(_ id: TodoID, in projectID: ProjectID) { send(.dispatchTodo(id, projectID)) }
func completeTodo(_ id: TodoID) { send(.setTodoStatus(id, .done)) }
func deleteTodo(_ id: TodoID) { send(.deleteTodo(id)) }
func renameSession(_ id: SessionID, to title: String) {
let trimmed = title.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
send(.renameSession(id, trimmed))
}
func setFavorite(_ id: SessionID, _ favorite: Bool) { send(.setFavorite(id, favorite)) }
func setArchived(_ id: SessionID, _ archived: Bool) { send(.setArchived(id, archived)) }
func deleteSession(_ id: SessionID) {
send(.deleteSession(id))
if id == openSessionID { closeOpen() }
}
func integrate(_ id: SessionID, _ mode: IntegrationMode) { send(.integrate(id, mode)) }
/// Throw away the session's branch/worktree without landing it (the Mac's Discard…).
func discard(_ id: SessionID) { send(.discard(id)) }
func interrupt(_ id: SessionID) { send(.interrupt(id)) }
/// Cancel one queued (not-yet-sent) follow-up by id — the phone's per-message ✕.
func cancelQueuedMessage(_ id: SessionID, _ messageID: UUID) { send(.cancelQueuedMessage(id, messageID)) }
// Mid-session model / reasoning / automation — the same affordances the Mac header exposes.
// `nil` model/effort resets the session to the host/app default.
func setSessionModel(_ id: SessionID, _ model: String?) { send(.setSessionModel(id, model)) }
func setSessionEffort(_ id: SessionID, _ effort: String?) { send(.setSessionEffort(id, effort)) }
func setSessionAuto(_ id: SessionID, _ auto: Bool) { send(.setSessionAuto(id, auto)) }
func setSessionAutoShip(_ id: SessionID, _ autoShip: Bool) { send(.setSessionAutoShip(id, autoShip)) }
func setSessionShipBranch(_ id: SessionID, _ branch: String?) { send(.setSessionShipBranch(id, branch)) }
// MARK: - Plumbing
private func send(_ msg: ClientMsg) {
if demoMode { demoHandle(msg); return }
guard let client else { return }
Task { await client.send(msg) }
}
// MARK: - Demo simulator (offline, interactive)
//
// In demo mode there's no host, so writes can't go over the wire. Instead they mutate the
// already-`@Published` projection directly and (for the agent loop) stream a short canned run,
// so a reviewer can send messages, approve actions, start chats, and manage to-dos and see the
// UI respond — the read-only fixtures (`seedDemo`/`seedDemoTranscript`/`demoDiff`) already
// cover the passive surfaces.
private func demoHandle(_ msg: ClientMsg) {
switch msg {
case .sendInput(let id, let input):
demoRun(id, userText: input.plainText ?? "")
case .startChat(let req):
demoStartChat(req)
case .approvalRespond(let id, let decision):
demoResolveApproval(id, decision)
case .captureTodo(let req):
demoCaptureTodo(req)
case .setTodoStatus(let id, let status):
demoSetTodoStatus(id, status)
case .deleteTodo(let id):
demoMutateTodos { $0.filter { $0.id != id } }
case .dispatchTodo(let id, _):
demoSetTodoStatus(id, .dispatched)
case .renameSession(let id, let title):
demoUpdateSession(id) { $0.demoCopy(title: title) }
case .setFavorite(let id, let favorite):
demoUpdateSession(id) { $0.demoCopy(favorite: favorite) }
case .setArchived(let id, let archived):
demoUpdateSession(id) { $0.demoCopy(archived: archived) }
case .deleteSession(let id):
sessions.removeAll { $0.sessionID == id }
NotificationRouter.shared.updateBadge(needsYouCount)
LiveActivityManager.shared.sync(hostName: hostName, sessions: sessions)
case .discard(let id):
sessions.removeAll { $0.sessionID == id }
NotificationRouter.shared.updateBadge(needsYouCount)
case .interrupt(let id):
demoUpdateSession(id) { $0.demoCopy(status: .interrupted, disposition: .some(nil)) }
case .integrate(let id, _):
demoAppend(id, .note(NoteEvent(text: "nvrsion: Landed to trunk.", icon: "arrow.triangle.branch")))
demoUpdateSession(id) { $0.demoCopy(status: .finished, disposition: .some(.completed)) }
case .setSessionModel(let id, let model):
demoUpdateSession(id) { $0.demoCopy(model: .some(model)) }
case .setSessionEffort(let id, let effort):
demoUpdateSession(id) { $0.demoCopy(effort: .some(effort)) }
case .setSessionAuto(let id, let auto):
demoUpdateSession(id) { $0.demoCopy(auto: auto) }
case .setSessionAutoShip(let id, let autoShip):
demoUpdateSession(id) { $0.demoCopy(autoShip: autoShip) }
case .setSessionShipBranch(let id, let branch):
demoUpdateSession(id) { $0.demoCopy(shipBranch: .some(branch)) }
case .hello, .listSessions, .listDashboard, .subscribe, .unsubscribe,
.ping, .cancelQueuedMessage, .fetchDiff:
break // passive / already handled by the seeded fixtures
}
}
/// Append a transcript event to the open session (no-op if it isn't the one on screen).
private func demoAppend(_ sessionID: SessionID, _ kind: AgentEvent.Kind) {
guard sessionID == openSessionID else { return }
let seq = (openEvents.map(\.seq).max() ?? 0) + 1
let backend = sessions.first { $0.sessionID == sessionID }?.backend ?? .claudeCode
openEvents.append(AgentEvent(
sessionID: sessionID, seq: seq, at: Date(), backend: backend, nativeType: nil, kind: kind))
}
/// Replace a session summary in the list, keeping the badge / Live Activity in sync.
private func demoUpdateSession(_ id: SessionID, _ transform: (WireSessionSummary) -> WireSessionSummary) {
guard let i = sessions.firstIndex(where: { $0.sessionID == id }) else { return }
sessions[i] = transform(sessions[i])
NotificationRouter.shared.updateBadge(needsYouCount)
LiveActivityManager.shared.sync(hostName: hostName, sessions: sessions)
}
/// Stream a short, believable canned turn for a session: assistant prose, a tool call +
/// result, usage, and a finish — flipping the summary running → awaiting-input. Each step
/// re-checks demo mode and cancellation so `exitDemo()` stops it cleanly. Runs entirely on the
/// main actor (the store is `@MainActor`, and a `Task` in an isolated method inherits it).
private func demoRun(_ sessionID: SessionID, userText: String?) {
if let userText, !userText.isEmpty {
demoAppend(sessionID, .userText(TextChunk(messageID: UUID().uuidString, text: userText, isPartial: false)))
}
demoUpdateSession(sessionID) { $0.demoCopy(status: .running, disposition: .some(nil)) }
let toolID = UUID().uuidString
let task = Task { [weak self] in
guard let self else { return }
@MainActor func pause(_ ms: Int) async -> Bool {
try? await Task.sleep(for: .milliseconds(ms))
if Task.isCancelled { return false }
return self.demoMode
}
guard await pause(600) else { return }
self.demoAppend(sessionID, .assistantText(TextChunk(
messageID: UUID().uuidString, text: "On it — let me take a look.", isPartial: false)))
guard await pause(700) else { return }
self.demoAppend(sessionID, .toolCallStarted(ToolCall(
toolCallID: toolID, name: "Bash", input: ["command": "npm test"])))
guard await pause(900) else { return }
self.demoAppend(sessionID, .toolResult(ToolResult(
toolCallID: toolID, content: "42 passing\n0 failing", isError: false)))
guard await pause(700) else { return }
self.demoAppend(sessionID, .assistantText(TextChunk(
messageID: UUID().uuidString,
text: "All green — tests pass and the change is in place. Anything else?",
isPartial: false)))
self.demoAppend(sessionID, .usage(Usage(
inputTokens: 12_400, outputTokens: 640, costUSD: 0.0088, contextInputTokens: 12_400)))
self.demoAppend(sessionID, .runFinished(RunFinished(outcome: .completed, finalText: "Done.")))
self.demoUpdateSession(sessionID) {
$0.demoCopy(status: .awaitingInput, disposition: .some(.completed))
}
}
demoTasks.append(task)
}
private func demoStartChat(_ req: StartChatRequest) {
let project = dashboard.projects.first { $0.id == req.projectID }
let id = SessionID(rawValue: "demo-\(UUID().uuidString.prefix(8))")
let title = String(req.message.prefix(48))
let summary = WireSessionSummary(
sessionID: id, projectID: req.projectID.rawValue,
projectName: project?.name ?? "project", backend: BackendID.forModel(req.model) ?? .claudeCode,
status: .running, disposition: nil, title: title.isEmpty ? "New chat" : title,
branch: "nucleic/\(id.rawValue)", lastSeq: 0, diffStat: nil,
pendingApprovalCount: 0, favorite: false, archived: false,
model: req.model, effort: req.effort, auto: req.auto ?? false,
updatedAt: Date())
sessions.insert(summary, at: 0)
LiveActivityManager.shared.sync(hostName: hostName, sessions: sessions)
demoRun(id, userText: req.message)
}
private func demoResolveApproval(_ id: ApprovalID, _ decision: Decision) {
openApprovals.removeAll { $0.id == id }
NotificationRouter.shared.withdrawApproval(id)
guard let sessionID = openSessionID else { return }
switch decision {
case .deny, .cancelRun:
demoAppend(sessionID, .note(NoteEvent(text: "You denied the command.", icon: "hand.raised")))
demoUpdateSession(sessionID) {
$0.demoCopy(status: .awaitingInput, disposition: .some(.awaitingInput), pendingApprovalCount: 0)
}
case .allow, .allowAlways:
demoAppend(sessionID, .toolResult(ToolResult(
toolCallID: "t-appr", content: "Deployed successfully.", isError: false)))
demoUpdateSession(sessionID) { $0.demoCopy(pendingApprovalCount: 0) }
demoRun(sessionID, userText: nil) // wrap up the turn after the approved tool runs
}
}
private func demoCaptureTodo(_ req: CaptureTodoRequest) {
let project = req.projectID.flatMap { pid in dashboard.projects.first { $0.id == pid } }
let todo = WireTodo(
id: .generate(), text: req.text, summary: nil, projectID: req.projectID,
projectName: project?.name, status: .open, dispatchedSessionID: nil,
triage: nil, updatedAt: Date())
demoMutateTodos { [todo] + $0 }
}
private func demoSetTodoStatus(_ id: TodoID, _ status: TodoStatus) {
demoMutateTodos { todos in
todos.map { todo in
todo.id == id
? WireTodo(id: todo.id, text: todo.text, summary: todo.summary,
projectID: todo.projectID, projectName: todo.projectName,
status: status, dispatchedSessionID: todo.dispatchedSessionID,
triage: todo.triage, updatedAt: Date())
: todo
}
}
}
/// Rebuild the dashboard with a transformed to-do list (its fields are `let`).
private func demoMutateTodos(_ transform: ([WireTodo]) -> [WireTodo]) {
dashboard = DashboardSnapshot(
counts: dashboard.counts, activity: dashboard.activity, projects: dashboard.projects,
todos: transform(dashboard.todos), usage: dashboard.usage, statusFeeds: dashboard.statusFeeds)
}
private func makeChannel(_ endpoint: NWEndpoint) -> NWFrameChannel {
NWFrameChannel(endpoint: endpoint)
}
private func resolveEndpoint(fingerprint: String?, lanHost: String?, lanPort: UInt16?) -> NWEndpoint? {
discovery.endpoint(forFingerprint: fingerprint, lanHost: lanHost, lanPort: lanPort)
}
private func consume(_ client: SyncClient, pairingPayload: PairingPayload?) {
eventTask = Task { [weak self] in
let stream = await client.start()
for await event in stream {
// A replaced client's tail events (`.failed` is always chased by `.closed`)
// must not leak into the new attempt — they'd advance the candidate chain
// or schedule retries against a connection that no longer exists.
guard let self, self.client === client else { break }
await self.handle(event, pairingPayload: pairingPayload)
}
}
}
private func handle(_ event: SyncClient.Event, pairingPayload: PairingPayload?) async {
switch event {
case .connecting:
break
case .ready(let welcome):
reconnectAttempts = 0
connectPlan = nil // the chain found its transport
connectivity = .connected(activeTransport)
hostName = welcome.host.hostName
capabilities = welcome.capabilities
grantedScope = welcome.grantedScope
modelCatalog = welcome.modelCatalog
if let payload = pairingPayload, let hostKey = await client?.hostKey() {
IdentityStore.savePairedHost(PairedHost(
deviceID: IdentityStore.deviceID(), hostName: welcome.host.hostName,
hostStaticKey: hostKey, fingerprint: hostKey.fingerprintHex,
lanHost: payload.lanHost, lanPort: payload.lanPort,
transport: payload.transport, tailnetHost: payload.tailnetHost,
tailnetPort: payload.tailnetPort))
}
send(.listSessions)
send(.listDashboard)
if let id = openSessionID { send(.subscribe(Subscribe(sessionID: id, sinceSeq: nil, verbosity: .full))) }
flushPendingNotificationDecision()
case .sessionList(let list):
sessions = list
NotificationRouter.shared.updateBadge(needsYouCount)
LiveActivityManager.shared.sync(hostName: hostName, sessions: sessions)
case .sessionUpdated(let summary):
let previous: WireSessionSummary?
if let i = sessions.firstIndex(where: { $0.sessionID == summary.sessionID }) {
previous = sessions[i]
sessions[i] = summary
} else {
previous = nil
sessions.append(summary)
}
// Notify on the transition into "waiting on you" / "finished" — only when the
// app isn't foreground-active (in-app, the list's washes and badges carry it).
let becameWaiting = summary.status == .awaitingInput
&& previous?.status != .awaitingInput
if becameWaiting, !isActive, !summary.archived {
NotificationRouter.shared.postSessionUpdate(summary)
}
NotificationRouter.shared.updateBadge(needsYouCount)
LiveActivityManager.shared.sync(hostName: hostName, sessions: sessions)
case .dashboard(let snapshot):
dashboard = snapshot
case .snapshot(let snapshot):
guard snapshot.summary.sessionID == openSessionID else { break }
seenSeq = Set(snapshot.recentEvents.map(\.seq))
openEvents = snapshot.recentEvents
openApprovals = snapshot.pendingApprovals
case .events(let batch):
guard batch.sessionID == openSessionID else { break }
for e in batch.events where !seenSeq.contains(e.seq) {
seenSeq.insert(e.seq)
openEvents.append(e)
}
case .approvalRequested(let req):
if req.sessionID == openSessionID, !openApprovals.contains(where: { $0.id == req.id }) {
openApprovals.append(req)
}
// Always post; the router suppresses the banner when the user is already
// looking at this session, and resolution (any device) withdraws it.
let title = sessions.first { $0.sessionID == req.sessionID }?.title ?? "Approval"
NotificationRouter.shared.postApproval(req, sessionTitle: title)
case .approvalResolved(let resolved):
openApprovals.removeAll { $0.id == resolved.id }
NotificationRouter.shared.withdrawApproval(resolved.id)
case .sessionDiff(let diff):
guard diff.sessionID == openSessionID else { break }
openDiff = diff
diffLoading = false
case .wireError(let error):
// A channel mismatch means the host and this remote were built from incompatible
// release channels. Surface it as a persistent failure with a clear message rather
// than a transient bubble; the follow-on `.closed` still schedules a backoff retry,
// so the connection recovers on its own once either side is updated to a matching
// channel.
if error.code == .channelMismatch {
connectivity = .failed(error.message)
break
}
// Not fatal — surface as a transient bubble (the Mac's last-error overlay).
// Losing an approval race isn't an error worth interrupting for; the card
// collapses on the matching `approvalResolved`.
guard error.code != .alreadyResolved else { break }
// While the connect chain is still resolving a transport, a pre-ready error
// (e.g. "handshake closed" from a LAN probe about to fall back to tailnet) is
// routine, not news — the follow-on `.closed` advances the chain silently.
guard connectPlan == nil else { break }
showError(error.message, sessionID: error.sessionID)
case .failed(let message):
// Another candidate may still carry the session (e.g. LAN died → tailnet).
if tryNextCandidate() { break }
connectPlan = nil
connectivity = .failed(message)
scheduleRetry()
case .closed:
if !connectivity.isLive, tryNextCandidate() { break }
if connectivity.isLive {
connectivity = .reconnecting
} else if connectPlan?.pairingPayload != nil {
// A pairing chain died silently (every candidate closed pre-welcome).
// There's no retry loop before a pairing succeeds, so without a terminal
// state this would sit on "Connecting…" forever.
connectivity = .failed("Couldn't connect to your Mac — check that it's reachable, then scan again.")
}
connectPlan = nil
scheduleRetry()
}
}
/// Show a transient error bubble, replacing any current one; auto-dismisses after 6s
/// (matching the Mac's last-error overlay cadence).
private func showError(_ message: String, sessionID: SessionID?) {
let error = LastError(message: message, sessionID: sessionID)
lastError = error
errorDismissTask?.cancel()
errorDismissTask = Task { [weak self] in
try? await Task.sleep(for: .seconds(6))
guard let self, self.lastError == error else { return }
self.lastError = nil
}
}
func dismissError() {
errorDismissTask?.cancel()
lastError = nil
}
private func scheduleRetry() {
guard isPaired else { return }
reconnectAttempts += 1
let delay = min(Double(reconnectAttempts) * 1.5, 10)
retryTask?.cancel()
retryTask = Task { [weak self] in
// `try?` swallows the sleep's CancellationError, so check explicitly.
try? await Task.sleep(for: .seconds(delay))
guard !Task.isCancelled, let self, !self.connectivity.isLive else { return }
self.reconnect()
}
}
private func teardown() {
retryTask?.cancel()
retryTask = nil
connectTask?.cancel()
connectTask = nil
connectPlan = nil
teardownClient()
}
/// Drop just the current client/channel — what moving to the next transport candidate
/// needs, without discarding the rest of the plan.
private func teardownClient() {
lanConnectTimeout?.cancel()
lanConnectTimeout = nil
eventTask?.cancel()
eventTask = nil
if let client { Task { await client.disconnect() } }
client = nil
}
}
extension Data {
/// Same fingerprint scheme as `DeviceIdentity.fingerprint` (first 8 bytes of SHA-256).
var fingerprintHex: String {
DeviceIdentity.fingerprint(ofStaticKey: self)
}
}
extension WireSessionSummary {
/// A copy with selected fields overridden — the demo simulator's only way to "mutate" a
/// summary, whose stored properties are all `let`. Nullable fields use a double optional so a
/// caller can distinguish "keep" (omit) from "set to nil" (`.some(nil)`). `updatedAt` bumps
/// to now unless given. Only the fields the simulator touches are exposed.
func demoCopy(
status: SessionStatus? = nil,
disposition: TurnDisposition?? = nil,
title: String? = nil,
favorite: Bool? = nil,
archived: Bool? = nil,
pendingApprovalCount: Int? = nil,
diffStat: DiffStat?? = nil,
model: String?? = nil,
effort: String?? = nil,
auto: Bool? = nil,
autoShip: Bool? = nil,
shipBranch: String?? = nil,
updatedAt: Date? = nil
) -> WireSessionSummary {
WireSessionSummary(
sessionID: sessionID, projectID: projectID, projectName: projectName, backend: backend,
status: status ?? self.status,
disposition: disposition ?? self.disposition,
title: title ?? self.title, branch: branch, lastSeq: lastSeq,
diffStat: diffStat ?? self.diffStat,
pendingApprovalCount: pendingApprovalCount ?? self.pendingApprovalCount,
favorite: favorite ?? self.favorite,
archived: archived ?? self.archived,
queuedMessage: queuedMessage, queuedMessages: queuedMessages,
model: model ?? self.model, effort: effort ?? self.effort,
auto: auto ?? self.auto, autoShip: autoShip ?? self.autoShip,
shipBranch: shipBranch ?? self.shipBranch,
contextInputTokens: contextInputTokens,
updatedAt: updatedAt ?? Date())
}
}