From 7c3502f031b65c1ec8a2dd3a93c51683695ab015 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 13 Dec 2025 17:58:03 +0000 Subject: [PATCH] fix(ios): improve bridge discovery and pairing UX --- apps/ios/Sources/Bridge/BridgeClient.swift | 196 +++++++++++++----- .../Sources/Bridge/BridgeDiscoveryModel.swift | 9 +- .../ios/Sources/Bridge/BridgeEndpointID.swift | 11 +- apps/ios/Sources/Settings/SettingsTab.swift | 75 ++++--- src/gateway/server.ts | 12 ++ src/infra/machine-name.ts | 43 ++++ 6 files changed, 263 insertions(+), 83 deletions(-) create mode 100644 src/infra/machine-name.ts diff --git a/apps/ios/Sources/Bridge/BridgeClient.swift b/apps/ios/Sources/Bridge/BridgeClient.swift index 23ff59726..01a0d81dd 100644 --- a/apps/ios/Sources/Bridge/BridgeClient.swift +++ b/apps/ios/Sources/Bridge/BridgeClient.swift @@ -12,69 +12,85 @@ actor BridgeClient { displayName: String?, platform: String, version: String, - existingToken: String?) async throws -> String + existingToken: String?, + onStatus: (@Sendable (String) -> Void)? = nil) async throws -> String { let connection = NWConnection(to: endpoint, using: .tcp) let queue = DispatchQueue(label: "com.steipete.clawdis.ios.bridge-client") - connection.start(queue: queue) + defer { connection.cancel() } + try await self.withTimeout(seconds: 8, purpose: "connect") { + try await self.startAndWaitForReady(connection, queue: queue) + } - let token = existingToken + onStatus?("Authenticating…") try await self.send( BridgeHello( nodeId: nodeId, displayName: displayName, - token: token, + token: existingToken, platform: platform, version: version), over: connection) - if let line = try await self.receiveLine(over: connection), - let data = line.data(using: .utf8), - let base = try? self.decoder.decode(BridgeBaseFrame.self, from: data) - { - if base.type == "hello-ok" { - connection.cancel() - return existingToken ?? "" + var buffer = Data() + let first = try await self.withTimeout(seconds: 10, purpose: "hello") { () -> ReceivedFrame in + guard let frame = try await self.receiveFrame(over: connection, buffer: &buffer) else { + throw NSError(domain: "Bridge", code: 0, userInfo: [ + NSLocalizedDescriptionKey: "Bridge closed connection during hello", + ]) } - if base.type == "error" { - let err = try self.decoder.decode(BridgeErrorFrame.self, from: data) - if err.code == "NOT_PAIRED" || err.code == "UNAUTHORIZED" { - try await self.send( - BridgePairRequest( - nodeId: nodeId, - displayName: displayName, - platform: platform, - version: version), - over: connection) + return frame + } - while let next = try await self.receiveLine(over: connection) { - guard let nextData = next.data(using: .utf8) else { continue } - let nextBase = try self.decoder.decode(BridgeBaseFrame.self, from: nextData) - if nextBase.type == "pair-ok" { - let ok = try self.decoder.decode(BridgePairOk.self, from: nextData) - connection.cancel() - return ok.token - } - if nextBase.type == "error" { - let e = try self.decoder.decode(BridgeErrorFrame.self, from: nextData) - connection.cancel() - throw NSError(domain: "Bridge", code: 2, userInfo: [ - NSLocalizedDescriptionKey: "\(e.code): \(e.message)", - ]) - } - } - } - connection.cancel() + switch first.base.type { + case "hello-ok": + // We only return a token if we have one; callers should treat empty as "no token yet". + return existingToken ?? "" + + case "error": + let err = try self.decoder.decode(BridgeErrorFrame.self, from: first.data) + if err.code != "NOT_PAIRED", err.code != "UNAUTHORIZED" { throw NSError(domain: "Bridge", code: 1, userInfo: [ NSLocalizedDescriptionKey: "\(err.code): \(err.message)", ]) } - } - connection.cancel() - throw NSError(domain: "Bridge", code: 0, userInfo: [ - NSLocalizedDescriptionKey: "Unexpected bridge response", - ]) + onStatus?("Requesting approval…") + try await self.send( + BridgePairRequest( + nodeId: nodeId, + displayName: displayName, + platform: platform, + version: version), + over: connection) + + onStatus?("Waiting for approval…") + let ok = try await self.withTimeout(seconds: 60, purpose: "pairing approval") { + while let next = try await self.receiveFrame(over: connection, buffer: &buffer) { + switch next.base.type { + case "pair-ok": + return try self.decoder.decode(BridgePairOk.self, from: next.data) + case "error": + let e = try self.decoder.decode(BridgeErrorFrame.self, from: next.data) + throw NSError(domain: "Bridge", code: 2, userInfo: [ + NSLocalizedDescriptionKey: "\(e.code): \(e.message)", + ]) + default: + continue + } + } + throw NSError(domain: "Bridge", code: 3, userInfo: [ + NSLocalizedDescriptionKey: "Pairing failed: bridge closed connection", + ]) + } + + return ok.token + + default: + throw NSError(domain: "Bridge", code: 0, userInfo: [ + NSLocalizedDescriptionKey: "Unexpected bridge response", + ]) + } } private func send(_ obj: some Encodable, over connection: NWConnection) async throws { @@ -89,18 +105,17 @@ actor BridgeClient { } } - private func receiveLine(over connection: NWConnection) async throws -> String? { - var buffer = Data() - while true { - if let idx = buffer.firstIndex(of: 0x0A) { - let lineData = buffer.prefix(upTo: idx) - return String(data: lineData, encoding: .utf8) - } + private struct ReceivedFrame { + var base: BridgeBaseFrame + var data: Data + } - let chunk = try await self.receiveChunk(over: connection) - if chunk.isEmpty { return nil } - buffer.append(chunk) + private func receiveFrame(over connection: NWConnection, buffer: inout Data) async throws -> ReceivedFrame? { + guard let lineData = try await self.receiveLineData(over: connection, buffer: &buffer) else { + return nil } + let base = try self.decoder.decode(BridgeBaseFrame.self, from: lineData) + return ReceivedFrame(base: base, data: lineData) } private func receiveChunk(over connection: NWConnection) async throws -> Data { @@ -118,4 +133,77 @@ actor BridgeClient { } } } + + private func receiveLineData(over connection: NWConnection, buffer: inout Data) async throws -> Data? { + while true { + if let idx = buffer.firstIndex(of: 0x0A) { + let line = buffer.prefix(upTo: idx) + buffer.removeSubrange(...idx) + return Data(line) + } + + let chunk = try await self.receiveChunk(over: connection) + if chunk.isEmpty { return nil } + buffer.append(chunk) + } + } + + private struct TimeoutError: LocalizedError, Sendable { + var purpose: String + var seconds: Int + + var errorDescription: String? { + if self.purpose == "pairing approval" { + return "Timed out waiting for approval (\(self.seconds)s). Approve the node on your gateway and try again." + } + return "Timed out during \(self.purpose) (\(self.seconds)s)." + } + } + + private func withTimeout( + seconds: Int, + purpose: String, + _ op: @escaping @Sendable () async throws -> T) async throws -> T + { + try await withThrowingTaskGroup(of: T.self) { group in + group.addTask { + try await op() + } + group.addTask { + try await Task.sleep(nanoseconds: UInt64(seconds) * 1_000_000_000) + throw TimeoutError(purpose: purpose, seconds: seconds) + } + let result = try await group.next()! + group.cancelAll() + return result + } + } + + private func startAndWaitForReady(_ connection: NWConnection, queue: DispatchQueue) async throws { + try await withCheckedThrowingContinuation(isolation: nil) { (cont: CheckedContinuation) in + var didResume = false + connection.stateUpdateHandler = { state in + if didResume { return } + switch state { + case .ready: + didResume = true + cont.resume(returning: ()) + case let .failed(err): + didResume = true + cont.resume(throwing: err) + case let .waiting(err): + didResume = true + cont.resume(throwing: err) + case .cancelled: + didResume = true + cont.resume(throwing: NSError(domain: "Bridge", code: 50, userInfo: [ + NSLocalizedDescriptionKey: "Connection cancelled", + ])) + default: + break + } + } + connection.start(queue: queue) + } + } } diff --git a/apps/ios/Sources/Bridge/BridgeDiscoveryModel.swift b/apps/ios/Sources/Bridge/BridgeDiscoveryModel.swift index cf47b88a4..3229db54d 100644 --- a/apps/ios/Sources/Bridge/BridgeDiscoveryModel.swift +++ b/apps/ios/Sources/Bridge/BridgeDiscoveryModel.swift @@ -52,8 +52,9 @@ final class BridgeDiscoveryModel: ObservableObject { switch result.endpoint { case let .service(name, _, _, _): let decodedName = BonjourEscapes.decode(name) + let prettyName = Self.prettifyInstanceName(decodedName) return DiscoveredBridge( - name: decodedName, + name: prettyName, endpoint: result.endpoint, stableID: BridgeEndpointID.stableID(result.endpoint), debugID: BridgeEndpointID.prettyDescription(result.endpoint)) @@ -75,4 +76,10 @@ final class BridgeDiscoveryModel: ObservableObject { self.bridges = [] self.statusText = "Stopped" } + + private static func prettifyInstanceName(_ decodedName: String) -> String { + let normalized = decodedName.split(whereSeparator: \.isWhitespace).joined(separator: " ") + let stripped = normalized.replacingOccurrences(of: " (Clawdis)", with: "") + return stripped.trimmingCharacters(in: .whitespacesAndNewlines) + } } diff --git a/apps/ios/Sources/Bridge/BridgeEndpointID.swift b/apps/ios/Sources/Bridge/BridgeEndpointID.swift index e5662f433..0bb07fb34 100644 --- a/apps/ios/Sources/Bridge/BridgeEndpointID.swift +++ b/apps/ios/Sources/Bridge/BridgeEndpointID.swift @@ -6,8 +6,9 @@ enum BridgeEndpointID { static func stableID(_ endpoint: NWEndpoint) -> String { switch endpoint { case let .service(name, type, domain, _): - // Keep this stable across encode/decode differences; use raw service tuple. - "\(type)|\(domain)|\(name)" + // Keep this stable across encode/decode differences (e.g. `\032` for spaces). + let normalizedName = Self.normalizeServiceNameForID(name) + return "\(type)|\(domain)|\(normalizedName)" default: String(describing: endpoint) } @@ -16,4 +17,10 @@ enum BridgeEndpointID { static func prettyDescription(_ endpoint: NWEndpoint) -> String { BonjourEscapes.decode(String(describing: endpoint)) } + + private static func normalizeServiceNameForID(_ rawName: String) -> String { + let decoded = BonjourEscapes.decode(rawName) + let normalized = decoded.split(whereSeparator: \.isWhitespace).joined(separator: " ") + return normalized.trimmingCharacters(in: .whitespacesAndNewlines) + } } diff --git a/apps/ios/Sources/Settings/SettingsTab.swift b/apps/ios/Sources/Settings/SettingsTab.swift index 1a07f731c..00d4d3503 100644 --- a/apps/ios/Sources/Settings/SettingsTab.swift +++ b/apps/ios/Sources/Settings/SettingsTab.swift @@ -6,9 +6,10 @@ struct SettingsTab: View { @AppStorage("node.displayName") private var displayName: String = "iOS Node" @AppStorage("node.instanceId") private var instanceId: String = UUID().uuidString @AppStorage("voiceWake.enabled") private var voiceWakeEnabled: Bool = false + @AppStorage("bridge.preferredStableID") private var preferredBridgeStableID: String = "" @StateObject private var discovery = BridgeDiscoveryModel() @State private var connectStatus: String? - @State private var isConnecting = false + @State private var connectingBridgeID: String? @State private var didAutoConnect = false var body: some View { @@ -74,11 +75,12 @@ struct SettingsTab: View { service: "com.steipete.clawdis.bridge", account: self.keychainAccount()) guard let existing, !existing.isEmpty else { return } - guard let first = newValue.first else { return } + guard let target = self.pickAutoConnectBridge(from: newValue) else { return } self.didAutoConnect = true + self.preferredBridgeStableID = target.stableID self.appModel.connectToBridge( - endpoint: first.endpoint, + endpoint: target.endpoint, token: existing, nodeId: self.instanceId, displayName: self.displayName, @@ -120,10 +122,17 @@ struct SettingsTab: View { } Spacer() - Button(self.isConnecting ? "…" : "Connect") { + Button { Task { await self.connect(bridge) } + } label: { + if self.connectingBridgeID == bridge.id { + ProgressView() + .progressViewStyle(.circular) + } else { + Text("Connect") + } } - .disabled(self.isConnecting) + .disabled(self.connectingBridgeID != nil) } } } @@ -149,31 +158,36 @@ struct SettingsTab: View { } private func connect(_ bridge: BridgeDiscoveryModel.DiscoveredBridge) async { - self.isConnecting = true - defer { self.isConnecting = false } + self.connectingBridgeID = bridge.id + self.preferredBridgeStableID = bridge.stableID + defer { self.connectingBridgeID = nil } - let existing = KeychainStore.loadString(service: "com.steipete.clawdis.bridge", account: self.keychainAccount()) do { - let token: String - if let existing, !existing.isEmpty { - token = existing - } else { - let newToken = try await BridgeClient().pairAndHello( - endpoint: bridge.endpoint, - nodeId: self.instanceId, - displayName: self.displayName, - platform: self.platformString(), - version: self.appVersion(), - existingToken: nil) - guard !newToken.isEmpty else { - self.connectStatus = "Pairing failed: empty token" - return - } + let existing = KeychainStore.loadString( + service: "com.steipete.clawdis.bridge", + account: self.keychainAccount()) + let existingToken = (existing?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false) ? + existing : + nil + + let token = try await BridgeClient().pairAndHello( + endpoint: bridge.endpoint, + nodeId: self.instanceId, + displayName: self.displayName, + platform: self.platformString(), + version: self.appVersion(), + existingToken: existingToken, + onStatus: { status in + Task { @MainActor in + self.connectStatus = status + } + }) + + if !token.isEmpty, token != existingToken { _ = KeychainStore.saveString( - newToken, + token, service: "com.steipete.clawdis.bridge", account: self.keychainAccount()) - token = newToken } self.appModel.connectToBridge( @@ -184,9 +198,18 @@ struct SettingsTab: View { platform: self.platformString(), version: self.appVersion()) - self.connectStatus = "Connected" } catch { self.connectStatus = "Failed: \(error.localizedDescription)" } } + + private func pickAutoConnectBridge(from bridges: [BridgeDiscoveryModel.DiscoveredBridge]) -> BridgeDiscoveryModel + .DiscoveredBridge? { + if !self.preferredBridgeStableID.isEmpty, + let match = bridges.first(where: { $0.stableID == self.preferredBridgeStableID }) + { + return match + } + return bridges.first + } } diff --git a/src/gateway/server.ts b/src/gateway/server.ts index d1d8cca9d..dc4a1ff54 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -47,6 +47,7 @@ import { getLastHeartbeatEvent, onHeartbeatEvent, } from "../infra/heartbeat-events.js"; +import { getMachineDisplayName } from "../infra/machine-name.js"; import { approveNodePairing, listNodePairing, @@ -118,6 +119,13 @@ type Client = { presenceKey?: string; }; +function formatBonjourInstanceName(displayName: string) { + const trimmed = displayName.trim(); + if (!trimmed) return "Clawdis"; + if (/clawdis/i.test(trimmed)) return trimmed; + return `${trimmed} (Clawdis)`; +} + type GatewaySessionsDefaults = { model: string | null; contextTokens: number | null; @@ -797,11 +805,14 @@ export async function startGatewayServer( } }; + const machineDisplayName = await getMachineDisplayName(); + if (bridgeEnabled && bridgePort > 0) { try { const started = await startNodeBridgeServer({ host: bridgeHost, port: bridgePort, + serverName: machineDisplayName, onAuthenticated: (node) => { const host = node.displayName?.trim() || node.nodeId; const ip = node.remoteIp?.trim(); @@ -887,6 +898,7 @@ export async function startGatewayServer( tailnetDnsEnv && tailnetDnsEnv.length > 0 ? tailnetDnsEnv : undefined; const bonjour = await startGatewayBonjourAdvertiser({ + instanceName: formatBonjourInstanceName(machineDisplayName), gatewayPort: port, bridgePort: bridge?.port, sshPort, diff --git a/src/infra/machine-name.ts b/src/infra/machine-name.ts new file mode 100644 index 000000000..3ff8a7bbc --- /dev/null +++ b/src/infra/machine-name.ts @@ -0,0 +1,43 @@ +import { execFile } from "node:child_process"; +import os from "node:os"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); + +let cachedPromise: Promise | null = null; + +async function tryScutil(key: "ComputerName" | "LocalHostName") { + try { + const { stdout } = await execFileAsync("/usr/sbin/scutil", ["--get", key], { + timeout: 1000, + windowsHide: true, + }); + const value = String(stdout ?? "").trim(); + return value.length > 0 ? value : null; + } catch { + return null; + } +} + +function fallbackHostName() { + return ( + os + .hostname() + .replace(/\.local$/i, "") + .trim() || "clawdis" + ); +} + +export async function getMachineDisplayName(): Promise { + if (cachedPromise) return cachedPromise; + cachedPromise = (async () => { + if (process.platform === "darwin") { + const computerName = await tryScutil("ComputerName"); + if (computerName) return computerName; + const localHostName = await tryScutil("LocalHostName"); + if (localHostName) return localHostName; + } + return fallbackHostName(); + })(); + return cachedPromise; +}