Merge branch 'dev' into canary

This commit is contained in:
2026-07-04 19:14:47 -07:00
16 changed files with 1798 additions and 554 deletions
@@ -0,0 +1,459 @@
import Foundation
import Network
import NucleicProtocol
import NucleicTailnet
#if canImport(UIKit)
import UIKit
#endif
/// One phone→Mac connection and its projected state (mesh P3 multiplexer foundation).
///
/// This is the per-host connection engine extracted from `RemoteStore`: it owns the `SyncClient`,
/// the LAN→tailnet candidate chain, reconnect backoff, and the event stream for a single paired
/// Mac, and it keeps that Mac's projection (connectivity, sessions, dashboard, capabilities, …).
/// `RemoteStore` will own one of these per paired host (`[HostID: HostConnection]`), aggregating
/// their state and routing intents to the right one; aggregate concerns (badge, Live Activity,
/// notifications, the single open transcript) are surfaced through `Callbacks` so `RemoteStore`
/// can merge across hosts. A phone in demo mode has no `HostConnection` — demo seeds state directly.
@MainActor
final class HostConnection {
/// The Mac this connection targets, by `HostID` (its static-key fingerprint).
let hostID: String
// MARK: Projected state (this host's slice of what the phone shows)
private(set) var hostName: String
private(set) var connectivity: RemoteStore.Connectivity = .connecting
private(set) var sessions: [WireSessionSummary] = []
private(set) var capabilities = WireCapabilities(canModifyToolInput: false, allowAlwaysScopes: [])
private(set) var grantedScope: DeviceScope = .approve
private(set) var modelCatalog: WireModelCatalog = .empty
private(set) var dashboard = DashboardSnapshot.empty
private(set) var meshPeers: [PeerSummary] = []
/// The live transport, for the "Connected · …" chip.
private(set) var activeTransport: SyncTransportHint = .lan
/// The session RemoteStore currently has open on this host, if any — so snapshot/events are only
/// forwarded (and deduped) for the transcript on screen. Set by RemoteStore on open/close.
var openSessionID: SessionID?
// MARK: Callbacks up to RemoteStore (aggregate concerns)
/// How a `HostConnection` talks back to `RemoteStore`. All fire on the main actor.
struct Callbacks {
/// This host's projected state changed — refresh the aggregate list/dashboard/chip.
var didUpdate: () -> Void = {}
/// The open session's transcript snapshot arrived (only when `openSessionID` matches).
var openSnapshot: (SessionSnapshot) -> Void = { _ in }
/// New transcript events for the open session.
var openEvents: (EventBatch) -> Void = { _ in }
/// The open session's full diff arrived.
var openDiff: (WireSessionDiff) -> Void = { _ in }
/// An approval was requested (with the resolved session title) — post the notification and,
/// if it's the open session, add it to the approval strip.
var approvalRequested: (ApprovalRequest, String) -> Void = { _, _ in }
/// An approval resolved anywhere — withdraw the notification and clear the strip.
var approvalResolved: (ApprovalResolved) -> Void = { _ in }
/// A session became "waiting on you" (for a background notification).
var sessionBecameWaiting: (WireSessionSummary) -> Void = { _ in }
/// A non-fatal host error to surface as a transient bubble.
var wireError: (WireError) -> Void = { _ in }
/// A pairing handshake succeeded — persist the pinned host record.
var didPair: (PairedHost) -> Void = { _ in }
/// The embedded Tailscale node's status changed (for Settings).
var tailnetStatus: (String?, URL?) -> Void = { _, _ in }
}
private let callbacks: Callbacks
// MARK: Shared dependencies
private let identity: DeviceIdentity
private let discovery: LANDiscovery
// MARK: Connection machine (moved from RemoteStore, one per host)
private var client: SyncClient?
private var eventTask: Task<Void, Never>?
private var connectTask: Task<Void, Never>?
private var retryTask: Task<Void, Never>?
private var lanConnectTimeout: Task<Void, Never>?
private var reconnectAttempts = 0
private var seenSeq: Set<UInt64> = []
private enum TransportAttempt {
case lan(NWEndpoint)
case tailnet(host: String, port: UInt16)
}
private struct ConnectPlan {
var remaining: [TransportAttempt]
let hostStaticKey: Data
let mode: SyncClient.Mode
let deviceID: String
let pairingPayload: PairingPayload?
}
private var connectPlan: ConnectPlan?
init(
hostID: String, hostName: String,
identity: DeviceIdentity, discovery: LANDiscovery, callbacks: Callbacks
) {
self.hostID = hostID
self.hostName = hostName
self.identity = identity
self.discovery = discovery
self.callbacks = callbacks
}
// MARK: - Connect (pair / reconnect)
/// Pair from a scanned QR (SYNC §4.2): try the QR's transports in order, run XXpsk0, and on
/// success pin the host key for future IK reconnects.
func pair(with payload: PairingPayload) {
teardown()
connectivity = .connecting
hostName = payload.hostName
callbacks.didUpdate()
guard let hint = payload.transportHint else {
fail("This pairing code needs a newer version of Nucleic Remote.")
return
}
guard hint != .relay else {
fail("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 {
fail(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 pinned host using IK: LAN when reachable, else the pairing's tailnet hint.
func reconnect(to host: PairedHost) {
teardown()
pinnedHost = host
connectivity = reconnectAttempts == 0 ? .connecting : .reconnecting
hostName = host.hostName
callbacks.didUpdate()
guard host.transportHint != .relay else {
fail("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
callbacks.didUpdate()
scheduleRetry(host: host)
return
}
connectPlan = ConnectPlan(
remaining: candidates, hostStaticKey: host.hostStaticKey,
mode: .reconnect, deviceID: host.deviceID, pairingPayload: nil)
_ = tryNextCandidate()
}
/// The host this connection reconnects to (for its retry loop). Set on `reconnect(to:)`.
private var pinnedHost: PairedHost?
private func fail(_ message: String) {
connectivity = .failed(message)
callbacks.didUpdate()
}
private func buildCandidates(
fingerprint: String?, lanHost: String?, lanPort: UInt16?,
tailnet: (host: String?, port: UInt16?)?
) -> [TransportAttempt] {
var candidates: [TransportAttempt] = []
if let endpoint = discovery.endpoint(forFingerprint: 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
}
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)
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)
}
}
}
}
private func tailnetAttemptFailed(_ error: Error, isPairing: Bool) {
if tryNextCandidate() { return }
if isPairing {
fail(error.localizedDescription)
return
}
switch error {
case TailnetError.notBuiltIn, TailnetError.notConfigured:
fail(error.localizedDescription)
default:
connectivity = .hostOffline
callbacks.didUpdate()
scheduleRetry(host: pinnedHost)
}
}
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)
}
private func tailnetChannel(host: String, port: UInt16) async throws -> FDFrameChannel {
guard TailnetSupport.isBuiltIn else { throw TailnetError.notBuiltIn }
let config = Self.phoneTailnetConfig()
callbacks.tailnetStatus("Starting…", nil)
let watcher = Task { [weak self] in
for await status in await TailnetNode.shared.statusStream() {
guard let self, !Task.isCancelled else { break }
var loginURL: URL?
if case .needsLogin(let url) = status, let u = URL(string: url) {
loginURL = u
// Eject to Safari only for user-initiated pairing (the user is watching).
if self.connectPlan?.pairingPayload != nil {
UIApplication.shared.open(u, options: [:], completionHandler: nil)
}
}
self.callbacks.tailnetStatus(status.label, loginURL)
}
}
defer {
watcher.cancel()
callbacks.tailnetStatus(nil, nil)
}
do {
try await TailnetNode.shared.ensureRunning(config: config)
} catch TailnetError.timedOut(let message) {
callbacks.tailnetStatus(await TailnetNode.shared.status.label, nil)
throw TailnetError.notConfigured(message)
} catch {
callbacks.tailnetStatus(await TailnetNode.shared.status.label, nil)
throw error
}
callbacks.tailnetStatus(await TailnetNode.shared.status.label, nil)
return try await TailnetNode.shared.dial(host: host, port: port)
}
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)
}
private func makeChannel(_ endpoint: NWEndpoint) -> NWFrameChannel {
let channel = NWFrameChannel(endpoint: endpoint)
channel.onFailed = { [weak self] error in
Task { @MainActor in self?.handleTransportFailure(error) }
}
return channel
}
private func handleTransportFailure(_ error: String) {
guard !connectivity.isLive else { return }
guard connectPlan?.remaining.isEmpty != false else { return }
connectivity = .failed(RemoteStore.friendlyTransportError(error))
callbacks.didUpdate()
}
// MARK: - Event stream
private func consume(_ client: SyncClient, pairingPayload: PairingPayload?) {
eventTask = Task { [weak self] in
let stream = await client.start()
for await event in stream {
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
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() {
callbacks.didPair(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))
}
callbacks.didUpdate()
send(.listSessions)
send(.listDashboard)
if let id = openSessionID { send(.subscribe(Subscribe(sessionID: id, sinceSeq: nil, verbosity: .full))) }
case .sessionList(let list):
sessions = list
callbacks.didUpdate()
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)
}
let becameWaiting = summary.status == .awaitingInput && previous?.status != .awaitingInput
if becameWaiting, !summary.archived { callbacks.sessionBecameWaiting(summary) }
callbacks.didUpdate()
case .dashboard(let snapshot):
dashboard = snapshot
callbacks.didUpdate()
case .snapshot(let snapshot):
guard snapshot.summary.sessionID == openSessionID else { break }
seenSeq = Set(snapshot.recentEvents.map(\.seq))
callbacks.openSnapshot(snapshot)
case .events(let batch):
guard batch.sessionID == openSessionID else { break }
let fresh = batch.events.filter { !seenSeq.contains($0.seq) }
for e in fresh { seenSeq.insert(e.seq) }
if !fresh.isEmpty { callbacks.openEvents(EventBatch(sessionID: batch.sessionID, events: fresh)) }
case .approvalRequested(let req):
let title = sessions.first { $0.sessionID == req.sessionID }?.title ?? "Approval"
callbacks.approvalRequested(req, title)
case .approvalResolved(let resolved):
callbacks.approvalResolved(resolved)
case .sessionDiff(let diff):
guard diff.sessionID == openSessionID else { break }
callbacks.openDiff(diff)
case .peerList(let peers):
meshPeers = peers
callbacks.didUpdate()
case .transferAccept, .transferReject, .transferReady, .transferCommitted, .transferChunkAck:
// Session-transfer replies (mesh P5) only reach a *source* Mac; a phone is never one.
break
case .wireError(let error):
if error.code == .channelMismatch {
connectivity = .failed(error.message)
callbacks.didUpdate()
break
}
guard error.code != .alreadyResolved else { break }
guard connectPlan == nil else { break }
callbacks.wireError(error)
case .failed(let message):
if tryNextCandidate() { break }
connectPlan = nil
if case .failed = connectivity {} else { connectivity = .failed(message) }
callbacks.didUpdate()
scheduleRetry(host: pinnedHost)
case .closed:
if !connectivity.isLive, tryNextCandidate() { break }
if connectivity.isLive {
connectivity = .reconnecting
} else if connectPlan?.pairingPayload != nil {
if case .failed = connectivity {} else {
connectivity = .failed("Couldn't connect to your Mac — check that it's reachable, then scan again.")
}
}
connectPlan = nil
callbacks.didUpdate()
scheduleRetry(host: pinnedHost)
}
}
// MARK: - Send / retry / teardown
func send(_ msg: ClientMsg) {
guard let client else { return }
Task { await client.send(msg) }
}
private func scheduleRetry(host: PairedHost?) {
guard let host else { return }
reconnectAttempts += 1
let delay = min(Double(reconnectAttempts) * 1.5, 10)
retryTask?.cancel()
retryTask = Task { [weak self] in
try? await Task.sleep(for: .seconds(delay))
guard !Task.isCancelled, let self, !self.connectivity.isLive else { return }
self.reconnect(to: host)
}
}
func teardown() {
retryTask?.cancel(); retryTask = nil
connectTask?.cancel(); connectTask = nil
connectPlan = nil
teardownClient()
}
private func teardownClient() {
lanConnectTimeout?.cancel(); lanConnectTimeout = nil
eventTask?.cancel(); eventTask = nil
if let client { Task { await client.disconnect() } }
client = nil
}
}
@@ -1,6 +1,9 @@
import Foundation
import Security
import NucleicProtocol
#if canImport(UIKit)
import UIKit
#endif
/// What the phone pins about its Mac at pairing (SYNC §4.2): the host's static key (for IK
/// reconnect), a display name, and the transport + connection hint from the QR — LAN
@@ -26,7 +29,12 @@ struct PairedHost: Codable, Equatable {
/// (UserDefaults). The identity is generated once on first launch and reused thereafter.
enum IdentityStore {
private static let keychainAccount = "xyz.blakeslee.nucleic.remote.identity"
/// Legacy single-host slot (pre-mesh P3). Migrated into `pairedHostsKey` on first registry read.
private static let pairedHostKey = "nucleic.pairedHost"
/// The multi-host registry (mesh P3): an ordered `[PairedHost]`, most-recently-paired last.
/// The last entry is the "active" host the single connection uses today; the multiplexer will
/// connect to all of them.
private static let pairedHostsKey = "nucleic.pairedHosts"
private static let deviceIDKey = "nucleic.deviceID"
static func loadOrCreateIdentity() -> DeviceIdentity {
@@ -42,24 +50,82 @@ enum IdentityStore {
static func deviceID() -> String {
let defaults = UserDefaults.standard
if let existing = defaults.string(forKey: deviceIDKey) { return existing }
let id = "iphone-" + UUID().uuidString.prefix(8).lowercased()
let id = deviceIDPrefix + UUID().uuidString.prefix(8).lowercased()
defaults.set(id, forKey: deviceIDKey)
return id
}
static func loadPairedHost() -> PairedHost? {
guard let data = UserDefaults.standard.data(forKey: pairedHostKey) else { return nil }
return try? JSONDecoder().decode(PairedHost.self, from: data)
/// Idiom-tagged prefix so the host lists a paired device with the right kind/icon
/// (`ipad-…` vs `iphone-…`). Only stamps *freshly generated* ids — an existing install
/// keeps whatever id it already persisted, so upgrading a phone never changes its identity.
private static var deviceIDPrefix: String {
#if canImport(UIKit)
return UIDevice.current.userInterfaceIdiom == .pad ? "ipad-" : "iphone-"
#else
return "iphone-"
#endif
}
static func savePairedHost(_ host: PairedHost) {
if let data = try? JSONEncoder().encode(host) {
UserDefaults.standard.set(data, forKey: pairedHostKey)
// MARK: - Paired-host registry (mesh P3)
/// Every Mac this phone is paired with, most-recently-paired last. Migrates the legacy
/// single-host slot into the registry on first read (then removes it), so an upgrade keeps its
/// Mac. Ordered so the last is the "active" host today; a future multiplexer connects to all.
static func pairedHosts() -> [PairedHost] {
let defaults = UserDefaults.standard
if let data = defaults.data(forKey: pairedHostsKey),
let hosts = try? JSONDecoder().decode([PairedHost].self, from: data) {
return hosts
}
// One-time migration from the pre-mesh single slot.
if let legacy = defaults.data(forKey: pairedHostKey),
let host = try? JSONDecoder().decode(PairedHost.self, from: legacy) {
savePairedHosts([host])
defaults.removeObject(forKey: pairedHostKey)
return [host]
}
return []
}
/// The one paired Mac with `hostID` (its `fingerprint`), if any.
static func pairedHost(id hostID: String) -> PairedHost? {
pairedHosts().first { $0.fingerprint == hostID }
}
/// Add or update a host by fingerprint, moving it to the end (making it the active host). Used
/// on a successful pairing handshake and on any address refresh.
static func upsertPairedHost(_ host: PairedHost) {
var hosts = pairedHosts().filter { $0.fingerprint != host.fingerprint }
hosts.append(host)
savePairedHosts(hosts)
}
/// Forget one paired Mac by fingerprint (per-host unpair).
static func removePairedHost(id hostID: String) {
savePairedHosts(pairedHosts().filter { $0.fingerprint != hostID })
}
private static func savePairedHosts(_ hosts: [PairedHost]) {
if let data = try? JSONEncoder().encode(hosts) {
UserDefaults.standard.set(data, forKey: pairedHostsKey)
}
}
// MARK: - Single-host bridge (until the RemoteStore multiplexer lands)
/// The active host the single connection uses — the most-recently-paired. `nil` if unpaired.
static func loadPairedHost() -> PairedHost? {
pairedHosts().last
}
/// Pair (or re-pair) a host and make it active.
static func savePairedHost(_ host: PairedHost) {
upsertPairedHost(host)
}
/// Unpair the active host (the one the single connection currently uses).
static func clearPairedHost() {
UserDefaults.standard.removeObject(forKey: pairedHostKey)
if let active = pairedHosts().last { removePairedHost(id: active.fingerprint) }
}
// MARK: - Keychain
@@ -39,6 +39,10 @@ final class RemoteStore: ObservableObject {
@Published private(set) var connectivity: Connectivity = .unpaired
@Published private(set) var hostName: String = ""
/// The paired Mac this connection currently reflects (mesh P3), by its `HostID` (fingerprint).
/// `hostName`/`sessions`/etc. above are that host's projection; switching hosts re-points them
/// (live: reconnect to a different paired Mac; demo: swap the mock host). `nil` until connected.
@Published private(set) var activeHostID: String?
@Published private(set) var sessions: [WireSessionSummary] = []
@Published private(set) var capabilities = WireCapabilities(canModifyToolInput: false, allowAlwaysScopes: [])
@Published private(set) var grantedScope: DeviceScope = .approve
@@ -49,6 +53,11 @@ final class RemoteStore: ObservableObject {
/// 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] = []
@@ -92,8 +101,12 @@ final class RemoteStore: ObservableObject {
/// 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))
// The approval may belong to any connected Mac (mesh P3) and the notification carries no
// hostID — broadcast to every live connection; the owning host resolves it, the rest see an
// unknown/`alreadyResolved` approval and no-op.
let live = connections.values.filter { $0.connectivity.isLive }
if !live.isEmpty {
for conn in live { conn.send(.approvalRespond(id, decision)) }
} else {
pendingNotificationDecision = (id, decision, Date())
reconnect()
@@ -105,9 +118,11 @@ final class RemoteStore: ObservableObject {
private func flushPendingNotificationDecision() {
guard let (id, decision, at) = pendingNotificationDecision else { return }
let live = connections.values.filter { $0.connectivity.isLive }
guard !live.isEmpty else { return } // wait until something's connected
pendingNotificationDecision = nil
guard Date().timeIntervalSince(at) < 30 else { return } // stale — require the app
send(.approvalRespond(id, decision))
for conn in live { conn.send(.approvalRespond(id, decision)) }
}
private static let lastOpenedKey = "nucleic.lastOpenedAt"
@@ -144,22 +159,19 @@ final class RemoteStore: ObservableObject {
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).
/// One live connection per paired Mac (mesh P3 multiplexer). All connected at once; the flat
/// `sessions`/`connectivity`/`capabilities`/… above mirror the *active* one (`activeHostID`), so
/// switching hosts is instant (no reconnect). Empty in demo mode (demo seeds state directly).
private var connections: [String: HostConnection] = [:]
/// The active connection — the Mac whose projection the flat state reflects + whose transcript
/// is open.
private var activeConnection: HostConnection? { activeHostID.flatMap { connections[$0] } }
/// The phone's embedded Tailscale node state, for Settings (nil = not running). Shared across all
/// connections (one embedded node, many dials).
@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 —
@@ -174,6 +186,75 @@ final class RemoteStore: ObservableObject {
/// In-flight simulated-run tasks (canned streaming), cancelled on `exitDemo()`.
private var demoTasks: [Task<Void, Never>] = []
// MARK: - Multi-host switching (mesh P3)
/// One selectable Mac for the host switcher — the paired Macs (live) or the mock hosts (demo).
struct HostChoice: Identifiable, Equatable {
let id: String // HostID (fingerprint), or a demo id
let name: String
let isActive: Bool
}
/// The Macs this phone can switch between. Live: the paired-host registry. Demo: the mock hosts.
/// One entry ⇒ the switcher hides itself and the app reads single-host. Today the phone holds one
/// *live* connection at a time (switching reconnects); simultaneous connections are the deferred
/// multiplexer step.
var hostChoices: [HostChoice] {
if demoMode {
return demoHosts.map { HostChoice(id: $0.id, name: $0.name, isActive: $0.id == activeHostID) }
}
return IdentityStore.pairedHosts().map {
HostChoice(id: $0.fingerprint, name: $0.hostName, isActive: $0.fingerprint == activeHostID)
}
}
/// Switch which paired Mac is active. Live: every Mac is already connected, so this is instant —
/// close the current transcript, re-point at the target connection, and mirror its state. Demo:
/// swap the mock host's projection, preserving in-demo edits.
func switchHost(to id: String) {
guard id != activeHostID else { return }
if demoMode {
if let i = demoHosts.firstIndex(where: { $0.id == activeHostID }) {
demoHosts[i].sessions = sessions // keep any in-demo mutations
demoHosts[i].dashboard = dashboard
}
guard let next = demoHosts.first(where: { $0.id == id }) else { return }
applyDemoHost(next)
} else {
// Close the transcript open on the old host.
activeConnection?.openSessionID = nil
openSessionID = nil; openEvents = []; openApprovals = []; openDiff = nil; diffLoading = false
activeHostID = id
// The target should already be connected; (re)dial only if it isn't.
if let host = IdentityStore.pairedHost(id: id) {
let conn = connection(for: host)
if !conn.connectivity.isLive { conn.reconnect(to: host) }
}
mirrorActive()
}
}
/// Mock hosts for the multi-host demo. Each carries its own name + sessions + dashboard;
/// switching swaps the flat active-host projection. Empty outside demo.
private struct DemoHost {
let id: String
let name: String
var sessions: [WireSessionSummary]
var dashboard: DashboardSnapshot
}
private var demoHosts: [DemoHost] = []
/// Project a mock host onto the flat active-host state (demo host switch).
private func applyDemoHost(_ host: DemoHost) {
activeHostID = host.id
hostName = host.name
sessions = host.sessions
dashboard = host.dashboard
openSessionID = nil; openEvents = []; openApprovals = []; openDiff = nil
LiveActivityManager.shared.sync(hostName: hostName, sessions: sessions)
NotificationRouter.shared.updateBadge(needsYouCount)
}
var isPaired: Bool { demoMode || IdentityStore.loadPairedHost() != nil }
var deviceFingerprint: String { identity.fingerprint }
@@ -221,13 +302,6 @@ final class RemoteStore: ObservableObject {
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
@@ -236,7 +310,15 @@ final class RemoteStore: ObservableObject {
return ActivityDay(day: cal.date(byAdding: .day, value: -i, to: today)!,
count: count, tokens: count * 8_500 + (i * 137) % 4_000)
}
dashboard = DashboardSnapshot(
// Host 1 — "Andrew's Mac".
let host1Sessions = [
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 host1Dashboard = DashboardSnapshot(
counts: DashboardCounts(
projects: 2, chats: 5, activeChats: 2, messages: 142, activeDays: 9, tokens: 1_284_000),
activity: activity,
@@ -267,7 +349,32 @@ final class RemoteStore: ObservableObject {
]),
WireStatusFeed(provider: "xai", providerName: "xAI", incidents: []),
])
LiveActivityManager.shared.sync(hostName: hostName, sessions: sessions)
// Host 2 — "Studio Mac" (mesh P3: a second paired Mac, for the host switcher).
let p3 = ProjectID(rawValue: "p3")
let host2Sessions = [
sum("b1", p3, "renderer", "shadow-mapping", .running, add: 140, rem: 22),
sum("b2", p3, "renderer", "gpu-profiling", .awaitingApproval, approvals: 1),
sum("b3", p1, "nucleic", "cloud-runtime", .awaitingInput, .completed, add: 60, rem: 8),
]
let host2Dashboard = DashboardSnapshot(
counts: DashboardCounts(
projects: 1, chats: 3, activeChats: 2, messages: 61, activeDays: 5, tokens: 540_000),
activity: activity,
projects: [WireProject(id: p3, name: "renderer", defaultBranch: "main", sessionCount: 2, activeCount: 2)],
todos: [
WireTodo(id: TodoID(rawValue: "t4"), text: "Bake the light probes overnight", summary: "Bake light probes",
projectID: p3, projectName: "renderer", status: .open, dispatchedSessionID: nil,
triage: "medium", updatedAt: Date()),
],
usage: WireSubscriptionUsage(
fiveHour: WireUsageWindow(utilization: 18, resetsAt: Date().addingTimeInterval(2 * 3600)),
sevenDay: WireUsageWindow(utilization: 40, resetsAt: nil)),
statusFeeds: [])
demoHosts = [
DemoHost(id: "demo-1", name: "Andrew's Mac", sessions: host1Sessions, dashboard: host1Dashboard),
DemoHost(id: "demo-2", name: "Studio Mac", sessions: host2Sessions, dashboard: host2Dashboard),
]
applyDemoHost(demoHosts[0])
}
/// Offline diff fixture (NUCLEIC_DEMO) so the full-patch Diff tab renders without a host.
@@ -290,250 +397,179 @@ final class RemoteStore: ObservableObject {
+ 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)
}
// MARK: - Connections (mesh P3 multiplexer)
/// 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.
/// Pair a newly-scanned Mac, make it the active host, and start its connection — while any
/// existing connections keep running.
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()
let id = payload.hostStaticKey.fingerprintHex
connections[id]?.teardown()
let conn = makeConnection(hostID: id, hostName: payload.hostName)
connections[id] = conn
activeHostID = id
mirrorActive()
conn.pair(with: payload)
}
/// 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.
/// Get or build the connection for a paired Mac.
private func connection(for host: PairedHost) -> HostConnection {
if let existing = connections[host.fingerprint] { return existing }
let conn = makeConnection(hostID: host.fingerprint, hostName: host.hostName)
connections[host.fingerprint] = conn
return conn
}
private func makeConnection(hostID: String, hostName: String) -> HostConnection {
HostConnection(
hostID: hostID, hostName: hostName, identity: identity, discovery: discovery,
callbacks: callbacks(for: hostID))
}
/// The callbacks one `HostConnection` uses to drive shared/aggregate state. `hostID` is captured
/// so a change updates the flat projection only for the active host, while badges, notifications,
/// and the single open transcript are handled globally across hosts.
private func callbacks(for hostID: String) -> HostConnection.Callbacks {
var cb = HostConnection.Callbacks()
cb.didUpdate = { [weak self] in
guard let self else { return }
if hostID == self.activeHostID { self.mirrorActive() }
self.refreshAggregate()
self.flushPendingNotificationDecision()
}
cb.openSnapshot = { [weak self] snap in
guard let self, hostID == self.activeHostID, snap.summary.sessionID == self.openSessionID else { return }
self.openEvents = snap.recentEvents
self.openApprovals = snap.pendingApprovals
}
cb.openEvents = { [weak self] batch in
guard let self, hostID == self.activeHostID, batch.sessionID == self.openSessionID else { return }
self.openEvents.append(contentsOf: batch.events)
}
cb.openDiff = { [weak self] diff in
guard let self, hostID == self.activeHostID, diff.sessionID == self.openSessionID else { return }
self.openDiff = diff
self.diffLoading = false
}
cb.approvalRequested = { [weak self] req, title in
guard let self else { return }
if hostID == self.activeHostID, req.sessionID == self.openSessionID,
!self.openApprovals.contains(where: { $0.id == req.id }) {
self.openApprovals.append(req)
}
// Post for any host; the router suppresses the banner if the user is on this session.
NotificationRouter.shared.postApproval(req, sessionTitle: title)
}
cb.approvalResolved = { [weak self] resolved in
guard let self else { return }
self.openApprovals.removeAll { $0.id == resolved.id }
NotificationRouter.shared.withdrawApproval(resolved.id)
}
cb.sessionBecameWaiting = { [weak self] summary in
guard let self, !self.isActive else { return }
NotificationRouter.shared.postSessionUpdate(summary)
}
cb.wireError = { [weak self] error in
self?.showError(error.message, sessionID: error.sessionID)
}
cb.didPair = { [weak self] host in
guard let self else { return }
IdentityStore.savePairedHost(host)
self.activeHostID = host.fingerprint
}
cb.tailnetStatus = { [weak self] status, loginURL in
self?.tailnetStatus = status
self?.tailnetLoginURL = loginURL
}
return cb
}
/// Mirror the active connection's projection onto the flat @Published state the UI binds to.
private func mirrorActive() {
guard let c = activeConnection else { return }
connectivity = c.connectivity
hostName = c.hostName
sessions = c.sessions
capabilities = c.capabilities
grantedScope = c.grantedScope
modelCatalog = c.modelCatalog
dashboard = c.dashboard
meshPeers = c.meshPeers
}
/// Every connected Mac's live (non-archived) sessions — the aggregate the app-icon badge and
/// Live Activity summarize across hosts.
private var allLiveSessions: [WireSessionSummary] {
if demoMode { return demoHosts.flatMap(\.sessions).filter { !$0.archived } }
return connections.values.flatMap(\.sessions).filter { !$0.archived }
}
/// Refresh cross-host aggregates: the app-icon badge (needs-you across *all* Macs) + the Live
/// Activity summary.
private func refreshAggregate() {
let needs = allLiveSessions.filter { $0.status.needsYou($0.disposition) }.count
NotificationRouter.shared.updateBadge(needs)
LiveActivityManager.shared.sync(hostName: hostName, sessions: allLiveSessions)
}
/// Tear down every live connection (leaving the paired registry intact) — for entering demo.
private func teardownAll() {
for conn in connections.values { conn.teardown() }
connections.removeAll()
}
/// Connect to every paired Mac at once (mesh P3 multiplexer): each `HostConnection` runs its own
/// IK reconnect (LAN→tailnet, with backoff), so all your Macs are live simultaneously and the
/// switcher flips between them instantly. Idempotent — an already-live connection is left alone;
/// an offline one (re)dials. Connections for since-unpaired Macs are dropped. The launch +
/// "Reconnect" path.
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 hosts = IdentityStore.pairedHosts()
guard !hosts.isEmpty else { connectivity = .unpaired; return }
let paired = Set(hosts.map(\.fingerprint))
for (id, conn) in connections where !paired.contains(id) { conn.teardown(); connections[id] = nil }
for host in hosts {
let conn = connection(for: host)
if !conn.connectivity.isLive { conn.reconnect(to: host) }
}
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
if activeHostID == nil || connections[activeHostID!] == nil {
activeHostID = hosts.last?.fingerprint
}
connectPlan = ConnectPlan(
remaining: candidates, hostStaticKey: host.hostStaticKey,
mode: .reconnect, deviceID: host.deviceID, pairingPayload: nil)
_ = tryNextCandidate()
mirrorActive()
refreshAggregate()
}
/// 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()
// Forget + drop the active Mac's connection specifically (the switcher may have made it not
// the last). Other Macs' connections keep running.
if let id = activeHostID {
connections[id]?.teardown()
connections[id] = nil
IdentityStore.removePairedHost(id: id)
} else {
IdentityStore.clearPairedHost()
}
activeHostID = nil
// Mesh P3: if other Macs remain paired, switch to one (already connected) rather than unpairing.
if let next = IdentityStore.pairedHosts().last {
activeHostID = next.fingerprint
reconnect()
return
}
connectivity = .unpaired
sessions = []
// Nothing left to dial — spin the embedded Tailscale node down if it was running.
@@ -547,10 +583,9 @@ final class RemoteStore: ObservableObject {
/// 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()
teardownAll()
demoMode = true
UserDefaults.standard.set(true, forKey: Self.demoModeKey)
reconnectAttempts = 0
seedDemo()
}
@@ -561,6 +596,8 @@ final class RemoteStore: ObservableObject {
UserDefaults.standard.set(false, forKey: Self.demoModeKey)
demoTasks.forEach { $0.cancel() }
demoTasks.removeAll()
demoHosts = []
activeHostID = nil
openSessionID = nil
openEvents = []
openApprovals = []
@@ -585,9 +622,11 @@ final class RemoteStore: ObservableObject {
openApprovals = []
openDiff = nil
diffLoading = false
seenSeq.removeAll()
markOpened(sessionID)
if demoMode { seedDemoTranscript(sessionID); return }
// The open transcript belongs to the active host; tell its connection so it forwards the
// snapshot/events (and dedupes them), then subscribe.
activeConnection?.openSessionID = sessionID
send(.subscribe(Subscribe(sessionID: sessionID, sinceSeq: nil, verbosity: .full)))
}
@@ -613,6 +652,12 @@ final class RemoteStore: ObservableObject {
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: []))),
@@ -628,8 +673,14 @@ final class RemoteStore: ObservableObject {
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."))),
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).
@@ -648,11 +699,18 @@ final class RemoteStore: ObservableObject {
ApprovalID(rawValue: "demo-appr-\(sessionID.rawValue)")
}
func closeOpen() {
if let id = openSessionID {
send(.unsubscribe(id))
markOpened(id) // everything up to now has been seen
}
/// 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 }
activeConnection?.openSessionID = nil
openSessionID = nil
openEvents = []
openApprovals = []
@@ -723,10 +781,12 @@ final class RemoteStore: ObservableObject {
// MARK: - Plumbing
/// Route an intent to the active host's connection (mesh P3). Every current intent — subscribe,
/// send-input, approvals, favorite/archive/delete, start-chat — targets a session or project on
/// the Mac whose list is on screen, i.e. the active one.
private func send(_ msg: ClientMsg) {
if demoMode { demoHandle(msg); return }
guard let client else { return }
Task { await client.send(msg) }
activeConnection?.send(msg)
}
// MARK: - Demo simulator (offline, interactive)
@@ -782,8 +842,11 @@ final class RemoteStore: ObservableObject {
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
.ping, .cancelQueuedMessage, .fetchDiff, .listPeers, .addressUpdate,
// Session transfer (mesh P5) is a Mac↔Mac flow — the phone never originates these,
// and demo has no peer Macs, so they're inert here.
.transferOffer, .transferChunk, .transferCommit, .transferCancel:
break // passive / already handled by the seeded fixtures (demo has no mesh peers)
}
}
@@ -909,30 +972,10 @@ final class RemoteStore: ObservableObject {
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 {
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."
@@ -943,142 +986,6 @@ final class RemoteStore: ObservableObject {
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?) {
@@ -1097,38 +1004,6 @@ final class RemoteStore: ObservableObject {
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 {
@@ -1172,6 +1047,7 @@ extension WireSessionSummary {
auto: auto ?? self.auto, autoShip: autoShip ?? self.autoShip,
shipBranch: shipBranch ?? self.shipBranch,
contextInputTokens: contextInputTokens,
updatedAt: updatedAt ?? Date())
updatedAt: updatedAt ?? Date(),
movedTo: movedTo, arrivedFrom: arrivedFrom)
}
}
@@ -36,8 +36,6 @@ struct NucleicRemoteApp: App {
struct RootView: View {
@EnvironmentObject var store: RemoteStore
// Initial tab; overridable via NUCLEIC_TAB for offline UI previews.
@State private var tab = Int(ProcessInfo.processInfo.environment["NUCLEIC_TAB"] ?? "") ?? 0
// Appearance preferences — same storage keys and semantics as the Mac's Settings →
// Appearance, so both devices honor the same choices.
@AppStorage(AppAppearance.storageKey) private var appearanceRaw = AppAppearance.system.rawValue
@@ -52,30 +50,15 @@ struct RootView: View {
DemoBanner()
Group {
if store.isPaired {
TabView(selection: $tab) {
HomeView()
.tabItem { Label("Home", systemImage: "house") }.tag(0)
SessionsView()
.tabItem { Label("Sessions", systemImage: "square.stack.3d.up") }
.badge(store.needsYouCount).tag(1)
ProjectsView()
.tabItem { Label("Projects", systemImage: "folder") }.tag(2)
TodosView()
.tabItem { Label("To-dos", systemImage: "checklist") }.tag(3)
SettingsView()
.tabItem { Label("Settings", systemImage: "gearshape") }.tag(4)
}
// Width-adaptive shell: the iPhone's TabView on compact width, the Mac's
// sidebar+detail split on a regular-width iPad (see AdaptiveRootView).
AdaptiveRootView()
} else {
PairingIntroView()
}
}
}
.overlay(alignment: .bottom) { ErrorBubble() }
// A notification tap routes to its session: jump to the Sessions tab, where
// `SessionsView` consumes `pendingRoute` and pushes the detail.
.onChange(of: store.pendingRoute) { _, route in
if route != nil { tab = 1 }
}
.tint(Palette.accent)
.preferredColorScheme((AppAppearance(rawValue: appearanceRaw) ?? .system).colorScheme)
.dynamicTypeSize((AppTextSize(rawValue: textSizeRaw) ?? .medium).dynamicTypeSize)
@@ -0,0 +1,245 @@
import SwiftUI
import NucleicProtocol
/// Chooses the navigation shell by width so one universal binary serves both idioms:
/// • **compact** (iPhone, and iPad in Slide Over / narrow split) → the five-tab `TabView`.
/// • **regular** (iPad full-screen / Stage Manager) → the Mac's sidebar + detail split.
///
/// Both render the SAME `RemoteStore` projection — the iPad is a third renderer of the one host
/// authority (UX_MACOS §8), not a fork. The store holds all real state, so a size-class flip
/// (e.g. resizing a Stage Manager window) only swaps chrome; sessions, connection, and the open
/// transcript survive it.
struct AdaptiveRootView: View {
@Environment(\.horizontalSizeClass) private var sizeClass
var body: some View {
if sizeClass == .regular {
SplitRootView()
} else {
CompactRootView()
}
}
}
// MARK: - Compact (iPhone / narrow multitasking)
/// The original iPhone shell, relocated verbatim from `RootView`: a five-tab `TabView`. A
/// notification tap routes to the Sessions tab, where `SessionsView` consumes `pendingRoute`
/// and pushes the detail.
struct CompactRootView: View {
@EnvironmentObject var store: RemoteStore
// Initial tab; overridable via NUCLEIC_TAB for offline UI previews.
@State private var tab = Int(ProcessInfo.processInfo.environment["NUCLEIC_TAB"] ?? "") ?? 0
var body: some View {
TabView(selection: $tab) {
HomeView()
.tabItem { Label("Home", systemImage: "house") }.tag(0)
SessionsView()
.tabItem { Label("Sessions", systemImage: "square.stack.3d.up") }
.badge(store.needsYouCount).tag(1)
ProjectsView()
.tabItem { Label("Projects", systemImage: "folder") }.tag(2)
TodosView()
.tabItem { Label("To-dos", systemImage: "checklist") }.tag(3)
SettingsView()
.tabItem { Label("Settings", systemImage: "gearshape") }.tag(4)
}
// A notification tap routes to its session by jumping to the Sessions tab.
.onChange(of: store.pendingRoute) { _, route in
if route != nil { tab = 1 }
}
}
}
// MARK: - Regular (iPad sidebar + detail)
/// What the sidebar can select. Mirrors the Mac sidebar: a few global destinations plus the
/// live sessions themselves (grouped by project). Projects are reached through the `projects`
/// destination (`ProjectsView`), which also hosts the scoped "start a chat here" composer.
enum SidebarItem: Hashable {
case home
case projects
case todos
case settings
case session(SessionID)
}
/// The Mac's information architecture on iPad: a two-column `NavigationSplitView` whose leading
/// column carries the destinations + the project→session tree, and whose detail is the selected
/// session (or a destination view). Selection lives here and binds the same `openSessionID`
/// lifecycle the iPhone push/pop path uses — the detail is keyed by session id so switching one
/// session for another fires `open`/`closeOpen` exactly as a push/pop would.
struct SplitRootView: View {
@EnvironmentObject var store: RemoteStore
@State private var selection: SidebarItem? = .home
@State private var columnVisibility: NavigationSplitViewVisibility = .all
/// One-shot: a Cmd+N sets this, the Home composer consumes it to grab focus.
@State private var focusNewChat = false
var body: some View {
NavigationSplitView(columnVisibility: $columnVisibility) {
SplitSidebar(selection: $selection, focusNewChat: $focusNewChat)
} detail: {
SplitDetail(selection: selection, focusNewChat: $focusNewChat)
}
.navigationSplitViewStyle(.balanced)
.background { keyboardShortcuts }
// Notification tap → select its session directly (the regular-width analogue of the
// compact path's jump-to-Sessions-tab). Then clear the request so it isn't re-consumed.
.onChange(of: store.pendingRoute) { _, route in
guard let route else { return }
selection = .session(route)
store.pendingRoute = nil
}
.onAppear {
// Offline UI previews (NUCLEIC_DEMO): open straight into one session.
if let raw = ProcessInfo.processInfo.environment["NUCLEIC_DEMO_SESSION"], !raw.isEmpty {
selection = .session(SessionID(rawValue: raw))
}
}
}
/// Hardware-keyboard shortcuts (Magic Keyboard on iPad), the remote's echo of the Mac's menu
/// commands: ⌘N new chat, ⌘1–4 jump to a destination, ⌘R refresh. Carried by hidden buttons —
/// SwiftUI still routes a shortcut to a zero-opacity button that's in the hierarchy.
private var keyboardShortcuts: some View {
Group {
Button("New chat") { selection = .home; focusNewChat = true }
.keyboardShortcut("n", modifiers: .command)
Button("Home") { selection = .home }.keyboardShortcut("1", modifiers: .command)
Button("Projects") { selection = .projects }.keyboardShortcut("2", modifiers: .command)
Button("To-dos") { selection = .todos }.keyboardShortcut("3", modifiers: .command)
Button("Settings") { selection = .settings }.keyboardShortcut("4", modifiers: .command)
Button("Refresh") { store.refreshSessions() }.keyboardShortcut("r", modifiers: .command)
}
.opacity(0)
.accessibilityHidden(true)
}
}
/// The leading column: global destinations, then the live sessions grouped under their projects
/// (attention-first within each group), mirroring the Mac sidebar tree. The persistent
/// connection status sits in the footer — one home for it, versus the phone's per-tab inset.
private struct SplitSidebar: View {
@EnvironmentObject var store: RemoteStore
@Binding var selection: SidebarItem?
var focusNewChat: Binding<Bool> = .constant(false)
/// Live sessions grouped under their project (dashboard order), with anything whose project
/// isn't in the dashboard collected into a trailing "Other" group. Attention-first sort.
private var projectGroups: [(name: String, rows: [WireSessionSummary])] {
let live = store.liveSessions.sorted {
let a = StatusStyle.sortRank($0), b = StatusStyle.sortRank($1)
return a == b ? $0.updatedAt > $1.updatedAt : a < b
}
var groups: [(name: String, rows: [WireSessionSummary])] = []
var placed = Set<SessionID>()
for project in store.dashboard.projects {
let rows = live.filter { $0.projectID == project.id.rawValue }
guard !rows.isEmpty else { continue }
groups.append((project.name, rows))
rows.forEach { placed.insert($0.sessionID) }
}
let orphans = live.filter { !placed.contains($0.sessionID) }
if !orphans.isEmpty { groups.append(("Other", orphans)) }
return groups
}
var body: some View {
List(selection: $selection) {
Section {
Label("Home", systemImage: "house").tag(SidebarItem.home)
Label("Projects", systemImage: "folder").tag(SidebarItem.projects)
Label("To-dos", systemImage: "checklist").tag(SidebarItem.todos)
Label("Settings", systemImage: "gearshape").tag(SidebarItem.settings)
}
if projectGroups.isEmpty {
Section("Sessions") {
Text(store.connectivity.isLive
? "Start a session from Home or the Mac to see it here."
: "Waiting to connect to \(store.hostName)…")
.font(.callout).foregroundStyle(.secondary)
}
} else {
ForEach(projectGroups, id: \.name) { group in
Section(group.name) {
ForEach(group.rows, id: \.sessionID) { summary in
SessionRow(summary: summary, showProjectName: false)
.tag(SidebarItem.session(summary.sessionID))
.listRowBackground(SessionRowWash(summary: summary))
.swipeActions(edge: .leading) {
Button {
store.setFavorite(summary.sessionID, !summary.favorite)
} label: {
Label("Favorite", systemImage: summary.favorite ? "star.slash" : "star.fill")
}.tint(.yellow)
}
.swipeActions(edge: .trailing) {
Button(role: .destructive) {
store.deleteSession(summary.sessionID)
} label: { Label("Delete", systemImage: "trash") }
Button {
store.setArchived(summary.sessionID, !summary.archived)
} label: {
Label(summary.archived ? "Unarchive" : "Archive",
systemImage: summary.archived ? "tray.and.arrow.up" : "archivebox")
}.tint(.gray)
}
}
}
}
}
}
.listStyle(.sidebar)
.navigationTitle(store.hostName.isEmpty ? "Nucleic" : store.hostName)
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button {
selection = .home
focusNewChat.wrappedValue = true
} label: {
Image(systemName: "square.and.pencil")
}
.help("New chat (⌘N)")
.disabled(!store.canControl)
}
ToolbarItem(placement: .primaryAction) {
Button { store.refreshSessions() } label: {
Image(systemName: "arrow.clockwise")
}
.help("Refresh (⌘R)")
}
}
.safeAreaInset(edge: .bottom) {
ConnectionChip().padding(.bottom, 10)
}
}
}
/// The detail column. A selected session reuses the existing `SessionDetailView` unchanged
/// (it owns its own subscribe/unsubscribe on appear/disappear); it's keyed `.id(sessionID)` so
/// switching sessions remounts and drives that lifecycle. The destination views already embed
/// their own `NavigationStack`, so they're shown directly.
private struct SplitDetail: View {
let selection: SidebarItem?
var focusNewChat: Binding<Bool> = .constant(false)
var body: some View {
switch selection {
case .home, .none:
HomeView(focusNewChat: focusNewChat)
case .projects:
ProjectsView()
case .todos:
TodosView()
case .settings:
SettingsView()
case .session(let id):
NavigationStack {
SessionDetailView(sessionID: id)
}
.id(id)
}
}
}
@@ -33,13 +33,17 @@ struct ApprovalCardView: View {
.background(approval.risk.color.opacity(0.2), in: Capsule())
.foregroundStyle(approval.risk.color)
}
// A host_exec gate escapes the sandbox onto the macOS host, so lay out what it's
// about to run — the inferred purpose and the program/actions/flags, parsed from the
// command itself (never from the agent) — then the exact command beneath it. Every
// A git commit reads as a structured commit card (subject + Markdown body) so you can
// see exactly what you're granting; the literal command stays under "Show command".
// Otherwise a host_exec gate lays out its parsed breakdown + exact command, and every
// other tool shows its untruncated detail in a single scrollable box.
if approval.toolName == HostCommandSummary.hostExecToolName,
let command = approval.input["command"]?.stringValue,
let parsed = HostCommandSummary.summary(for: command) {
if let command = approval.input["command"]?.stringValue,
let commit = GitCommitSummary.parse(command) {
GitCommitCard(commit: commit, rawCommand: command)
} else if let command = approval.input["command"]?.stringValue,
let parsed = HostCommandSummary.summary(for: command),
approval.toolName == HostCommandSummary.hostExecToolName
|| parsed.invocations.count > 1 || parsed.isDestructive {
HostCommandBreakdown(summary: parsed)
Text("Exact command").font(.caption2.weight(.semibold)).foregroundStyle(.secondary)
ApprovalDetailBox(text: command)
@@ -66,6 +70,9 @@ struct ApprovalCardView: View {
Text("Deny").frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
// Hardware-keyboard shortcuts for the defining interaction: Esc denies, Return
// allows (the approval bar replaces the composer, so Return is unclaimed here).
.keyboardShortcut(.cancelAction)
Button {
store.respond(approval, .allow())
@@ -74,9 +81,13 @@ struct ApprovalCardView: View {
}
.buttonStyle(.borderedProminent)
.disabled(!allowEnabled)
.keyboardShortcut(.defaultAction)
}
if !store.capabilities.allowAlwaysScopes.isEmpty {
// A destructive action (rm, force-push, reset --hard, …) offers no remembered
// allow: every one must be a deliberate, one-off approval, never granted in a
// way that lets the next one through unseen. Mirrors the Mac.
if approval.risk != .destructive, !store.capabilities.allowAlwaysScopes.isEmpty {
Menu("Allow always…") {
ForEach(store.capabilities.allowAlwaysScopes, id: \.self) { scope in
Button(alwaysLabel(scope)) {
@@ -5,11 +5,16 @@ import NucleicProtocol
/// composer). Picks a project, types a prompt, optionally toggles Auto.
struct StartChatComposer: View {
@EnvironmentObject var store: RemoteStore
/// A one-shot request from the iPad `Cmd+N` shortcut to focus the prompt so a keyboard user can
/// start typing a new chat immediately. Consumed (reset to false) once handled, so it never
/// steals focus on a later Home appearance. `.constant(false)` for the iPhone/compact path.
var focusNewChat: Binding<Bool> = .constant(false)
@State private var projectID: ProjectID?
@State private var draft = ""
@State private var auto = false
@State private var model: String?
@State private var effort = MobileEfforts.fallback
@FocusState private var draftFocused: Bool
// Advanced options (the Mac new-session sheet's base branch + worktree fields).
@State private var showOptions = false
@State private var baseBranch = ""
@@ -79,6 +84,7 @@ struct StartChatComposer: View {
.lineLimit(1...5)
.orchestraGlow(active: MobileEfforts.isOrchestra(effort) && controlled)
.keyboardDismissable()
.focused($draftFocused)
Button {
if let project = selected {
let branch = baseBranch.trimmingCharacters(in: .whitespaces)
@@ -92,6 +98,7 @@ struct StartChatComposer: View {
Image(systemName: "arrow.up.circle.fill").font(.title)
}
.disabled(selected == nil || draft.trimmingCharacters(in: .whitespaces).isEmpty || !store.canControl)
.keyboardShortcut(.return, modifiers: .command)
}
if !store.canControl {
Text("This device is view-only.").font(.caption2).foregroundStyle(.secondary)
@@ -106,6 +113,18 @@ struct StartChatComposer: View {
effort = levels.last ?? MobileEfforts.fallback
}
}
// A Cmd+N (iPad) focuses the prompt so the keyboard user can type a new chat immediately.
// onAppear handles the case where Cmd+N switched to Home from a session (composer just
// mounted); onChange handles Cmd+N while Home is already on screen. Consumed either way.
.onAppear { consumeFocusRequest() }
.onChange(of: focusNewChat.wrappedValue) { consumeFocusRequest() }
}
private func consumeFocusRequest() {
guard focusNewChat.wrappedValue else { return }
if projectID == nil { projectID = store.dashboard.projects.first?.id }
draftFocused = true
focusNewChat.wrappedValue = false
}
}
@@ -6,9 +6,18 @@ import NucleicProtocol
/// start-chat composer (control scope).
struct HomeView: View {
@EnvironmentObject var store: RemoteStore
/// Set by the iPad `Cmd+N` shortcut to focus the start-chat composer below (consumed there).
var focusNewChat: Binding<Bool> = .constant(false)
private var counts: DashboardCounts { store.dashboard.counts }
/// Sessions blocking on a human — the dashboard's "which session needs me?" answer (the Mac
/// home §2). Uses turn disposition so a finished-the-work chat doesn't count; archived hidden.
private var needsAttention: [WireSessionSummary] {
store.liveSessions
.filter { $0.status.needsYou($0.disposition) }
.sorted { $0.updatedAt > $1.updatedAt }
}
/// Sessions actively working a turn — drives both the "In progress" stat card and the
/// live list pinned above the to-dos.
private var running: [WireSessionSummary] {
@@ -64,6 +73,10 @@ struct HomeView: View {
}
}
// "Which session needs me?" — pinned above everything when a chat is blocking
// on you (an approval or your next prompt), so it leads the dashboard.
if !needsAttention.isEmpty { NeedsAttentionSection(sessions: needsAttention) }
statCards
// The Mac's usage gauges (5-hour / weekly windows); hidden when the host
@@ -83,9 +96,12 @@ struct HomeView: View {
QuickTodos()
StartChatComposer()
StartChatComposer(focusNewChat: focusNewChat)
}
.padding()
// Cap + center the dashboard on a wide iPad so it reads like the Mac home rather
// than a stretched phone; a no-op at phone width.
.readableColumn()
}
.navigationTitle("Home")
.navigationBarTitleDisplayMode(.inline)
@@ -116,6 +132,39 @@ struct HomeView: View {
}
}
/// The dashboard's attention list: chats blocking on you (an approval or your next prompt),
/// pinned at the very top. Tapping a row drops into that session. Header tinted in the attention
/// colour so it reads as urgent, matching the Mac home's "Needs attention" lead.
private struct NeedsAttentionSection: View {
let sessions: [WireSessionSummary]
var body: some View {
VStack(alignment: .leading, spacing: 10) {
HStack {
Label("Needs you", systemImage: "exclamationmark.triangle.fill")
.font(.headline).foregroundStyle(Palette.attention)
Spacer()
Text("\(sessions.count)")
.font(.subheadline.weight(.semibold)).foregroundStyle(Palette.attention)
}
ForEach(sessions.prefix(6), id: \.sessionID) { summary in
NavigationLink {
SessionDetailView(sessionID: summary.sessionID)
} label: {
SessionRow(summary: summary)
}
.buttonStyle(.plain)
.hoverEffect(.highlight)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.card()
.overlay(
RoundedRectangle(cornerRadius: 14)
.strokeBorder(Palette.attention.opacity(0.35), lineWidth: 1))
}
}
/// Live peek at sessions actively running a turn, pinned above the to-do list on Home. Tapping
/// a row drops into the session transcript.
private struct InProgressSessions: View {
@@ -135,6 +184,7 @@ private struct InProgressSessions: View {
SessionRow(summary: summary)
}
.buttonStyle(.plain)
.hoverEffect(.highlight)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
@@ -0,0 +1,12 @@
import SwiftUI
/// Width tuning for the universal layout. In a wide iPad detail column, content designed for a
/// phone runs edge-to-edge and loses the Mac's comfortable reading measure; `readableColumn`
/// caps its width and centers it. It's a no-op on the phone and on iPad compact multitasking,
/// where the available width is already below the cap — so the iPhone layout is unchanged.
extension View {
func readableColumn(_ maxWidth: CGFloat = 820) -> some View {
frame(maxWidth: maxWidth, alignment: .leading)
.frame(maxWidth: .infinity, alignment: .center)
}
}
@@ -71,7 +71,22 @@ struct SessionDetailView: View {
Button("Cancel", role: .cancel) {}
}
.onAppear { store.open(sessionID) }
.onDisappear { store.closeOpen() }
// Pass our own id so an iPad split-view A→B switch (which may mount B before A
// disappears) unsubscribes A without tearing down B's just-opened state.
.onDisappear { store.closeOpen(sessionID) }
.background { interruptShortcut }
}
/// ⌘. interrupts a running session (the Mac's "stop" convention) — the action is otherwise
/// only in the ⋯ menu. A hidden button carries the shortcut; present only when it applies.
@ViewBuilder
private var interruptShortcut: some View {
if store.canControl, summary?.status == .running {
Button("Interrupt") { store.interrupt(sessionID) }
.keyboardShortcut(".", modifiers: .command)
.opacity(0)
.accessibilityHidden(true)
}
}
/// Whether this session's project is under Nucleic Control (gates Orchestra + autoship).
@@ -221,6 +236,9 @@ struct SessionDetailView: View {
.font(.title2)
}
.disabled(draft.trimmingCharacters(in: .whitespaces).isEmpty || !store.connectivity.isLive)
// Hardware-keyboard send (Magic Keyboard on iPad), mirroring the Mac —
// plain Return stays newline in the multiline field.
.keyboardShortcut(.return, modifiers: .command)
}
}
}
@@ -297,6 +315,9 @@ struct TranscriptList: View {
}
}
.padding()
// Cap the transcript to a readable measure on a wide iPad so lines don't run
// edge-to-edge on a 13-inch screen; a no-op at phone width.
.readableColumn()
}
// A drag on the transcript dismisses the keyboard, so a tall multiline composer can
// be put away without leaving the session.
@@ -310,25 +331,26 @@ struct TranscriptList: View {
}
}
/// The Diff tab with the real patch (the Mac Diff tab, phone form): a per-file summary list
/// over the unified patch, colored +/− per line, fetched on demand via `fetchDiff`.
/// The Diff tab, read-only over the on-demand `WireSessionDiff`. On a phone it's a per-file
/// summary over one scrolling unified patch; on a regular-width iPad it becomes the Mac's
/// two-pane diff — a selectable file list beside the selected file's patch — exploiting the
/// wide detail column. Same wire, no new protocol.
struct SessionDiffView: View {
let diff: WireSessionDiff?
let loading: Bool
/// Below this available width the two-pane diff would leave the patch too cramped, so we
/// keep the phone stack. Picks two-pane on a wide iPad (landscape, or a large iPad) and the
/// stack on a phone or a narrow portrait split — based on real width, not just size class.
private let twoPaneMinWidth: CGFloat = 700
var body: some View {
if let diff, diff.stat.filesChanged > 0 {
ScrollView {
VStack(alignment: .leading, spacing: 14) {
fileList(diff)
PatchText(patch: diff.patch)
if diff.truncated {
Label("Patch truncated — open the Mac for the rest.",
systemImage: "scissors")
.font(.caption).foregroundStyle(.secondary)
}
GeometryReader { geo in
if geo.size.width >= twoPaneMinWidth {
SplitDiffView(diff: diff)
} else {
stacked(diff)
}
.padding()
}
} else if loading {
ProgressView("Fetching diff…")
@@ -338,28 +360,123 @@ struct SessionDiffView: View {
}
}
private func fileList(_ diff: WireSessionDiff) -> some View {
VStack(alignment: .leading, spacing: 6) {
Text("\(diff.stat.filesChanged) file\(diff.stat.filesChanged == 1 ? "" : "s") changed · +\(diff.stat.added) −\(diff.stat.removed)")
.font(.subheadline.weight(.semibold))
ForEach(diff.files) { file in
HStack(spacing: 8) {
Image(systemName: statusIcon(file.status))
.font(.caption)
.foregroundStyle(statusColor(file.status))
Text(file.path)
.font(.caption.monospaced())
.lineLimit(1).truncationMode(.middle)
Spacer(minLength: 8)
Text("+\(file.added)").font(.caption2.monospacedDigit()).foregroundStyle(Palette.success)
Text("−\(file.removed)").font(.caption2.monospacedDigit()).foregroundStyle(Palette.danger)
/// Phone form: the file summary over one scrolling unified patch.
private func stacked(_ diff: WireSessionDiff) -> some View {
ScrollView {
VStack(alignment: .leading, spacing: 14) {
VStack(alignment: .leading, spacing: 6) {
DiffStatHeader(stat: diff.stat)
ForEach(diff.files) { DiffFileRow(file: $0) }
}
.card()
PatchText(patch: diff.patch)
if diff.truncated { TruncatedPatchNote() }
}
.padding()
.readableColumn()
}
}
}
/// iPad form: the Mac's two-pane diff. A tappable file list on the left drives the selected
/// file's patch on the right. Row taps use a plain `Button` (not `List(selection:)`, whose
/// single-select tap handling is unreliable outside an edit-mode / split-view context).
private struct SplitDiffView: View {
let diff: WireSessionDiff
@State private var selected: String?
/// The combined patch split into per-file sections, keyed by file path.
private var sections: [String: String] { UnifiedPatch.sections(diff.patch, files: diff.files) }
private var current: String? { selected ?? diff.files.first?.path }
var body: some View {
HStack(spacing: 0) {
fileList
.frame(width: 280)
.background(Color(.secondarySystemBackground))
Divider()
patchPane
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.onAppear { if selected == nil { selected = diff.files.first?.path } }
// Keep the selection valid as the diff refetches (files can appear/vanish between turns).
.onChange(of: diff.files) { _, files in
if selected == nil || !files.contains(where: { $0.path == selected }) {
selected = files.first?.path
}
}
.card()
}
private func statusIcon(_ status: String) -> String {
private var fileList: some View {
ScrollView {
LazyVStack(alignment: .leading, spacing: 0) {
DiffStatHeader(stat: diff.stat)
.padding(.horizontal, 12).padding(.vertical, 10)
.frame(maxWidth: .infinity, alignment: .leading)
Divider()
ForEach(diff.files) { file in
Button { selected = file.path } label: {
DiffFileRow(file: file)
.padding(.horizontal, 12).padding(.vertical, 9)
.frame(maxWidth: .infinity, alignment: .leading)
.background(current == file.path
? Palette.accent.opacity(0.15) : Color.clear)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.hoverEffect(.highlight)
}
if diff.truncated { TruncatedPatchNote().padding(12) }
}
}
}
@ViewBuilder
private var patchPane: some View {
if let path = current, let patch = sections[path], !patch.isEmpty {
ScrollView { PatchText(patch: patch).padding() }
} else if current != nil {
// A file with no textual hunk (binary, or a section dropped by patch truncation).
ContentUnavailableView(
"No preview", systemImage: "doc",
description: Text("This file has no textual diff\(diff.truncated ? " in the fetched patch" : "").")
)
} else {
ContentUnavailableView("Select a file", systemImage: "sidebar.left")
}
}
}
/// The "N files changed · +A −R" header shared by both diff forms.
private struct DiffStatHeader: View {
let stat: DiffStat
var body: some View {
Text("\(stat.filesChanged) file\(stat.filesChanged == 1 ? "" : "s") changed · +\(stat.added) −\(stat.removed)")
.font(.subheadline.weight(.semibold))
}
}
/// One file's row: status glyph, path, and its +/− counts. Shared by the phone summary and the
/// iPad file list.
struct DiffFileRow: View {
let file: WireFileDiff
var body: some View {
HStack(spacing: 8) {
Image(systemName: DiffStatus.icon(file.status))
.font(.caption)
.foregroundStyle(DiffStatus.color(file.status))
Text(file.path)
.font(.caption.monospaced())
.lineLimit(1).truncationMode(.middle)
Spacer(minLength: 8)
Text("+\(file.added)").font(.caption2.monospacedDigit()).foregroundStyle(Palette.success)
Text("−\(file.removed)").font(.caption2.monospacedDigit()).foregroundStyle(Palette.danger)
}
}
}
enum DiffStatus {
static func icon(_ status: String) -> String {
switch status {
case "added", "untracked": "plus.circle"
case "deleted": "minus.circle"
@@ -367,8 +484,7 @@ struct SessionDiffView: View {
default: "pencil.circle"
}
}
private func statusColor(_ status: String) -> Color {
static func color(_ status: String) -> Color {
switch status {
case "added", "untracked": Palette.success
case "deleted": Palette.danger
@@ -377,6 +493,57 @@ struct SessionDiffView: View {
}
}
private struct TruncatedPatchNote: View {
var body: some View {
Label("Patch truncated — open the Mac for the rest.", systemImage: "scissors")
.font(.caption).foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
/// Splits a combined `git diff` patch into per-file sections for the iPad's two-pane view.
enum UnifiedPatch {
/// Map from file path to that file's slice of the unified patch. Sections begin at a
/// `diff --git a/… b/…` line. When the section count matches `files` we pair positionally
/// (the host emits patch and files together, in order); otherwise we key each section by the
/// new path parsed from its header. Callers fall back to a "no preview" state on a miss.
static func sections(_ patch: String, files: [WireFileDiff]) -> [String: String] {
guard patch.contains("diff --git ") else {
// A single-file patch with no git header: attribute the whole thing to the sole file.
return files.count == 1 ? [files[0].path: patch] : [:]
}
var chunks: [String] = []
var current: [Substring] = []
for line in patch.split(separator: "\n", omittingEmptySubsequences: false) {
if line.hasPrefix("diff --git ") {
if !current.isEmpty { chunks.append(current.joined(separator: "\n")) }
current = [line]
} else if !current.isEmpty {
current.append(line)
}
}
if !current.isEmpty { chunks.append(current.joined(separator: "\n")) }
var result: [String: String] = [:]
if chunks.count == files.count {
for (file, chunk) in zip(files, chunks) { result[file.path] = chunk }
} else {
for chunk in chunks where newPath(chunk) != nil { result[newPath(chunk)!] = chunk }
}
return result
}
/// The new-side path from a section's `diff --git a/OLD b/NEW` header. Nil when the header is
/// absent or the path is quoted/spaced in a way we don't split cleanly.
private static func newPath(_ chunk: String) -> String? {
guard let header = chunk.split(separator: "\n", maxSplits: 1).first,
header.hasPrefix("diff --git "),
let bRange = header.range(of: " b/") else { return nil }
let path = header[bRange.upperBound...]
return path.isEmpty ? nil : String(path)
}
}
/// The unified patch, one `Text` per line with the classic +/− coloring. Lines scroll
/// horizontally as a block so long lines don't wrap into noise.
private struct PatchText: View {
@@ -79,6 +79,27 @@ struct SessionsView: View {
// A notification tap while this tab is already up.
.onChange(of: store.pendingRoute) { consumeRoute() }
.toolbar {
// Host switcher (mesh P3): pick which paired Mac to view. Hidden with a single Mac.
ToolbarItem(placement: .topBarLeading) {
if store.hostChoices.count > 1 {
Menu {
ForEach(store.hostChoices) { choice in
Button {
store.switchHost(to: choice.id)
} label: {
Label(choice.name,
systemImage: choice.isActive ? "checkmark" : "desktopcomputer")
}
}
} label: {
HStack(spacing: 4) {
Image(systemName: "desktopcomputer")
Text(store.hostName).lineLimit(1)
Image(systemName: "chevron.down").font(.caption2)
}
}
}
}
ToolbarItem(placement: .topBarTrailing) {
Button {
showArchived.toggle()
@@ -125,18 +146,32 @@ struct SessionRow: View {
}
Text(summary.title).font(.body.weight(.medium)).lineLimit(1)
// The same trailing markers the Mac sidebar row carries: auto-approval
// bolt, Orchestra note, autoship box.
if summary.auto {
Image(systemName: "bolt.fill").font(.caption2).foregroundStyle(Palette.accent)
}
if orchestra {
Image(systemName: "music.note.list").font(.caption2).foregroundStyle(Palette.orchestra)
}
if summary.autoShip {
Image(systemName: "shippingbox.fill").font(.caption2).foregroundStyle(Palette.accent)
// bolt, Orchestra note, autoship box. Suppressed on a moved-away tombstone.
if summary.movedTo == nil {
if summary.auto {
Image(systemName: "bolt.fill").font(.caption2).foregroundStyle(Palette.accent)
}
if orchestra {
Image(systemName: "music.note.list").font(.caption2).foregroundStyle(Palette.orchestra)
}
if summary.autoShip {
Image(systemName: "shippingbox.fill").font(.caption2).foregroundStyle(Palette.accent)
}
}
}
if showProjectName {
if let moved = summary.movedTo {
// Moved to another Mac (mesh P5): the name is baked by the host, so the phone
// renders it directly without needing to know that Mac itself.
Label("Moved to \(moved.deviceName)", systemImage: "arrow.up.forward")
.font(.caption).foregroundStyle(.secondary).lineLimit(1)
} else if let arrived = summary.arrivedFrom {
// Arrived from another Mac (mesh P5) — subtle provenance, with the project name
// folded in when it would otherwise show.
Label(showProjectName ? "\(summary.projectName) · from \(arrived.deviceName)"
: "from \(arrived.deviceName)",
systemImage: "arrow.down.forward")
.font(.caption).foregroundStyle(.secondary).lineLimit(1)
} else if showProjectName {
Text(summary.projectName).font(.caption).foregroundStyle(.secondary)
}
}
@@ -1,12 +1,14 @@
import SwiftUI
import NucleicProtocol
import NucleicTailnet
import UIKit
struct SettingsView: View {
@EnvironmentObject var store: RemoteStore
@State private var showScanner = false
@State private var tailscaleAuthKey: String = TailnetAuthStore.loadAuthKey() ?? ""
@FocusState private var tailscaleKeyFocused: Bool
@State private var showManualPair = false
/// Bumped after removing a paired Mac so the "Paired Macs" list re-reads the registry (mesh P3).
@State private var pairedHostsToken = UUID()
@AppStorage("nucleic.showRawEvents") private var showRaw = false
@AppStorage("nucleic.showLockEvents") private var showLockEvents = true
@AppStorage(HeartbeatSettings.shareAnonymousUsageKey) private var shareAnonymousUsage = true
@@ -64,18 +66,44 @@ struct SettingsView: View {
.disabled(!store.isPaired)
}
// Mesh P3: every Mac this phone is paired with. Today the connection uses the
// "Active" one (most-recently paired); the multiplexer will connect to all. Removing
// the active Mac unpairs the live connection; removing another just forgets it.
let pairedHosts = IdentityStore.pairedHosts()
if !pairedHosts.isEmpty {
Section("Paired Macs") {
ForEach(pairedHosts, id: \.fingerprint) { host in
let isActive = host.fingerprint == pairedHosts.last?.fingerprint
HStack(spacing: 10) {
Image(systemName: "desktopcomputer").foregroundStyle(.secondary)
VStack(alignment: .leading, spacing: 2) {
Text(host.hostName.isEmpty ? "Mac" : host.hostName)
Text(host.fingerprint.prefix(16) + "…")
.font(.footnote.monospaced()).foregroundStyle(.secondary)
}
Spacer()
if isActive {
Text("Active").font(.caption).foregroundStyle(.secondary)
}
Button(role: .destructive) {
if isActive {
store.unpair()
} else {
IdentityStore.removePairedHost(id: host.fingerprint)
}
pairedHostsToken = UUID()
} label: {
Image(systemName: "minus.circle")
}
.buttonStyle(.borderless)
}
}
}
.id(pairedHostsToken)
}
Section {
if TailnetSupport.isBuiltIn {
// Commit on editing end, not per keystroke — a per-change save would
// clear the valid Keychain key on the first backspace of an edit.
SecureField("Auth key (tskey-auth-…)", text: $tailscaleAuthKey)
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
.focused($tailscaleKeyFocused)
.onSubmit { TailnetAuthStore.saveAuthKey(tailscaleAuthKey) }
.onChange(of: tailscaleKeyFocused) {
if !tailscaleKeyFocused { TailnetAuthStore.saveAuthKey(tailscaleAuthKey) }
}
if let status = store.tailnetStatus {
LabeledContent("Node", value: status)
}
@@ -83,6 +111,9 @@ struct SettingsView: View {
Link(destination: loginURL) {
Label("Open Tailscale login", systemImage: "arrow.up.forward.app")
}
} else {
Text("No setup needed — when your Mac shares over Tailscale, this iPhone joins your tailnet on first connect and opens a browser login to approve itself.")
.font(.caption).foregroundStyle(.secondary)
}
} else {
Text("This build doesn't include Tailscale support.")
@@ -91,7 +122,7 @@ struct SettingsView: View {
} header: {
Text("Tailscale")
} footer: {
Text("Needed only when your Mac shares over Tailscale (Mac ▸ Settings ▸ Remote ▸ Connect via). Leave the key empty to approve this iPhone in your browser on first connect, or create an auth key in the Tailscale admin console (kept in the Keychain, used once to join your tailnet).")
Text("Needed only when your Mac shares over Tailscale (Mac ▸ Settings ▸ Remote ▸ Connection methods). This iPhone joins your tailnet in the browser on first connect.")
}
Section("This device") {
@@ -133,6 +164,11 @@ struct SettingsView: View {
} label: {
Label("Pair with a Mac", systemImage: "qrcode.viewfinder")
}
Button {
showManualPair = true
} label: {
Label("Enter code manually", systemImage: "keyboard")
}
if store.isPaired {
Button(role: .destructive) { store.unpair() } label: {
Label("Unpair this device", systemImage: "minus.circle")
@@ -149,6 +185,12 @@ struct SettingsView: View {
store.pair(with: payload)
}
}
.sheet(isPresented: $showManualPair) {
ManualPairingView { payload in
showManualPair = false
store.pair(with: payload)
}
}
}
}
}
@@ -157,12 +199,13 @@ struct SettingsView: View {
struct PairingIntroView: View {
@EnvironmentObject var store: RemoteStore
@State private var showScanner = false
@State private var showManualPair = false
var body: some View {
VStack(spacing: 24) {
Image(systemName: "qrcode.viewfinder").font(.system(size: 72)).foregroundStyle(.tint)
Text("Pair with your Mac").font(.title2.weight(.semibold))
Text("On your Mac, open Nucleic ▸ Settings ▸ Add iPhone to show a QR code, then scan it here.")
Text("On your Mac, open Nucleic ▸ Settings ▸ Add device to show a QR code, then scan it here — or enter the pairing code beneath it manually.")
.multilineTextAlignment(.center)
.foregroundStyle(.secondary)
.padding(.horizontal, 32)
@@ -173,6 +216,15 @@ struct PairingIntroView: View {
}
.buttonStyle(.borderedProminent)
// A camera-free path — the iPad's scanner can't run while the app is mirrored to an
// external display or in some Stage Manager states; pasting the code always works.
Button {
showManualPair = true
} label: {
Label("Enter code manually", systemImage: "keyboard")
}
.buttonStyle(.bordered)
// Feedback after a scan: the whole UI is still the intro until pairing completes, so
// without this a failed or in-flight pair looks like "nothing happened."
pairingStatus
@@ -201,6 +253,12 @@ struct PairingIntroView: View {
store.pair(with: payload)
}
}
.sheet(isPresented: $showManualPair) {
ManualPairingView { payload in
showManualPair = false
store.pair(with: payload)
}
}
}
/// A status line reflecting an in-flight or failed pairing attempt. Silent in the resting
@@ -239,3 +297,72 @@ struct PairingIntroView: View {
}
}
}
/// A camera-free pairing path (UX_IOS §7): paste the Mac's `nucleic://pair?d=…` code instead of
/// scanning its QR. The iPad's camera can be blocked while mirrored to an external display or in
/// some Stage Manager states, where the scanner's `couldNotStart` path leaves you stuck — this
/// always works. Parses with the same `PairingPayload(qrString:)` the scanner uses.
struct ManualPairingView: View {
let onEntered: (PairingPayload) -> Void
@Environment(\.dismiss) private var dismiss
@State private var code = ""
@State private var error: String?
@FocusState private var fieldFocused: Bool
var body: some View {
NavigationStack {
Form {
Section {
TextField("nucleic://pair?d=…", text: $code, axis: .vertical)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.keyboardType(.URL)
.font(.callout.monospaced())
.lineLimit(2...6)
.focused($fieldFocused)
.onChange(of: code) { error = nil }
} header: {
Text("Pairing code")
} footer: {
Text("On your Mac, open Nucleic ▸ Settings ▸ Add device and copy the pairing link shown beneath the QR code, then paste it here.")
}
if let error {
Label(error, systemImage: "exclamationmark.triangle.fill")
.font(.callout).foregroundStyle(.red)
}
Button {
submit()
} label: {
Label("Pair", systemImage: "link").frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.disabled(code.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
.keyboardShortcut(.defaultAction)
}
.navigationTitle("Enter pairing code")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } }
ToolbarItem(placement: .primaryAction) {
Button {
if let pasted = UIPasteboard.general.string { code = pasted }
} label: {
Label("Paste", systemImage: "doc.on.clipboard")
}
.disabled(!UIPasteboard.general.hasStrings)
}
}
.onAppear { fieldFocused = true }
}
}
private func submit() {
let trimmed = code.trimmingCharacters(in: .whitespacesAndNewlines)
guard let payload = try? PairingPayload(qrString: trimmed) else {
error = "That isn't a valid Nucleic pairing code. Copy the link beneath the QR on your Mac."
return
}
onEntered(payload)
dismiss()
}
}
@@ -0,0 +1,89 @@
import SwiftUI
/// A `git commit` rendered as a structured card — the remote's echo of the desktop `GitBlockCard`:
/// the commit subject reads as a headline and the message body as Markdown, so a commit in the
/// transcript is legible at a glance instead of a raw `git commit -F - <<'EOF' …` blob. The literal
/// command stays one tap away under "Show command", so the card never hides what actually ran.
struct GitCommitCard: View {
let commit: GitCommitSummary.Commit
/// The literal command, revealed under "Show command". Omit to hide the disclosure.
var rawCommand: String? = nil
private var accent: Color { Palette.accent }
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack(alignment: .firstTextBaseline, spacing: 9) {
Image(systemName: commit.amend ? "pencil.circle" : "checkmark.seal")
.font(.callout).foregroundStyle(accent).frame(width: 16)
VStack(alignment: .leading, spacing: 2) {
Text(commit.amend ? "Amend commit" : "Commit")
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
if !commit.subject.isEmpty {
Text(commit.subject)
.font(.callout.weight(.semibold))
.fixedSize(horizontal: false, vertical: true)
.textSelection(.enabled)
}
}
}
if !commit.body.isEmpty {
// The body is real commit-message Markdown (paragraphs, bullet lists) — render it
// formatted, indented under the subject.
MarkdownText(markdown: commit.body)
.padding(.leading, 25)
.textSelection(.enabled)
}
if let rawCommand, !rawCommand.isEmpty {
CommandDisclosure(command: rawCommand, accent: accent)
}
}
.padding(.horizontal, 12).padding(.vertical, 10)
.frame(maxWidth: .infinity, alignment: .leading)
.background(accent.opacity(0.06))
.overlay(RoundedRectangle(cornerRadius: 8).strokeBorder(accent.opacity(0.16), lineWidth: 1))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
/// A disclosure that reveals the literal shell command beneath a structured command card — so a
/// command rendered as a GUI element never hides what's actually being run. The remote's port of
/// the desktop `CommandDisclosure`.
struct CommandDisclosure: View {
/// The literal command to reveal.
let command: String
/// The card's accent, applied to the toggle and the revealed block.
let accent: Color
@State private var showCommand = false
var body: some View {
VStack(alignment: .leading, spacing: 6) {
Button {
withAnimation(.easeInOut(duration: 0.15)) { showCommand.toggle() }
} label: {
HStack(spacing: 5) {
Image(systemName: "chevron.right")
.font(.caption2.weight(.semibold))
.rotationEffect(.degrees(showCommand ? 90 : 0))
Text(showCommand ? "Hide command" : "Show command")
.font(.caption.weight(.medium))
}
.foregroundStyle(accent)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
if showCommand {
Text(command)
.font(.system(.caption, design: .monospaced))
.foregroundStyle(.secondary)
.textSelection(.enabled)
.multilineTextAlignment(.leading)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(8)
.background(accent.opacity(0.05), in: RoundedRectangle(cornerRadius: 6))
}
}
}
}
@@ -577,6 +577,87 @@ private enum Lexer {
}
}
// MARK: - Git commit summary
/// Pulls the commit message out of a command that runs `git commit`, so a commit reads as a
/// structured card (subject headline + Markdown body) instead of a raw `git commit -F - <<'EOF' …`
/// blob — the remote's echo of the desktop `GitCommandSummary`/`GitBlockCard` (which live in
/// NucleicCore, unavailable here). Handles the two shapes agents actually use: `-m`/`--message`
/// arguments and a `-F -`/`--file=-` heredoc body. Parsed from the command string alone, reusing
/// the same shell-aware ``Lexer`` the host-exec summary uses.
enum GitCommitSummary {
struct Commit: Equatable {
var subject: String
var body: String
var amend: Bool
}
static func parse(_ command: String) -> Commit? {
let (flattened, bodies) = Lexer.stripHeredocs(command)
guard let args = commitArguments(in: flattened) else { return nil }
let amend = args.contains("--amend")
var messages: [String] = []
var usesStdinFile = false
var i = 0
while i < args.count {
let a = args[i]
if a == "-m" || a == "--message" {
if i + 1 < args.count { messages.append(args[i + 1]); i += 2; continue }
} else if a.hasPrefix("--message=") {
messages.append(String(a.dropFirst("--message=".count)))
} else if a.hasPrefix("-m"), a.count > 2 {
messages.append(String(a.dropFirst(2)))
} else if (a == "-F" || a == "--file"), i + 1 < args.count, args[i + 1] == "-" {
usesStdinFile = true
} else if a == "--file=-" || a == "-F-" {
usesStdinFile = true
}
i += 1
}
// Prefer explicit `-m` messages; otherwise a `-F -` heredoc body is the message.
let message: String
if !messages.isEmpty {
message = messages.joined(separator: "\n\n")
} else if usesStdinFile,
let body = bodies.first(where: { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }) {
message = body
} else {
return nil
}
// Subject = first non-blank line; body = everything after it.
let lines = message.components(separatedBy: "\n")
guard let subjectIndex = lines.firstIndex(where: { !$0.trimmingCharacters(in: .whitespaces).isEmpty }) else {
return nil
}
let subject = lines[subjectIndex].trimmingCharacters(in: .whitespaces)
let body = lines[(subjectIndex + 1)...].joined(separator: "\n")
.trimmingCharacters(in: .whitespacesAndNewlines)
return Commit(subject: subject, body: body, amend: amend)
}
/// The arguments after `git commit` in whichever segment runs it (skipping env assignments and
/// git's global flags like `-C <dir>` / `-c k=v`), or nil when no segment is a `git commit`.
private static func commitArguments(in flattened: String) -> [String]? {
for segment in Lexer.splitSegments(flattened) {
var tokens = Lexer.tokenize(segment)
while let first = tokens.first, Lexer.isEnvAssignment(first) { tokens.removeFirst() }
guard tokens.first == "git" else { continue }
var rest = Array(tokens.dropFirst())
while let flag = rest.first, flag.hasPrefix("-") {
let takesValue = (flag == "-C" || flag == "-c")
rest.removeFirst()
if takesValue, !rest.isEmpty { rest.removeFirst() }
}
guard rest.first == "commit" else { continue }
return Array(rest.dropFirst())
}
return nil
}
}
// MARK: - ToolGroup convenience
extension ToolGroup {
@@ -29,7 +29,10 @@ struct HostExecToolCard: View {
!$0.flags.isEmpty || !$0.arguments.isEmpty || !$0.env.isEmpty
}
}
private var canExpand: Bool { hasOutput || hasBreakdown }
/// A host-run `git commit` — surfaced as a structured commit card (subject + Markdown body),
/// like the Mac, instead of only the generic "Commit changes" purpose.
private var commit: GitCommitSummary.Commit? { GitCommitSummary.parse(command) }
private var canExpand: Bool { hasOutput || hasBreakdown || commit != nil }
var body: some View {
VStack(alignment: .leading, spacing: 8) {
@@ -37,7 +40,11 @@ struct HostExecToolCard: View {
label: { header }
.buttonStyle(.plain)
if expanded {
if hasBreakdown, let parsed {
if let commit {
// The `$ command` is already shown in the header, so omit the disclosure here.
Divider().overlay(Color.primary.opacity(0.06))
GitCommitCard(commit: commit)
} else if hasBreakdown, let parsed {
Divider().overlay(Color.primary.opacity(0.06))
HostCommandBreakdown(summary: parsed, showPurpose: false)
}
@@ -14,7 +14,9 @@ struct ToolCallCard: View {
var body: some View {
let content = VStack(alignment: .leading, spacing: 8) {
Button { expanded.toggle() } label: { header }.buttonStyle(.plain)
Button { expanded.toggle() } label: { header }
.buttonStyle(.plain)
.hoverEffect(.highlight)
if expanded { details }
}
if inGroup {
@@ -43,9 +45,23 @@ struct ToolCallCard: View {
.contentShape(Rectangle())
}
/// Only shell tools carry a literal command worth structuring (a git commit); everything else
/// keeps the plain input block, so a non-shell input can't be misread as a commit.
private var isShellTool: Bool { group.name == "Bash" || group.name == "Shell" }
@ViewBuilder private var details: some View {
let input = group.input.approvalDetail
if !input.isEmpty {
// A git commit reads as a structured commit card (subject + Markdown body); a multi-step or
// destructive shell pipeline reads as a compact step list (deletes flagged in red) — both
// like the Mac, with the literal command one tap away under "Show command". Everything else
// keeps the plain input block.
if isShellTool, let commit = GitCommitSummary.parse(input) {
GitCommitCard(commit: commit, rawCommand: input)
} else if isShellTool, let summary = HostCommandSummary.summary(for: input),
summary.invocations.count > 1 || summary.isDestructive {
HostCommandBreakdown(summary: summary, showPurpose: false)
CommandDisclosure(command: input, accent: Palette.accent)
} else if !input.isEmpty {
ToolBlock(label: "Input", text: input, mono: true)
}
if let result = group.result {
@@ -84,6 +100,7 @@ struct ToolBlockCard: View {
VStack(alignment: .leading, spacing: 0) {
header
.contentShape(Rectangle())
.hoverEffect(.highlight)
.onTapGesture { withAnimation(.easeInOut(duration: 0.15)) { expanded.toggle() } }
if expanded {
ForEach(groups, id: \.toolCallID) { group in