Files
nucleic-remote-ios/NucleicRemote/NucleicRemote/Models/RemoteStore.swift
T
abkslmandClaude Opus 4.8 15ea0f1dd2 iPad: render multi-step / destructive shell pipelines as a step list
The desktop CommandStepsCard breaks a shell pipeline (a rm/git chain) into a
step list with the destructive delete flagged, instead of a raw blob. Rather
than duplicate a parser, deliver that value by reusing the existing, tested
HostCommandSummary + HostCommandBreakdown (a general command parser whose
Invocation already carries a `destructive` flag for rm/rmdir) on the two paths
that still showed raw text:

- ToolCallCard.details: a shell tool whose command has >1 invocation or is
  destructive now renders the compact step breakdown (deletes glyphed/tinted in
  red) with the literal command under "Show command"; simple one-liners keep the
  plain input block.
- ApprovalCardView: the parsed breakdown (with its sudo/deletes risk banner) now
  covers any Bash pipeline / destructive approval, not just host_exec.

Demo transcript gains a destructive cleanup pipeline (rm -rf && git worktree
prune && git branch -D) so the step list is exercisable offline.

Verified in the iPad simulator: the pipeline expands to a step list with
`rm -rf` flagged in red.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-04 00:38:59 -07:00

1207 lines
62 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)
}
diff --git a/auth/session.ts b/auth/session.ts
new file mode 100644
--- /dev/null
+++ b/auth/session.ts
@@ -0,0 +1,6 @@
+export interface Session {
+ userId: string
+ issuedAt: number
+}
+
+export const SESSION_TTL = 3600
""")
}
/// 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)
}
// A realistic `git commit` (heredoc message) so the transcript's structured commit card
// is exercisable offline: expand the Bash call to see the subject + Markdown body.
let demoCommitCommand = "git commit -F - <<'EOF'\nfix: harden auth middleware\n\nRequire a Bearer token and reject a missing or blank one.\n\n- extract `requireSession`\n- add a `Bearer` prefix check\nEOF"
// A multi-step, destructive shell pipeline so the transcript's step list (with the delete
// flagged in red) is exercisable offline: expand the Bash call to see the breakdown.
let demoCleanupCommand = "cd ~/code/nucleic && rm -rf .worktrees/auth-old && git worktree prune && git branch -D nucleic/auth-old"
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, .toolCallStarted(ToolCall(toolCallID: "t4", name: "Bash", input: ["command": .string(demoCommitCommand)]))),
event(15, .toolCallCompleted(ToolCall(toolCallID: "t4", name: "Bash", input: ["command": .string(demoCommitCommand)]))),
event(16, .toolResult(ToolResult(toolCallID: "t4", content: "[nucleic/auth-refactor 1a2b3c4] fix: harden auth middleware\n 2 files changed, 312 insertions(+), 40 deletions(-)", isError: false))),
event(17, .toolCallStarted(ToolCall(toolCallID: "t5", name: "Bash", input: ["command": .string(demoCleanupCommand)]))),
event(18, .toolCallCompleted(ToolCall(toolCallID: "t5", name: "Bash", input: ["command": .string(demoCleanupCommand)]))),
event(19, .toolResult(ToolResult(toolCallID: "t5", content: "Removed 1 worktree; deleted branch nucleic/auth-old.", isError: false))),
event(20, .usage(Usage(inputTokens: 84_300, outputTokens: 2_140, costUSD: 0.0421, contextInputTokens: 84_300))),
event(21, .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)")
}
/// Close a session's live subscription. `id` names *which* session is closing — the detail
/// view passes its own. On iPad's split view, switching session A→B can mount B (which calls
/// `open(B)`, setting `openSessionID = B`) *before* A's detail disappears; so we always
/// unsubscribe the named session but only tear down the shared open-state when it still
/// belongs to that session — otherwise we'd wipe B's freshly-loaded transcript. Called with
/// no argument it closes whatever is currently open (the iPhone push/pop path, unchanged).
func closeOpen(_ id: SessionID? = nil) {
guard let target = id ?? openSessionID else { return }
send(.unsubscribe(target))
markOpened(target) // everything up to now has been seen
guard openSessionID == target else { return }
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 {
let channel = NWFrameChannel(endpoint: endpoint)
// Surface a transport-level failure with an actionable message. Without this, a refused
// connection or a denied local-network permission finished the frame stream and only
// showed up as a generic "handshake closed" — hiding the real (usually fixable) cause.
channel.onFailed = { [weak self] error in
Task { @MainActor in self?.handleTransportFailure(error) }
}
return channel
}
/// Map an `NWConnection` failure to a connectivity state the pairing/onboarding UI can act on.
/// Only meaningful while we're still establishing the link; once connected, a drop is the
/// normal `.closed` → reconnect path's job. And only when the connect chain has nothing
/// left to try — a failed LAN probe about to fall back to the tailnet is routine, not news.
private func handleTransportFailure(_ error: String) {
guard !connectivity.isLive else { return }
guard connectPlan?.remaining.isEmpty != false else { return }
connectivity = .failed(Self.friendlyTransportError(error))
}
/// Turn a raw `NWError` string into a short, fixable hint. The common onboarding failures are
/// a denied Local Network permission (EPERM / -65555) and the Mac not listening (refused).
private static func friendlyTransportError(_ error: String) -> String {
let lower = error.lowercased()
if lower.contains("denied") || lower.contains("not permitted") || lower.contains("65555") {
return "Can't reach the local network. In Settings ▸ Nucleic, allow Local Network access, then try again."
}
if lower.contains("refused") {
return "Your Mac refused the connection. Check that remote access is still on in Nucleic ▸ Settings."
}
return "Couldn't connect to your Mac — make sure it's on the same Wi‑Fi and remote access is on."
}
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
// The channel's onFailed may have just surfaced a friendlier transport-level
// cause (denied Local Network, connection refused) — don't clobber it.
if case .failed = connectivity {} else { 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. Keep a friendlier
// transport-level failure if one was already surfaced.
if case .failed = connectivity {} else {
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())
}
}