Multi-device mesh + session-transfer program (docs/MESH_TRANSFER.md). P1 (multi-select connection methods, Mac): SyncTransportSet with legacy migration; CompositeSyncListener partial-failure tolerant + per-method health; AppStore listens on all enabled methods; HostInfo.hostID = DeviceIdentity.hostID (key hash, not display name); Settings 3 method toggles + LAN-only recommendation banner. P2 core (relay hardening, security-critical, tested): nucleic-edge relayEnroll.ts X25519 proof-of-possession enroll + server-side roomId derivation; room.ts role-routed per-peer forwarding with deviceId-tag envelope + frame cap + one-host-per-room eviction; host-bearer minting bound to the PoP room. Swift cross-stack contracts pinned to test vectors: RelayEnrollment (PoP proof), RelayEnvelope (routing tag), PairingPayload relay fields. Socket transport + iOS un-gating remain deploy-gated. P4 foundation (peer model + listPeers, tested): PeerTypes (PeerKind/ PeerCapabilities/PeerSummary); Hello.deviceKind+clientCaps; WireCapabilities.canListPeers; ClientMsg.listPeers -> HostMsg.peerList; PairedDevice.kind+capabilities (decode-defaulted); ConnectionHandler records kind at pairing; SyncHost.connectedDeviceIDs(); AppStore.peerSummaries(); SyncClient .peerList event; iOS RemoteStore.meshPeers. PeerClient + Mac<->Mac pairing UI remain. All additive + capability-gated; SyncProtocol.version stays 1; pre-mesh stores and clients unaffected. Swift 724 core + 91 protocol tests green; edge 50 green. Co-Authored-By: Claude Fable 5 <[email protected]>
1187 lines
60 KiB
Swift
1187 lines
60 KiB
Swift
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
|
||
|
||
/// The host's mesh peers (mesh P4), from `HostMsg.peerList`. Populated only when the host
|
||
/// advertises `capabilities.canListPeers` and the client asks; drives the future mesh device
|
||
/// list and session-transfer destination picker.
|
||
@Published private(set) var meshPeers: [PeerSummary] = []
|
||
|
||
// 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 {
|
||
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 .peerList(let peers):
|
||
// Mesh P4: the host's known peers, for the (future) mesh device list and transfer
|
||
// picker. Recorded now so multi-host UI can render it; single-host builds ignore it.
|
||
meshPeers = peers
|
||
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())
|
||
}
|
||
}
|