Mesh P3: wire RemoteStore onto the [HostID: HostConnection] multiplexer
Completes the multiplexer rewire begun in f335eb4. RemoteStore no longer runs an inline single connection — it owns `connections: [HostID: HostConnection]` and connects every paired Mac at once, so all your Macs are live simultaneously. - reconnect() connects/reconnects all paired hosts (dropping since-unpaired ones); pair() adds a new connection and makes it active while the others keep running; unpair() drops the active Mac and switches to a remaining one. - The flat @Published state (connectivity/sessions/dashboard/capabilities/…) mirrors the *active* connection via callbacks, so switchHost() is now instant — every Mac is already connected, so it just re-points at the target's live state (no reconnect). - Aggregate concerns merge across hosts: the app-icon badge counts needs-you across ALL Macs, Live Activity summarizes all live sessions, and an approval answered from a notification is broadcast to every connection (the owner resolves it; the notification carries no hostID yet). Intents (send / open / approvals) route to the active connection. Removed ~450 lines of connection machine from RemoteStore (now in HostConnection); kept friendlyTransportError/showError/dismissError. Demo bypasses connections (seeds state directly). Compiles (iOS Simulator BUILD SUCCEEDED) and the demo host switcher is runtime-verified (screenshots: switching Macs swaps the session list + tab badge). The LIVE multi-connection path — simultaneous dials, reconnect/teardown, the shared embedded tailnet node — is compile-verified only; it needs two real Macs to validate. Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
@@ -101,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()
|
||||
@@ -114,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"
|
||||
@@ -153,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 —
|
||||
@@ -205,8 +208,9 @@ final class RemoteStore: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
/// Switch which paired Mac is active. Live: re-point the (single) connection via the existing
|
||||
/// reconnect machinery. Demo: swap the mock host's projection, preserving in-demo edits.
|
||||
/// 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 {
|
||||
@@ -217,15 +221,17 @@ final class RemoteStore: ObservableObject {
|
||||
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
|
||||
reconnect()
|
||||
// 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()
|
||||
}
|
||||
|
||||
/// The paired Mac the live connection targets — the explicitly-active one, else the most-recent.
|
||||
private func activeHost() -> PairedHost? {
|
||||
if let id = activeHostID, let host = IdentityStore.pairedHost(id: id) { return host }
|
||||
return IdentityStore.pairedHosts().last
|
||||
}
|
||||
|
||||
/// Mock hosts for the multi-host demo. Each carries its own name + sessions + dashboard;
|
||||
@@ -405,253 +411,162 @@ final class RemoteStore: ObservableObject {
|
||||
""")
|
||||
}
|
||||
|
||||
/// 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
|
||||
activeHostID = payload.hostStaticKey.fingerprintHex
|
||||
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.
|
||||
func reconnect() {
|
||||
guard let host = activeHost() else { connectivity = .unpaired; return }
|
||||
teardown()
|
||||
activeHostID = host.fingerprint
|
||||
connectivity = reconnectAttempts == 0 ? .connecting : .reconnecting
|
||||
hostName = host.hostName
|
||||
guard host.transportHint != .relay else {
|
||||
connectivity = .failed("Relay connections aren't supported yet.")
|
||||
return
|
||||
}
|
||||
let candidates = buildCandidates(
|
||||
fingerprint: host.fingerprint,
|
||||
lanHost: host.lanHost, lanPort: host.lanPort,
|
||||
tailnet: host.transportHint == .tailnet ? (host.tailnetHost, host.tailnetPort) : nil)
|
||||
guard !candidates.isEmpty else {
|
||||
connectivity = .hostOffline
|
||||
scheduleRetry()
|
||||
return
|
||||
}
|
||||
connectPlan = ConnectPlan(
|
||||
remaining: candidates, hostStaticKey: host.hostStaticKey,
|
||||
mode: .reconnect, deviceID: host.deviceID, pairingPayload: nil)
|
||||
_ = tryNextCandidate()
|
||||
/// 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
|
||||
}
|
||||
|
||||
/// 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
|
||||
private func makeConnection(hostID: String, hostName: String) -> HostConnection {
|
||||
HostConnection(
|
||||
hostID: hostID, hostName: hostName, identity: identity, discovery: discovery,
|
||||
callbacks: callbacks(for: hostID))
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// 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 }
|
||||
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)
|
||||
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
|
||||
}
|
||||
|
||||
/// 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()
|
||||
}
|
||||
/// 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
|
||||
}
|
||||
|
||||
|
||||
/// 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)
|
||||
/// 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 }
|
||||
}
|
||||
|
||||
/// 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)
|
||||
/// 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)
|
||||
}
|
||||
|
||||
/// 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())
|
||||
/// 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() {
|
||||
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) }
|
||||
}
|
||||
if activeHostID == nil || connections[activeHostID!] == nil {
|
||||
activeHostID = hosts.last?.fingerprint
|
||||
}
|
||||
mirrorActive()
|
||||
refreshAggregate()
|
||||
}
|
||||
|
||||
|
||||
func unpair() {
|
||||
teardown()
|
||||
// Forget the *active* Mac specifically (the switcher may have made it not the last).
|
||||
if let id = activeHostID { IdentityStore.removePairedHost(id: id) } else { 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 rather than going fully unpaired.
|
||||
if IdentityStore.pairedHosts().last != 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
|
||||
}
|
||||
@@ -668,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()
|
||||
}
|
||||
|
||||
@@ -708,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)))
|
||||
}
|
||||
|
||||
@@ -794,6 +710,7 @@ final class RemoteStore: ObservableObject {
|
||||
send(.unsubscribe(target))
|
||||
markOpened(target) // everything up to now has been seen
|
||||
guard openSessionID == target else { return }
|
||||
activeConnection?.openSessionID = nil
|
||||
openSessionID = nil
|
||||
openEvents = []
|
||||
openApprovals = []
|
||||
@@ -864,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)
|
||||
@@ -1053,26 +972,6 @@ 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).
|
||||
@@ -1087,150 +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 .peerList(let peers):
|
||||
// Mesh P4: the host's known peers, for the (future) mesh device list and transfer
|
||||
// picker. Recorded now so multi-host UI can render it; single-host builds ignore it.
|
||||
meshPeers = peers
|
||||
case .transferAccept, .transferReject, .transferReady, .transferCommitted, .transferChunkAck:
|
||||
// Session-transfer replies (mesh P5) only reach a *source* Mac driving a transfer; a
|
||||
// phone is never a transfer source, so these are inert here.
|
||||
break
|
||||
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?) {
|
||||
@@ -1249,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 {
|
||||
|
||||
Reference in New Issue
Block a user