fix(ios): improve bridge discovery and pairing UX

This commit is contained in:
Peter Steinberger
2025-12-13 17:58:03 +00:00
parent 61ab07ced3
commit 7c3502f031
6 changed files with 263 additions and 83 deletions

View File

@@ -12,69 +12,85 @@ actor BridgeClient {
displayName: String?, displayName: String?,
platform: String, platform: String,
version: 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 connection = NWConnection(to: endpoint, using: .tcp)
let queue = DispatchQueue(label: "com.steipete.clawdis.ios.bridge-client") 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( try await self.send(
BridgeHello( BridgeHello(
nodeId: nodeId, nodeId: nodeId,
displayName: displayName, displayName: displayName,
token: token, token: existingToken,
platform: platform, platform: platform,
version: version), version: version),
over: connection) over: connection)
if let line = try await self.receiveLine(over: connection), var buffer = Data()
let data = line.data(using: .utf8), let first = try await self.withTimeout(seconds: 10, purpose: "hello") { () -> ReceivedFrame in
let base = try? self.decoder.decode(BridgeBaseFrame.self, from: data) guard let frame = try await self.receiveFrame(over: connection, buffer: &buffer) else {
{ throw NSError(domain: "Bridge", code: 0, userInfo: [
if base.type == "hello-ok" { NSLocalizedDescriptionKey: "Bridge closed connection during hello",
connection.cancel() ])
return existingToken ?? ""
} }
if base.type == "error" { return frame
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)
while let next = try await self.receiveLine(over: connection) { switch first.base.type {
guard let nextData = next.data(using: .utf8) else { continue } case "hello-ok":
let nextBase = try self.decoder.decode(BridgeBaseFrame.self, from: nextData) // We only return a token if we have one; callers should treat empty as "no token yet".
if nextBase.type == "pair-ok" { return existingToken ?? ""
let ok = try self.decoder.decode(BridgePairOk.self, from: nextData)
connection.cancel() case "error":
return ok.token let err = try self.decoder.decode(BridgeErrorFrame.self, from: first.data)
} if err.code != "NOT_PAIRED", err.code != "UNAUTHORIZED" {
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()
throw NSError(domain: "Bridge", code: 1, userInfo: [ throw NSError(domain: "Bridge", code: 1, userInfo: [
NSLocalizedDescriptionKey: "\(err.code): \(err.message)", NSLocalizedDescriptionKey: "\(err.code): \(err.message)",
]) ])
} }
}
connection.cancel() onStatus?("Requesting approval…")
throw NSError(domain: "Bridge", code: 0, userInfo: [ try await self.send(
NSLocalizedDescriptionKey: "Unexpected bridge response", 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 { 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? { private struct ReceivedFrame {
var buffer = Data() var base: BridgeBaseFrame
while true { var data: Data
if let idx = buffer.firstIndex(of: 0x0A) { }
let lineData = buffer.prefix(upTo: idx)
return String(data: lineData, encoding: .utf8)
}
let chunk = try await self.receiveChunk(over: connection) private func receiveFrame(over connection: NWConnection, buffer: inout Data) async throws -> ReceivedFrame? {
if chunk.isEmpty { return nil } guard let lineData = try await self.receiveLineData(over: connection, buffer: &buffer) else {
buffer.append(chunk) 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 { 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<T>(
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<Void, Error>) 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)
}
}
} }

View File

@@ -52,8 +52,9 @@ final class BridgeDiscoveryModel: ObservableObject {
switch result.endpoint { switch result.endpoint {
case let .service(name, _, _, _): case let .service(name, _, _, _):
let decodedName = BonjourEscapes.decode(name) let decodedName = BonjourEscapes.decode(name)
let prettyName = Self.prettifyInstanceName(decodedName)
return DiscoveredBridge( return DiscoveredBridge(
name: decodedName, name: prettyName,
endpoint: result.endpoint, endpoint: result.endpoint,
stableID: BridgeEndpointID.stableID(result.endpoint), stableID: BridgeEndpointID.stableID(result.endpoint),
debugID: BridgeEndpointID.prettyDescription(result.endpoint)) debugID: BridgeEndpointID.prettyDescription(result.endpoint))
@@ -75,4 +76,10 @@ final class BridgeDiscoveryModel: ObservableObject {
self.bridges = [] self.bridges = []
self.statusText = "Stopped" 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)
}
} }

View File

@@ -6,8 +6,9 @@ enum BridgeEndpointID {
static func stableID(_ endpoint: NWEndpoint) -> String { static func stableID(_ endpoint: NWEndpoint) -> String {
switch endpoint { switch endpoint {
case let .service(name, type, domain, _): case let .service(name, type, domain, _):
// Keep this stable across encode/decode differences; use raw service tuple. // Keep this stable across encode/decode differences (e.g. `\032` for spaces).
"\(type)|\(domain)|\(name)" let normalizedName = Self.normalizeServiceNameForID(name)
return "\(type)|\(domain)|\(normalizedName)"
default: default:
String(describing: endpoint) String(describing: endpoint)
} }
@@ -16,4 +17,10 @@ enum BridgeEndpointID {
static func prettyDescription(_ endpoint: NWEndpoint) -> String { static func prettyDescription(_ endpoint: NWEndpoint) -> String {
BonjourEscapes.decode(String(describing: endpoint)) 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)
}
} }

View File

@@ -6,9 +6,10 @@ struct SettingsTab: View {
@AppStorage("node.displayName") private var displayName: String = "iOS Node" @AppStorage("node.displayName") private var displayName: String = "iOS Node"
@AppStorage("node.instanceId") private var instanceId: String = UUID().uuidString @AppStorage("node.instanceId") private var instanceId: String = UUID().uuidString
@AppStorage("voiceWake.enabled") private var voiceWakeEnabled: Bool = false @AppStorage("voiceWake.enabled") private var voiceWakeEnabled: Bool = false
@AppStorage("bridge.preferredStableID") private var preferredBridgeStableID: String = ""
@StateObject private var discovery = BridgeDiscoveryModel() @StateObject private var discovery = BridgeDiscoveryModel()
@State private var connectStatus: String? @State private var connectStatus: String?
@State private var isConnecting = false @State private var connectingBridgeID: String?
@State private var didAutoConnect = false @State private var didAutoConnect = false
var body: some View { var body: some View {
@@ -74,11 +75,12 @@ struct SettingsTab: View {
service: "com.steipete.clawdis.bridge", service: "com.steipete.clawdis.bridge",
account: self.keychainAccount()) account: self.keychainAccount())
guard let existing, !existing.isEmpty else { return } 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.didAutoConnect = true
self.preferredBridgeStableID = target.stableID
self.appModel.connectToBridge( self.appModel.connectToBridge(
endpoint: first.endpoint, endpoint: target.endpoint,
token: existing, token: existing,
nodeId: self.instanceId, nodeId: self.instanceId,
displayName: self.displayName, displayName: self.displayName,
@@ -120,10 +122,17 @@ struct SettingsTab: View {
} }
Spacer() Spacer()
Button(self.isConnecting ? "" : "Connect") { Button {
Task { await self.connect(bridge) } 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 { private func connect(_ bridge: BridgeDiscoveryModel.DiscoveredBridge) async {
self.isConnecting = true self.connectingBridgeID = bridge.id
defer { self.isConnecting = false } self.preferredBridgeStableID = bridge.stableID
defer { self.connectingBridgeID = nil }
let existing = KeychainStore.loadString(service: "com.steipete.clawdis.bridge", account: self.keychainAccount())
do { do {
let token: String let existing = KeychainStore.loadString(
if let existing, !existing.isEmpty { service: "com.steipete.clawdis.bridge",
token = existing account: self.keychainAccount())
} else { let existingToken = (existing?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false) ?
let newToken = try await BridgeClient().pairAndHello( existing :
endpoint: bridge.endpoint, nil
nodeId: self.instanceId,
displayName: self.displayName, let token = try await BridgeClient().pairAndHello(
platform: self.platformString(), endpoint: bridge.endpoint,
version: self.appVersion(), nodeId: self.instanceId,
existingToken: nil) displayName: self.displayName,
guard !newToken.isEmpty else { platform: self.platformString(),
self.connectStatus = "Pairing failed: empty token" version: self.appVersion(),
return existingToken: existingToken,
} onStatus: { status in
Task { @MainActor in
self.connectStatus = status
}
})
if !token.isEmpty, token != existingToken {
_ = KeychainStore.saveString( _ = KeychainStore.saveString(
newToken, token,
service: "com.steipete.clawdis.bridge", service: "com.steipete.clawdis.bridge",
account: self.keychainAccount()) account: self.keychainAccount())
token = newToken
} }
self.appModel.connectToBridge( self.appModel.connectToBridge(
@@ -184,9 +198,18 @@ struct SettingsTab: View {
platform: self.platformString(), platform: self.platformString(),
version: self.appVersion()) version: self.appVersion())
self.connectStatus = "Connected"
} catch { } catch {
self.connectStatus = "Failed: \(error.localizedDescription)" 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
}
} }

View File

@@ -47,6 +47,7 @@ import {
getLastHeartbeatEvent, getLastHeartbeatEvent,
onHeartbeatEvent, onHeartbeatEvent,
} from "../infra/heartbeat-events.js"; } from "../infra/heartbeat-events.js";
import { getMachineDisplayName } from "../infra/machine-name.js";
import { import {
approveNodePairing, approveNodePairing,
listNodePairing, listNodePairing,
@@ -118,6 +119,13 @@ type Client = {
presenceKey?: string; 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 = { type GatewaySessionsDefaults = {
model: string | null; model: string | null;
contextTokens: number | null; contextTokens: number | null;
@@ -797,11 +805,14 @@ export async function startGatewayServer(
} }
}; };
const machineDisplayName = await getMachineDisplayName();
if (bridgeEnabled && bridgePort > 0) { if (bridgeEnabled && bridgePort > 0) {
try { try {
const started = await startNodeBridgeServer({ const started = await startNodeBridgeServer({
host: bridgeHost, host: bridgeHost,
port: bridgePort, port: bridgePort,
serverName: machineDisplayName,
onAuthenticated: (node) => { onAuthenticated: (node) => {
const host = node.displayName?.trim() || node.nodeId; const host = node.displayName?.trim() || node.nodeId;
const ip = node.remoteIp?.trim(); const ip = node.remoteIp?.trim();
@@ -887,6 +898,7 @@ export async function startGatewayServer(
tailnetDnsEnv && tailnetDnsEnv.length > 0 ? tailnetDnsEnv : undefined; tailnetDnsEnv && tailnetDnsEnv.length > 0 ? tailnetDnsEnv : undefined;
const bonjour = await startGatewayBonjourAdvertiser({ const bonjour = await startGatewayBonjourAdvertiser({
instanceName: formatBonjourInstanceName(machineDisplayName),
gatewayPort: port, gatewayPort: port,
bridgePort: bridge?.port, bridgePort: bridge?.port,
sshPort, sshPort,

43
src/infra/machine-name.ts Normal file
View File

@@ -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<string> | 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<string> {
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;
}