diff --git a/apps/ios/Sources/Bridge/BridgeClient.swift b/apps/ios/Sources/Bridge/BridgeClient.swift deleted file mode 100644 index f880e6896..000000000 --- a/apps/ios/Sources/Bridge/BridgeClient.swift +++ /dev/null @@ -1,244 +0,0 @@ -import ClawdbotKit -import Foundation -import Network - -actor BridgeClient { - private let encoder = JSONEncoder() - private let decoder = JSONDecoder() - private var lineBuffer = Data() - - func pairAndHello( - endpoint: NWEndpoint, - hello: BridgeHello, - tls: BridgeTLSParams? = nil, - onStatus: (@Sendable (String) -> Void)? = nil) async throws -> String - { - do { - return try await self.pairAndHelloOnce( - endpoint: endpoint, - hello: hello, - tls: tls, - onStatus: onStatus) - } catch { - if let tls, !tls.required { - return try await self.pairAndHelloOnce( - endpoint: endpoint, - hello: hello, - tls: nil, - onStatus: onStatus) - } - throw error - } - } - - private func pairAndHelloOnce( - endpoint: NWEndpoint, - hello: BridgeHello, - tls: BridgeTLSParams?, - onStatus: (@Sendable (String) -> Void)? = nil) async throws -> String - { - self.lineBuffer = Data() - let params = self.makeParameters(tls: tls) - let connection = NWConnection(to: endpoint, using: params) - let queue = DispatchQueue(label: "com.clawdbot.ios.bridge-client") - defer { connection.cancel() } - try await self.withTimeout(seconds: 8, purpose: "connect") { - try await self.startAndWaitForReady(connection, queue: queue) - } - - onStatus?("Authenticating…") - try await self.send(hello, over: connection) - - let first = try await self.withTimeout(seconds: 10, purpose: "hello") { () -> ReceivedFrame in - guard let frame = try await self.receiveFrame(over: connection) else { - throw NSError(domain: "Bridge", code: 0, userInfo: [ - NSLocalizedDescriptionKey: "Bridge closed connection during hello", - ]) - } - return frame - } - - 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 hello.token ?? "" - - 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)", - ]) - } - - onStatus?("Requesting approval…") - try await self.send( - BridgePairRequest( - nodeId: hello.nodeId, - displayName: hello.displayName, - platform: hello.platform, - version: hello.version, - deviceFamily: hello.deviceFamily, - modelIdentifier: hello.modelIdentifier, - caps: hello.caps, - commands: hello.commands), - 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) { - 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 { - let data = try self.encoder.encode(obj) - var line = Data() - line.append(data) - line.append(0x0A) - try await withCheckedThrowingContinuation(isolation: nil) { (cont: CheckedContinuation) in - connection.send(content: line, completion: .contentProcessed { err in - if let err { cont.resume(throwing: err) } else { cont.resume(returning: ()) } - }) - } - } - - private struct ReceivedFrame { - var base: BridgeBaseFrame - var data: Data - } - - private func receiveFrame(over connection: NWConnection) async throws -> ReceivedFrame? { - guard let lineData = try await self.receiveLineData(over: connection) 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 { - try await withCheckedThrowingContinuation(isolation: nil) { (cont: CheckedContinuation) in - connection.receive(minimumIncompleteLength: 1, maximumLength: 64 * 1024) { data, _, isComplete, error in - if let error { - cont.resume(throwing: error) - return - } - if isComplete { - cont.resume(returning: Data()) - return - } - cont.resume(returning: data ?? Data()) - } - } - } - - private func receiveLineData(over connection: NWConnection) async throws -> Data? { - while true { - if let idx = self.lineBuffer.firstIndex(of: 0x0A) { - let line = self.lineBuffer.prefix(upTo: idx) - self.lineBuffer.removeSubrange(...idx) - return Data(line) - } - - let chunk = try await self.receiveChunk(over: connection) - if chunk.isEmpty { return nil } - self.lineBuffer.append(chunk) - } - } - - private func makeParameters(tls: BridgeTLSParams?) -> NWParameters { - if let tlsOptions = makeBridgeTLSOptions(tls) { - let tcpOptions = NWProtocolTCP.Options() - let params = NWParameters(tls: tlsOptions, tcp: tcpOptions) - params.includePeerToPeer = true - return params - } - let params = NWParameters.tcp - params.includePeerToPeer = true - return params - } - - 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 AsyncTimeout.withTimeout( - seconds: Double(seconds), - onTimeout: { TimeoutError(purpose: purpose, seconds: seconds) }, - operation: op) - } - - private func startAndWaitForReady(_ connection: NWConnection, queue: DispatchQueue) async throws { - try await withCheckedThrowingContinuation(isolation: nil) { (cont: CheckedContinuation) in - final class ResumeFlag: @unchecked Sendable { - private let lock = NSLock() - private var value = false - - func trySet() -> Bool { - self.lock.lock() - defer { self.lock.unlock() } - if self.value { return false } - self.value = true - return true - } - } - let didResume = ResumeFlag() - connection.stateUpdateHandler = { state in - switch state { - case .ready: - if didResume.trySet() { cont.resume(returning: ()) } - case let .failed(err): - if didResume.trySet() { cont.resume(throwing: err) } - case let .waiting(err): - if didResume.trySet() { cont.resume(throwing: err) } - case .cancelled: - if didResume.trySet() { - 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/BridgeEndpointID.swift b/apps/ios/Sources/Bridge/BridgeEndpointID.swift deleted file mode 100644 index 40cb9fee1..000000000 --- a/apps/ios/Sources/Bridge/BridgeEndpointID.swift +++ /dev/null @@ -1,26 +0,0 @@ -import ClawdbotKit -import Foundation -import Network - -enum BridgeEndpointID { - static func stableID(_ endpoint: NWEndpoint) -> String { - switch endpoint { - case let .service(name, type, domain, _): - // Keep this stable across encode/decode differences (e.g. `\032` for spaces). - let normalizedName = Self.normalizeServiceNameForID(name) - return "\(type)|\(domain)|\(normalizedName)" - default: - return String(describing: endpoint) - } - } - - 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/Bridge/BridgeSession.swift b/apps/ios/Sources/Bridge/BridgeSession.swift deleted file mode 100644 index adc3e0c00..000000000 --- a/apps/ios/Sources/Bridge/BridgeSession.swift +++ /dev/null @@ -1,422 +0,0 @@ -import ClawdbotKit -import Foundation -import Network - -actor BridgeSession { - private struct TimeoutError: LocalizedError { - var message: String - var errorDescription: String? { self.message } - } - - enum State: Sendable, Equatable { - case idle - case connecting - case connected(serverName: String) - case failed(message: String) - } - - private let encoder = JSONEncoder() - private let decoder = JSONDecoder() - - private var connection: NWConnection? - private var queue: DispatchQueue? - private var buffer = Data() - private var pendingRPC: [String: CheckedContinuation] = [:] - private var serverEventSubscribers: [UUID: AsyncStream.Continuation] = [:] - - private(set) var state: State = .idle - private var canvasHostUrl: String? - private var mainSessionKey: String? - - func currentCanvasHostUrl() -> String? { - self.canvasHostUrl - } - - func currentRemoteAddress() -> String? { - guard let endpoint = self.connection?.currentPath?.remoteEndpoint else { return nil } - return Self.prettyRemoteEndpoint(endpoint) - } - - private static func prettyRemoteEndpoint(_ endpoint: NWEndpoint) -> String? { - switch endpoint { - case let .hostPort(host, port): - let hostString = Self.prettyHostString(host) - if hostString.contains(":") { - return "[\(hostString)]:\(port)" - } - return "\(hostString):\(port)" - default: - return String(describing: endpoint) - } - } - - private static func prettyHostString(_ host: NWEndpoint.Host) -> String { - var hostString = String(describing: host) - hostString = hostString.replacingOccurrences(of: "::ffff:", with: "") - - guard let percentIndex = hostString.firstIndex(of: "%") else { return hostString } - - let prefix = hostString[.. Void)? = nil, - onInvoke: @escaping @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse) - async throws - { - await self.disconnect() - self.state = .connecting - do { - try await self.connectOnce( - endpoint: endpoint, - hello: hello, - tls: tls, - onConnected: onConnected, - onInvoke: onInvoke) - } catch { - if let tls, !tls.required { - try await self.connectOnce( - endpoint: endpoint, - hello: hello, - tls: nil, - onConnected: onConnected, - onInvoke: onInvoke) - return - } - throw error - } - } - - private func connectOnce( - endpoint: NWEndpoint, - hello: BridgeHello, - tls: BridgeTLSParams?, - onConnected: (@Sendable (String, String?) async -> Void)?, - onInvoke: @escaping @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse) async throws - { - let params = self.makeParameters(tls: tls) - let connection = NWConnection(to: endpoint, using: params) - let queue = DispatchQueue(label: "com.clawdbot.ios.bridge-session") - self.connection = connection - self.queue = queue - - let stateStream = Self.makeStateStream(for: connection) - connection.start(queue: queue) - - try await Self.waitForReady(stateStream, timeoutSeconds: 6) - - try await Self.withTimeout(seconds: 6) { - try await self.send(hello) - } - - guard let line = try await Self.withTimeout(seconds: 6, operation: { - try await self.receiveLine() - }), - let data = line.data(using: .utf8), - let base = try? self.decoder.decode(BridgeBaseFrame.self, from: data) - else { - await self.disconnect() - throw NSError(domain: "Bridge", code: 1, userInfo: [ - NSLocalizedDescriptionKey: "Unexpected bridge response", - ]) - } - - if base.type == "hello-ok" { - let ok = try self.decoder.decode(BridgeHelloOk.self, from: data) - self.state = .connected(serverName: ok.serverName) - self.canvasHostUrl = ok.canvasHostUrl?.trimmingCharacters(in: .whitespacesAndNewlines) - let mainKey = ok.mainSessionKey?.trimmingCharacters(in: .whitespacesAndNewlines) - self.mainSessionKey = (mainKey?.isEmpty == false) ? mainKey : nil - await onConnected?(ok.serverName, self.mainSessionKey) - } else if base.type == "error" { - let err = try self.decoder.decode(BridgeErrorFrame.self, from: data) - self.state = .failed(message: "\(err.code): \(err.message)") - await self.disconnect() - throw NSError(domain: "Bridge", code: 2, userInfo: [ - NSLocalizedDescriptionKey: "\(err.code): \(err.message)", - ]) - } else { - self.state = .failed(message: "Unexpected bridge response") - await self.disconnect() - throw NSError(domain: "Bridge", code: 3, userInfo: [ - NSLocalizedDescriptionKey: "Unexpected bridge response", - ]) - } - - while true { - guard let next = try await self.receiveLine() else { break } - guard let nextData = next.data(using: .utf8) else { continue } - guard let nextBase = try? self.decoder.decode(BridgeBaseFrame.self, from: nextData) else { continue } - - switch nextBase.type { - case "res": - let res = try self.decoder.decode(BridgeRPCResponse.self, from: nextData) - if let cont = self.pendingRPC.removeValue(forKey: res.id) { - cont.resume(returning: res) - } - - case "event": - let evt = try self.decoder.decode(BridgeEventFrame.self, from: nextData) - self.broadcastServerEvent(evt) - - case "ping": - let ping = try self.decoder.decode(BridgePing.self, from: nextData) - try await self.send(BridgePong(type: "pong", id: ping.id)) - - case "invoke": - let req = try self.decoder.decode(BridgeInvokeRequest.self, from: nextData) - let res = await onInvoke(req) - try await self.send(res) - - default: - continue - } - } - - await self.disconnect() - } - - func sendEvent(event: String, payloadJSON: String?) async throws { - try await self.send(BridgeEventFrame(type: "event", event: event, payloadJSON: payloadJSON)) - } - - func request(method: String, paramsJSON: String?, timeoutSeconds: Int = 15) async throws -> Data { - guard self.connection != nil else { - throw NSError(domain: "Bridge", code: 11, userInfo: [ - NSLocalizedDescriptionKey: "not connected", - ]) - } - - let id = UUID().uuidString - let req = BridgeRPCRequest(type: "req", id: id, method: method, paramsJSON: paramsJSON) - - let timeoutTask = Task { - try await Task.sleep(nanoseconds: UInt64(timeoutSeconds) * 1_000_000_000) - await self.timeoutRPC(id: id) - } - defer { timeoutTask.cancel() } - - let res: BridgeRPCResponse = try await withCheckedThrowingContinuation { cont in - Task { [weak self] in - guard let self else { return } - await self.beginRPC(id: id, request: req, continuation: cont) - } - } - - if res.ok { - let payload = res.payloadJSON ?? "" - guard let data = payload.data(using: .utf8) else { - throw NSError(domain: "Bridge", code: 12, userInfo: [ - NSLocalizedDescriptionKey: "Bridge response not UTF-8", - ]) - } - return data - } - - let code = res.error?.code ?? "UNAVAILABLE" - let message = res.error?.message ?? "request failed" - throw NSError(domain: "Bridge", code: 13, userInfo: [ - NSLocalizedDescriptionKey: "\(code): \(message)", - ]) - } - - func subscribeServerEvents(bufferingNewest: Int = 200) -> AsyncStream { - let id = UUID() - let session = self - return AsyncStream(bufferingPolicy: .bufferingNewest(bufferingNewest)) { continuation in - self.serverEventSubscribers[id] = continuation - continuation.onTermination = { @Sendable _ in - Task { await session.removeServerEventSubscriber(id) } - } - } - } - - func disconnect() async { - self.connection?.cancel() - self.connection = nil - self.queue = nil - self.buffer = Data() - self.canvasHostUrl = nil - self.mainSessionKey = nil - - let pending = self.pendingRPC.values - self.pendingRPC.removeAll() - for cont in pending { - cont.resume(throwing: NSError(domain: "Bridge", code: 14, userInfo: [ - NSLocalizedDescriptionKey: "UNAVAILABLE: connection closed", - ])) - } - - for (_, cont) in self.serverEventSubscribers { - cont.finish() - } - self.serverEventSubscribers.removeAll() - - self.state = .idle - } - - func currentMainSessionKey() -> String? { - self.mainSessionKey - } - - private func beginRPC( - id: String, - request: BridgeRPCRequest, - continuation: CheckedContinuation) async - { - self.pendingRPC[id] = continuation - do { - try await self.send(request) - } catch { - await self.failRPC(id: id, error: error) - } - } - - private func makeParameters(tls: BridgeTLSParams?) -> NWParameters { - if let tlsOptions = makeBridgeTLSOptions(tls) { - let tcpOptions = NWProtocolTCP.Options() - let params = NWParameters(tls: tlsOptions, tcp: tcpOptions) - params.includePeerToPeer = true - return params - } - let params = NWParameters.tcp - params.includePeerToPeer = true - return params - } - - private func timeoutRPC(id: String) async { - guard let cont = self.pendingRPC.removeValue(forKey: id) else { return } - cont.resume(throwing: NSError(domain: "Bridge", code: 15, userInfo: [ - NSLocalizedDescriptionKey: "UNAVAILABLE: request timeout", - ])) - } - - private func failRPC(id: String, error: Error) async { - guard let cont = self.pendingRPC.removeValue(forKey: id) else { return } - cont.resume(throwing: error) - } - - private func broadcastServerEvent(_ evt: BridgeEventFrame) { - for (_, cont) in self.serverEventSubscribers { - cont.yield(evt) - } - } - - private func removeServerEventSubscriber(_ id: UUID) { - self.serverEventSubscribers[id] = nil - } - - private func send(_ obj: some Encodable) async throws { - guard let connection = self.connection else { - throw NSError(domain: "Bridge", code: 10, userInfo: [ - NSLocalizedDescriptionKey: "not connected", - ]) - } - let data = try self.encoder.encode(obj) - var line = Data() - line.append(data) - line.append(0x0A) - try await withCheckedThrowingContinuation(isolation: nil) { (cont: CheckedContinuation) in - connection.send(content: line, completion: .contentProcessed { err in - if let err { cont.resume(throwing: err) } else { cont.resume(returning: ()) } - }) - } - } - - private func receiveLine() async throws -> String? { - while true { - if let idx = self.buffer.firstIndex(of: 0x0A) { - let lineData = self.buffer.prefix(upTo: idx) - self.buffer.removeSubrange(...idx) - return String(data: lineData, encoding: .utf8) - } - - let chunk = try await self.receiveChunk() - if chunk.isEmpty { return nil } - self.buffer.append(chunk) - } - } - - private func receiveChunk() async throws -> Data { - guard let connection = self.connection else { return Data() } - return try await withCheckedThrowingContinuation(isolation: nil) { (cont: CheckedContinuation) in - connection.receive(minimumIncompleteLength: 1, maximumLength: 64 * 1024) { data, _, isComplete, error in - if let error { - cont.resume(throwing: error) - return - } - if isComplete { - cont.resume(returning: Data()) - return - } - cont.resume(returning: data ?? Data()) - } - } - } - - private static func withTimeout( - seconds: Double, - operation: @escaping @Sendable () async throws -> T) async throws -> T - { - try await AsyncTimeout.withTimeout( - seconds: seconds, - onTimeout: { TimeoutError(message: "UNAVAILABLE: connection timeout") }, - operation: operation) - } - - private static func makeStateStream(for connection: NWConnection) -> AsyncStream { - AsyncStream { continuation in - continuation.onTermination = { @Sendable _ in - connection.stateUpdateHandler = nil - } - - connection.stateUpdateHandler = { state in - continuation.yield(state) - switch state { - case .ready, .cancelled, .failed, .waiting: - continuation.finish() - case .setup, .preparing: - break - @unknown default: - break - } - } - } - } - - private static func waitForReady( - _ stateStream: AsyncStream, - timeoutSeconds: Double) async throws - { - try await self.withTimeout(seconds: timeoutSeconds) { - for await state in stateStream { - switch state { - case .ready: - return - case let .failed(error): - throw error - case let .waiting(error): - throw error - case .cancelled: - throw TimeoutError(message: "UNAVAILABLE: connection cancelled") - case .setup, .preparing: - break - @unknown default: - break - } - } - - throw TimeoutError(message: "UNAVAILABLE: connection ended") - } - } -} diff --git a/apps/ios/Sources/Bridge/BridgeSettingsStore.swift b/apps/ios/Sources/Bridge/BridgeSettingsStore.swift deleted file mode 100644 index 7d0766235..000000000 --- a/apps/ios/Sources/Bridge/BridgeSettingsStore.swift +++ /dev/null @@ -1,112 +0,0 @@ -import Foundation - -enum BridgeSettingsStore { - private static let bridgeService = "com.clawdbot.bridge" - private static let nodeService = "com.clawdbot.node" - - private static let instanceIdDefaultsKey = "node.instanceId" - private static let preferredBridgeStableIDDefaultsKey = "bridge.preferredStableID" - private static let lastDiscoveredBridgeStableIDDefaultsKey = "bridge.lastDiscoveredStableID" - - private static let instanceIdAccount = "instanceId" - private static let preferredBridgeStableIDAccount = "preferredStableID" - private static let lastDiscoveredBridgeStableIDAccount = "lastDiscoveredStableID" - - static func bootstrapPersistence() { - self.ensureStableInstanceID() - self.ensurePreferredBridgeStableID() - self.ensureLastDiscoveredBridgeStableID() - } - - static func loadStableInstanceID() -> String? { - KeychainStore.loadString(service: self.nodeService, account: self.instanceIdAccount)? - .trimmingCharacters(in: .whitespacesAndNewlines) - } - - static func saveStableInstanceID(_ instanceId: String) { - _ = KeychainStore.saveString(instanceId, service: self.nodeService, account: self.instanceIdAccount) - } - - static func loadPreferredBridgeStableID() -> String? { - KeychainStore.loadString(service: self.bridgeService, account: self.preferredBridgeStableIDAccount)? - .trimmingCharacters(in: .whitespacesAndNewlines) - } - - static func savePreferredBridgeStableID(_ stableID: String) { - _ = KeychainStore.saveString( - stableID, - service: self.bridgeService, - account: self.preferredBridgeStableIDAccount) - } - - static func loadLastDiscoveredBridgeStableID() -> String? { - KeychainStore.loadString(service: self.bridgeService, account: self.lastDiscoveredBridgeStableIDAccount)? - .trimmingCharacters(in: .whitespacesAndNewlines) - } - - static func saveLastDiscoveredBridgeStableID(_ stableID: String) { - _ = KeychainStore.saveString( - stableID, - service: self.bridgeService, - account: self.lastDiscoveredBridgeStableIDAccount) - } - - private static func ensureStableInstanceID() { - let defaults = UserDefaults.standard - - if let existing = defaults.string(forKey: self.instanceIdDefaultsKey)? - .trimmingCharacters(in: .whitespacesAndNewlines), - !existing.isEmpty - { - if self.loadStableInstanceID() == nil { - self.saveStableInstanceID(existing) - } - return - } - - if let stored = self.loadStableInstanceID(), !stored.isEmpty { - defaults.set(stored, forKey: self.instanceIdDefaultsKey) - return - } - - let fresh = UUID().uuidString - self.saveStableInstanceID(fresh) - defaults.set(fresh, forKey: self.instanceIdDefaultsKey) - } - - private static func ensurePreferredBridgeStableID() { - let defaults = UserDefaults.standard - - if let existing = defaults.string(forKey: self.preferredBridgeStableIDDefaultsKey)? - .trimmingCharacters(in: .whitespacesAndNewlines), - !existing.isEmpty - { - if self.loadPreferredBridgeStableID() == nil { - self.savePreferredBridgeStableID(existing) - } - return - } - - if let stored = self.loadPreferredBridgeStableID(), !stored.isEmpty { - defaults.set(stored, forKey: self.preferredBridgeStableIDDefaultsKey) - } - } - - private static func ensureLastDiscoveredBridgeStableID() { - let defaults = UserDefaults.standard - - if let existing = defaults.string(forKey: self.lastDiscoveredBridgeStableIDDefaultsKey)? - .trimmingCharacters(in: .whitespacesAndNewlines), - !existing.isEmpty - { - if self.loadLastDiscoveredBridgeStableID() == nil { - self.saveLastDiscoveredBridgeStableID(existing) - } - return - } - - if let stored = self.loadLastDiscoveredBridgeStableID(), !stored.isEmpty { - defaults.set(stored, forKey: self.lastDiscoveredBridgeStableIDDefaultsKey) - } - } -} diff --git a/apps/ios/Sources/Bridge/BridgeTLS.swift b/apps/ios/Sources/Bridge/BridgeTLS.swift deleted file mode 100644 index 5ede00d3f..000000000 --- a/apps/ios/Sources/Bridge/BridgeTLS.swift +++ /dev/null @@ -1,66 +0,0 @@ -import CryptoKit -import Foundation -import Network -import Security - -struct BridgeTLSParams: Sendable { - let required: Bool - let expectedFingerprint: String? - let allowTOFU: Bool - let storeKey: String? -} - -enum BridgeTLSStore { - private static let service = "com.clawdbot.bridge.tls" - - static func loadFingerprint(stableID: String) -> String? { - KeychainStore.loadString(service: service, account: stableID)?.trimmingCharacters(in: .whitespacesAndNewlines) - } - - static func saveFingerprint(_ value: String, stableID: String) { - _ = KeychainStore.saveString(value, service: service, account: stableID) - } -} - -func makeBridgeTLSOptions(_ params: BridgeTLSParams?) -> NWProtocolTLS.Options? { - guard let params else { return nil } - let options = NWProtocolTLS.Options() - let expected = params.expectedFingerprint.map(normalizeBridgeFingerprint) - let allowTOFU = params.allowTOFU - let storeKey = params.storeKey - - sec_protocol_options_set_verify_block( - options.securityProtocolOptions, - { _, trust, complete in - let trustRef = sec_trust_copy_ref(trust).takeRetainedValue() - if let chain = SecTrustCopyCertificateChain(trustRef) as? [SecCertificate], - let cert = chain.first - { - let data = SecCertificateCopyData(cert) as Data - let fingerprint = sha256Hex(data) - if let expected { - complete(fingerprint == expected) - return - } - if allowTOFU { - if let storeKey { BridgeTLSStore.saveFingerprint(fingerprint, stableID: storeKey) } - complete(true) - return - } - } - let ok = SecTrustEvaluateWithError(trustRef, nil) - complete(ok) - }, - DispatchQueue(label: "com.clawdbot.bridge.tls.verify")) - - return options -} - -private func sha256Hex(_ data: Data) -> String { - let digest = SHA256.hash(data: data) - return digest.map { String(format: "%02x", $0) }.joined() -} - -private func normalizeBridgeFingerprint(_ raw: String) -> String { - raw.lowercased().filter { $0.isHexDigit } -} diff --git a/apps/ios/Sources/Camera/CameraController.swift b/apps/ios/Sources/Camera/CameraController.swift index e2ea47e05..e361664cf 100644 --- a/apps/ios/Sources/Camera/CameraController.swift +++ b/apps/ios/Sources/Camera/CameraController.swift @@ -44,7 +44,7 @@ actor CameraController { { let facing = params.facing ?? .front let format = params.format ?? .jpg - // Default to a reasonable max width to keep bridge payload sizes manageable. + // Default to a reasonable max width to keep gateway payload sizes manageable. // If you need the full-res photo, explicitly request a larger maxWidth. let maxWidth = params.maxWidth.flatMap { $0 > 0 ? $0 : nil } ?? 1600 let quality = Self.clampQuality(params.quality) @@ -270,7 +270,7 @@ actor CameraController { nonisolated static func clampDurationMs(_ ms: Int?) -> Int { let v = ms ?? 3000 - // Keep clips short by default; avoid huge base64 payloads on the bridge. + // Keep clips short by default; avoid huge base64 payloads on the gateway. return min(60000, max(250, v)) } diff --git a/apps/ios/Sources/Chat/ChatSheet.swift b/apps/ios/Sources/Chat/ChatSheet.swift index 0db033238..c0e5593ff 100644 --- a/apps/ios/Sources/Chat/ChatSheet.swift +++ b/apps/ios/Sources/Chat/ChatSheet.swift @@ -1,4 +1,5 @@ import ClawdbotChatUI +import ClawdbotKit import SwiftUI struct ChatSheet: View { @@ -6,8 +7,8 @@ struct ChatSheet: View { @State private var viewModel: ClawdbotChatViewModel private let userAccent: Color? - init(bridge: BridgeSession, sessionKey: String, userAccent: Color? = nil) { - let transport = IOSBridgeChatTransport(bridge: bridge) + init(gateway: GatewayNodeSession, sessionKey: String, userAccent: Color? = nil) { + let transport = IOSGatewayChatTransport(gateway: gateway) self._viewModel = State( initialValue: ClawdbotChatViewModel( sessionKey: sessionKey, diff --git a/apps/ios/Sources/Chat/IOSBridgeChatTransport.swift b/apps/ios/Sources/Chat/IOSGatewayChatTransport.swift similarity index 68% rename from apps/ios/Sources/Chat/IOSBridgeChatTransport.swift rename to apps/ios/Sources/Chat/IOSGatewayChatTransport.swift index f6d3cc4a0..acc8b7015 100644 --- a/apps/ios/Sources/Chat/IOSBridgeChatTransport.swift +++ b/apps/ios/Sources/Chat/IOSGatewayChatTransport.swift @@ -1,12 +1,13 @@ import ClawdbotChatUI import ClawdbotKit +import ClawdbotProtocol import Foundation -struct IOSBridgeChatTransport: ClawdbotChatTransport, Sendable { - private let bridge: BridgeSession +struct IOSGatewayChatTransport: ClawdbotChatTransport, Sendable { + private let gateway: GatewayNodeSession - init(bridge: BridgeSession) { - self.bridge = bridge + init(gateway: GatewayNodeSession) { + self.gateway = gateway } func abortRun(sessionKey: String, runId: String) async throws { @@ -16,7 +17,7 @@ struct IOSBridgeChatTransport: ClawdbotChatTransport, Sendable { } let data = try JSONEncoder().encode(Params(sessionKey: sessionKey, runId: runId)) let json = String(data: data, encoding: .utf8) - _ = try await self.bridge.request(method: "chat.abort", paramsJSON: json, timeoutSeconds: 10) + _ = try await self.gateway.request(method: "chat.abort", paramsJSON: json, timeoutSeconds: 10) } func listSessions(limit: Int?) async throws -> ClawdbotChatSessionsListResponse { @@ -27,7 +28,7 @@ struct IOSBridgeChatTransport: ClawdbotChatTransport, Sendable { } let data = try JSONEncoder().encode(Params(includeGlobal: true, includeUnknown: false, limit: limit)) let json = String(data: data, encoding: .utf8) - let res = try await self.bridge.request(method: "sessions.list", paramsJSON: json, timeoutSeconds: 15) + let res = try await self.gateway.request(method: "sessions.list", paramsJSON: json, timeoutSeconds: 15) return try JSONDecoder().decode(ClawdbotChatSessionsListResponse.self, from: res) } @@ -35,14 +36,14 @@ struct IOSBridgeChatTransport: ClawdbotChatTransport, Sendable { struct Subscribe: Codable { var sessionKey: String } let data = try JSONEncoder().encode(Subscribe(sessionKey: sessionKey)) let json = String(data: data, encoding: .utf8) - try await self.bridge.sendEvent(event: "chat.subscribe", payloadJSON: json) + await self.gateway.sendEvent(event: "chat.subscribe", payloadJSON: json) } func requestHistory(sessionKey: String) async throws -> ClawdbotChatHistoryPayload { struct Params: Codable { var sessionKey: String } let data = try JSONEncoder().encode(Params(sessionKey: sessionKey)) let json = String(data: data, encoding: .utf8) - let res = try await self.bridge.request(method: "chat.history", paramsJSON: json, timeoutSeconds: 15) + let res = try await self.gateway.request(method: "chat.history", paramsJSON: json, timeoutSeconds: 15) return try JSONDecoder().decode(ClawdbotChatHistoryPayload.self, from: res) } @@ -71,20 +72,20 @@ struct IOSBridgeChatTransport: ClawdbotChatTransport, Sendable { idempotencyKey: idempotencyKey) let data = try JSONEncoder().encode(params) let json = String(data: data, encoding: .utf8) - let res = try await self.bridge.request(method: "chat.send", paramsJSON: json, timeoutSeconds: 35) + let res = try await self.gateway.request(method: "chat.send", paramsJSON: json, timeoutSeconds: 35) return try JSONDecoder().decode(ClawdbotChatSendResponse.self, from: res) } func requestHealth(timeoutMs: Int) async throws -> Bool { let seconds = max(1, Int(ceil(Double(timeoutMs) / 1000.0))) - let res = try await self.bridge.request(method: "health", paramsJSON: nil, timeoutSeconds: seconds) + let res = try await self.gateway.request(method: "health", paramsJSON: nil, timeoutSeconds: seconds) return (try? JSONDecoder().decode(ClawdbotGatewayHealthOK.self, from: res))?.ok ?? true } func events() -> AsyncStream { AsyncStream { continuation in let task = Task { - let stream = await self.bridge.subscribeServerEvents() + let stream = await self.gateway.subscribeServerEvents() for await evt in stream { if Task.isCancelled { return } switch evt.event { @@ -93,18 +94,18 @@ struct IOSBridgeChatTransport: ClawdbotChatTransport, Sendable { case "seqGap": continuation.yield(.seqGap) case "health": - guard let json = evt.payloadJSON, let data = json.data(using: .utf8) else { break } - let ok = (try? JSONDecoder().decode(ClawdbotGatewayHealthOK.self, from: data))?.ok ?? true + guard let payload = evt.payload else { break } + let ok = (try? GatewayPayloadDecoding.decode(payload, as: ClawdbotGatewayHealthOK.self))?.ok ?? true continuation.yield(.health(ok: ok)) case "chat": - guard let json = evt.payloadJSON, let data = json.data(using: .utf8) else { break } - if let payload = try? JSONDecoder().decode(ClawdbotChatEventPayload.self, from: data) { - continuation.yield(.chat(payload)) + guard let payload = evt.payload else { break } + if let chatPayload = try? GatewayPayloadDecoding.decode(payload, as: ClawdbotChatEventPayload.self) { + continuation.yield(.chat(chatPayload)) } case "agent": - guard let json = evt.payloadJSON, let data = json.data(using: .utf8) else { break } - if let payload = try? JSONDecoder().decode(ClawdbotAgentEventPayload.self, from: data) { - continuation.yield(.agent(payload)) + guard let payload = evt.payload else { break } + if let agentPayload = try? GatewayPayloadDecoding.decode(payload, as: ClawdbotAgentEventPayload.self) { + continuation.yield(.agent(agentPayload)) } default: break diff --git a/apps/ios/Sources/ClawdbotApp.swift b/apps/ios/Sources/ClawdbotApp.swift index c29572e30..3ed8933b6 100644 --- a/apps/ios/Sources/ClawdbotApp.swift +++ b/apps/ios/Sources/ClawdbotApp.swift @@ -3,14 +3,14 @@ import SwiftUI @main struct ClawdbotApp: App { @State private var appModel: NodeAppModel - @State private var bridgeController: BridgeConnectionController + @State private var gatewayController: GatewayConnectionController @Environment(\.scenePhase) private var scenePhase init() { - BridgeSettingsStore.bootstrapPersistence() + GatewaySettingsStore.bootstrapPersistence() let appModel = NodeAppModel() _appModel = State(initialValue: appModel) - _bridgeController = State(initialValue: BridgeConnectionController(appModel: appModel)) + _gatewayController = State(initialValue: GatewayConnectionController(appModel: appModel)) } var body: some Scene { @@ -18,13 +18,13 @@ struct ClawdbotApp: App { RootCanvas() .environment(self.appModel) .environment(self.appModel.voiceWake) - .environment(self.bridgeController) + .environment(self.gatewayController) .onOpenURL { url in Task { await self.appModel.handleDeepLink(url: url) } } .onChange(of: self.scenePhase) { _, newValue in self.appModel.setScenePhase(newValue) - self.bridgeController.setScenePhase(newValue) + self.gatewayController.setScenePhase(newValue) } } } diff --git a/apps/ios/Sources/Bridge/BridgeConnectionController.swift b/apps/ios/Sources/Gateway/GatewayConnectionController.swift similarity index 53% rename from apps/ios/Sources/Bridge/BridgeConnectionController.swift rename to apps/ios/Sources/Gateway/GatewayConnectionController.swift index 01a6c6c0a..a80946713 100644 --- a/apps/ios/Sources/Bridge/BridgeConnectionController.swift +++ b/apps/ios/Sources/Gateway/GatewayConnectionController.swift @@ -6,40 +6,23 @@ import Observation import SwiftUI import UIKit -protocol BridgePairingClient: Sendable { - func pairAndHello( - endpoint: NWEndpoint, - hello: BridgeHello, - tls: BridgeTLSParams?, - onStatus: (@Sendable (String) -> Void)?) async throws -> String -} - -extension BridgeClient: BridgePairingClient {} - @MainActor @Observable -final class BridgeConnectionController { - private(set) var bridges: [BridgeDiscoveryModel.DiscoveredBridge] = [] +final class GatewayConnectionController { + private(set) var gateways: [GatewayDiscoveryModel.DiscoveredGateway] = [] private(set) var discoveryStatusText: String = "Idle" - private(set) var discoveryDebugLog: [BridgeDiscoveryModel.DebugLogEntry] = [] + private(set) var discoveryDebugLog: [GatewayDiscoveryModel.DebugLogEntry] = [] - private let discovery = BridgeDiscoveryModel() + private let discovery = GatewayDiscoveryModel() private weak var appModel: NodeAppModel? private var didAutoConnect = false - private let bridgeClientFactory: @Sendable () -> any BridgePairingClient - - init( - appModel: NodeAppModel, - startDiscovery: Bool = true, - bridgeClientFactory: @escaping @Sendable () -> any BridgePairingClient = { BridgeClient() }) - { + init(appModel: NodeAppModel, startDiscovery: Bool = true) { self.appModel = appModel - self.bridgeClientFactory = bridgeClientFactory - BridgeSettingsStore.bootstrapPersistence() + GatewaySettingsStore.bootstrapPersistence() let defaults = UserDefaults.standard - self.discovery.setDebugLoggingEnabled(defaults.bool(forKey: "bridge.discovery.debugLogs")) + self.discovery.setDebugLoggingEnabled(defaults.bool(forKey: "gateway.discovery.debugLogs")) self.updateFromDiscovery() self.observeDiscovery() @@ -64,18 +47,53 @@ final class BridgeConnectionController { } } + func connect(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) async { + let instanceId = UserDefaults.standard.string(forKey: "node.instanceId")? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let token = GatewaySettingsStore.loadGatewayToken(instanceId: instanceId) + let password = GatewaySettingsStore.loadGatewayPassword(instanceId: instanceId) + guard let host = self.resolveGatewayHost(gateway) else { return } + let port = gateway.gatewayPort ?? 18789 + let tlsParams = self.resolveDiscoveredTLSParams(gateway: gateway) + guard let url = self.buildGatewayURL(host: host, port: port, useTLS: tlsParams?.required == true) else { return } + self.didAutoConnect = true + self.startAutoConnect( + url: url, + gatewayStableID: gateway.stableID, + tls: tlsParams, + token: token, + password: password) + } + + func connectManual(host: String, port: Int, useTLS: Bool) async { + let instanceId = UserDefaults.standard.string(forKey: "node.instanceId")? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let token = GatewaySettingsStore.loadGatewayToken(instanceId: instanceId) + let password = GatewaySettingsStore.loadGatewayPassword(instanceId: instanceId) + let stableID = self.manualStableID(host: host, port: port) + let tlsParams = self.resolveManualTLSParams(stableID: stableID, tlsEnabled: useTLS) + guard let url = self.buildGatewayURL(host: host, port: port, useTLS: tlsParams?.required == true) else { return } + self.didAutoConnect = true + self.startAutoConnect( + url: url, + gatewayStableID: stableID, + tls: tlsParams, + token: token, + password: password) + } + private func updateFromDiscovery() { - let newBridges = self.discovery.bridges - self.bridges = newBridges + let newGateways = self.discovery.gateways + self.gateways = newGateways self.discoveryStatusText = self.discovery.statusText self.discoveryDebugLog = self.discovery.debugLog - self.updateLastDiscoveredBridge(from: newBridges) + self.updateLastDiscoveredGateway(from: newGateways) self.maybeAutoConnect() } private func observeDiscovery() { withObservationTracking { - _ = self.discovery.bridges + _ = self.discovery.gateways _ = self.discovery.statusText _ = self.discovery.debugLog } onChange: { [weak self] in @@ -90,181 +108,176 @@ final class BridgeConnectionController { private func maybeAutoConnect() { guard !self.didAutoConnect else { return } guard let appModel = self.appModel else { return } - guard appModel.bridgeServerName == nil else { return } + guard appModel.gatewayServerName == nil else { return } let defaults = UserDefaults.standard - let manualEnabled = defaults.bool(forKey: "bridge.manual.enabled") + let manualEnabled = defaults.bool(forKey: "gateway.manual.enabled") let instanceId = defaults.string(forKey: "node.instanceId")? .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" guard !instanceId.isEmpty else { return } - let token = KeychainStore.loadString( - service: "com.clawdbot.bridge", - account: self.keychainAccount(instanceId: instanceId))? - .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - guard !token.isEmpty else { return } + let token = GatewaySettingsStore.loadGatewayToken(instanceId: instanceId) + let password = GatewaySettingsStore.loadGatewayPassword(instanceId: instanceId) if manualEnabled { - let manualHost = defaults.string(forKey: "bridge.manual.host")? + let manualHost = defaults.string(forKey: "gateway.manual.host")? .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" guard !manualHost.isEmpty else { return } - let manualPort = defaults.integer(forKey: "bridge.manual.port") - let resolvedPort = manualPort > 0 ? manualPort : 18790 - guard let port = NWEndpoint.Port(rawValue: UInt16(resolvedPort)) else { return } + let manualPort = defaults.integer(forKey: "gateway.manual.port") + let resolvedPort = manualPort > 0 ? manualPort : 18789 + let manualTLS = defaults.bool(forKey: "gateway.manual.tls") + + let stableID = self.manualStableID(host: manualHost, port: resolvedPort) + let tlsParams = self.resolveManualTLSParams(stableID: stableID, tlsEnabled: manualTLS) + + guard let url = self.buildGatewayURL( + host: manualHost, + port: resolvedPort, + useTLS: tlsParams?.required == true) + else { return } self.didAutoConnect = true - let endpoint = NWEndpoint.hostPort(host: NWEndpoint.Host(manualHost), port: port) - let stableID = BridgeEndpointID.stableID(endpoint) - let tlsParams = self.resolveManualTLSParams(stableID: stableID) self.startAutoConnect( - endpoint: endpoint, - bridgeStableID: stableID, + url: url, + gatewayStableID: stableID, tls: tlsParams, token: token, - instanceId: instanceId) + password: password) return } - let preferredStableID = defaults.string(forKey: "bridge.preferredStableID")? + let preferredStableID = defaults.string(forKey: "gateway.preferredStableID")? .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - let lastDiscoveredStableID = defaults.string(forKey: "bridge.lastDiscoveredStableID")? + let lastDiscoveredStableID = defaults.string(forKey: "gateway.lastDiscoveredStableID")? .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" let candidates = [preferredStableID, lastDiscoveredStableID].filter { !$0.isEmpty } guard let targetStableID = candidates.first(where: { id in - self.bridges.contains(where: { $0.stableID == id }) + self.gateways.contains(where: { $0.stableID == id }) }) else { return } - guard let target = self.bridges.first(where: { $0.stableID == targetStableID }) else { return } + guard let target = self.gateways.first(where: { $0.stableID == targetStableID }) else { return } + guard let host = self.resolveGatewayHost(target) else { return } + let port = target.gatewayPort ?? 18789 + let tlsParams = self.resolveDiscoveredTLSParams(gateway: target) + guard let url = self.buildGatewayURL(host: host, port: port, useTLS: tlsParams?.required == true) + else { return } - let tlsParams = self.resolveDiscoveredTLSParams(bridge: target) self.didAutoConnect = true self.startAutoConnect( - endpoint: target.endpoint, - bridgeStableID: target.stableID, + url: url, + gatewayStableID: target.stableID, tls: tlsParams, token: token, - instanceId: instanceId) + password: password) } - private func updateLastDiscoveredBridge(from bridges: [BridgeDiscoveryModel.DiscoveredBridge]) { + private func updateLastDiscoveredGateway(from gateways: [GatewayDiscoveryModel.DiscoveredGateway]) { let defaults = UserDefaults.standard - let preferred = defaults.string(forKey: "bridge.preferredStableID")? + let preferred = defaults.string(forKey: "gateway.preferredStableID")? .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - let existingLast = defaults.string(forKey: "bridge.lastDiscoveredStableID")? + let existingLast = defaults.string(forKey: "gateway.lastDiscoveredStableID")? .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" // Avoid overriding user intent (preferred/lastDiscovered are also set on manual Connect). guard preferred.isEmpty, existingLast.isEmpty else { return } - guard let first = bridges.first else { return } + guard let first = gateways.first else { return } - defaults.set(first.stableID, forKey: "bridge.lastDiscoveredStableID") - BridgeSettingsStore.saveLastDiscoveredBridgeStableID(first.stableID) - } - - private func makeHello(token: String) -> BridgeHello { - let defaults = UserDefaults.standard - let nodeId = defaults.string(forKey: "node.instanceId") ?? "ios-node" - let displayName = self.resolvedDisplayName(defaults: defaults) - - return BridgeHello( - nodeId: nodeId, - displayName: displayName, - token: token, - platform: self.platformString(), - version: self.appVersion(), - deviceFamily: self.deviceFamily(), - modelIdentifier: self.modelIdentifier(), - caps: self.currentCaps(), - commands: self.currentCommands()) - } - - private func keychainAccount(instanceId: String) -> String { - "bridge-token.\(instanceId)" + defaults.set(first.stableID, forKey: "gateway.lastDiscoveredStableID") + GatewaySettingsStore.saveLastDiscoveredGatewayStableID(first.stableID) } private func startAutoConnect( - endpoint: NWEndpoint, - bridgeStableID: String, - tls: BridgeTLSParams?, - token: String, - instanceId: String) + url: URL, + gatewayStableID: String, + tls: GatewayTLSParams?, + token: String?, + password: String?) { guard let appModel else { return } + let connectOptions = self.makeConnectOptions() + Task { [weak self] in guard let self else { return } - do { - let hello = self.makeHello(token: token) - let refreshed = try await self.bridgeClientFactory().pairAndHello( - endpoint: endpoint, - hello: hello, - tls: tls, - onStatus: { status in - Task { @MainActor in - appModel.bridgeStatusText = status - } - }) - let resolvedToken = refreshed.isEmpty ? token : refreshed - if !refreshed.isEmpty, refreshed != token { - _ = KeychainStore.saveString( - refreshed, - service: "com.clawdbot.bridge", - account: self.keychainAccount(instanceId: instanceId)) - } - appModel.connectToBridge( - endpoint: endpoint, - bridgeStableID: bridgeStableID, - tls: tls, - hello: self.makeHello(token: resolvedToken)) - } catch { - await MainActor.run { - appModel.bridgeStatusText = "Bridge error: \(error.localizedDescription)" - } + await MainActor.run { + appModel.gatewayStatusText = "Connecting…" } + appModel.connectToGateway( + url: url, + gatewayStableID: gatewayStableID, + tls: tls, + token: token, + password: password, + connectOptions: connectOptions) } } - private func resolveDiscoveredTLSParams( - bridge: BridgeDiscoveryModel.DiscoveredBridge) -> BridgeTLSParams? - { - let stableID = bridge.stableID - let stored = BridgeTLSStore.loadFingerprint(stableID: stableID) + private func resolveDiscoveredTLSParams(gateway: GatewayDiscoveryModel.DiscoveredGateway) -> GatewayTLSParams? { + let stableID = gateway.stableID + let stored = GatewayTLSStore.loadFingerprint(stableID: stableID) - if bridge.tlsEnabled || bridge.tlsFingerprintSha256 != nil { - return BridgeTLSParams( + if gateway.tlsEnabled || gateway.tlsFingerprintSha256 != nil || stored != nil { + return GatewayTLSParams( required: true, - expectedFingerprint: bridge.tlsFingerprintSha256 ?? stored, + expectedFingerprint: gateway.tlsFingerprintSha256 ?? stored, allowTOFU: stored == nil, storeKey: stableID) } - if let stored { - return BridgeTLSParams( - required: true, - expectedFingerprint: stored, - allowTOFU: false, - storeKey: stableID) - } - return nil } - private func resolveManualTLSParams(stableID: String) -> BridgeTLSParams? { - if let stored = BridgeTLSStore.loadFingerprint(stableID: stableID) { - return BridgeTLSParams( + private func resolveManualTLSParams(stableID: String, tlsEnabled: Bool) -> GatewayTLSParams? { + let stored = GatewayTLSStore.loadFingerprint(stableID: stableID) + if tlsEnabled || stored != nil { + return GatewayTLSParams( required: true, expectedFingerprint: stored, - allowTOFU: false, + allowTOFU: stored == nil, storeKey: stableID) } - return BridgeTLSParams( - required: false, - expectedFingerprint: nil, - allowTOFU: true, - storeKey: stableID) + return nil + } + + private func resolveGatewayHost(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) -> String? { + if let lanHost = gateway.lanHost?.trimmingCharacters(in: .whitespacesAndNewlines), !lanHost.isEmpty { + return lanHost + } + if let tailnet = gateway.tailnetDns?.trimmingCharacters(in: .whitespacesAndNewlines), !tailnet.isEmpty { + return tailnet + } + return nil + } + + private func buildGatewayURL(host: String, port: Int, useTLS: Bool) -> URL? { + let scheme = useTLS ? "wss" : "ws" + var components = URLComponents() + components.scheme = scheme + components.host = host + components.port = port + return components.url + } + + private func manualStableID(host: String, port: Int) -> String { + "manual|\(host.lowercased())|\(port)" + } + + private func makeConnectOptions() -> GatewayConnectOptions { + let defaults = UserDefaults.standard + let displayName = self.resolvedDisplayName(defaults: defaults) + + return GatewayConnectOptions( + role: "node", + scopes: [], + caps: self.currentCaps(), + commands: self.currentCommands(), + permissions: [:], + clientId: "clawdbot-ios", + clientMode: "node", + clientDisplayName: displayName) } private func resolvedDisplayName(defaults: UserDefaults) -> String { @@ -313,6 +326,11 @@ final class BridgeConnectionController { ClawdbotCanvasA2UICommand.pushJSONL.rawValue, ClawdbotCanvasA2UICommand.reset.rawValue, ClawdbotScreenCommand.record.rawValue, + ClawdbotSystemCommand.notify.rawValue, + ClawdbotSystemCommand.which.rawValue, + ClawdbotSystemCommand.run.rawValue, + ClawdbotSystemCommand.execApprovalsGet.rawValue, + ClawdbotSystemCommand.execApprovalsSet.rawValue, ] let caps = Set(self.currentCaps()) @@ -368,11 +386,7 @@ final class BridgeConnectionController { } #if DEBUG -extension BridgeConnectionController { - func _test_makeHello(token: String) -> BridgeHello { - self.makeHello(token: token) - } - +extension GatewayConnectionController { func _test_resolvedDisplayName(defaults: UserDefaults) -> String { self.resolvedDisplayName(defaults: defaults) } @@ -401,8 +415,8 @@ extension BridgeConnectionController { self.appVersion() } - func _test_setBridges(_ bridges: [BridgeDiscoveryModel.DiscoveredBridge]) { - self.bridges = bridges + func _test_setGateways(_ gateways: [GatewayDiscoveryModel.DiscoveredGateway]) { + self.gateways = gateways } func _test_triggerAutoConnect() { diff --git a/apps/ios/Sources/Bridge/BridgeDiscoveryDebugLogView.swift b/apps/ios/Sources/Gateway/GatewayDiscoveryDebugLogView.swift similarity index 79% rename from apps/ios/Sources/Bridge/BridgeDiscoveryDebugLogView.swift rename to apps/ios/Sources/Gateway/GatewayDiscoveryDebugLogView.swift index 26a946213..830722540 100644 --- a/apps/ios/Sources/Bridge/BridgeDiscoveryDebugLogView.swift +++ b/apps/ios/Sources/Gateway/GatewayDiscoveryDebugLogView.swift @@ -1,9 +1,9 @@ import SwiftUI import UIKit -struct BridgeDiscoveryDebugLogView: View { - @Environment(BridgeConnectionController.self) private var bridgeController - @AppStorage("bridge.discovery.debugLogs") private var debugLogsEnabled: Bool = false +struct GatewayDiscoveryDebugLogView: View { + @Environment(GatewayConnectionController.self) private var gatewayController + @AppStorage("gateway.discovery.debugLogs") private var debugLogsEnabled: Bool = false var body: some View { List { @@ -12,11 +12,11 @@ struct BridgeDiscoveryDebugLogView: View { .foregroundStyle(.secondary) } - if self.bridgeController.discoveryDebugLog.isEmpty { + if self.gatewayController.discoveryDebugLog.isEmpty { Text("No log entries yet.") .foregroundStyle(.secondary) } else { - ForEach(self.bridgeController.discoveryDebugLog) { entry in + ForEach(self.gatewayController.discoveryDebugLog) { entry in VStack(alignment: .leading, spacing: 2) { Text(Self.formatTime(entry.ts)) .font(.caption) @@ -35,13 +35,13 @@ struct BridgeDiscoveryDebugLogView: View { Button("Copy") { UIPasteboard.general.string = self.formattedLog() } - .disabled(self.bridgeController.discoveryDebugLog.isEmpty) + .disabled(self.gatewayController.discoveryDebugLog.isEmpty) } } } private func formattedLog() -> String { - self.bridgeController.discoveryDebugLog + self.gatewayController.discoveryDebugLog .map { "\(Self.formatISO($0.ts)) \($0.message)" } .joined(separator: "\n") } diff --git a/apps/ios/Sources/Bridge/BridgeDiscoveryModel.swift b/apps/ios/Sources/Gateway/GatewayDiscoveryModel.swift similarity index 88% rename from apps/ios/Sources/Bridge/BridgeDiscoveryModel.swift rename to apps/ios/Sources/Gateway/GatewayDiscoveryModel.swift index 8ff032af2..a40a62397 100644 --- a/apps/ios/Sources/Bridge/BridgeDiscoveryModel.swift +++ b/apps/ios/Sources/Gateway/GatewayDiscoveryModel.swift @@ -5,14 +5,14 @@ import Observation @MainActor @Observable -final class BridgeDiscoveryModel { +final class GatewayDiscoveryModel { struct DebugLogEntry: Identifiable, Equatable { var id = UUID() var ts: Date var message: String } - struct DiscoveredBridge: Identifiable, Equatable { + struct DiscoveredGateway: Identifiable, Equatable { var id: String { self.stableID } var name: String var endpoint: NWEndpoint @@ -21,19 +21,18 @@ final class BridgeDiscoveryModel { var lanHost: String? var tailnetDns: String? var gatewayPort: Int? - var bridgePort: Int? var canvasPort: Int? var tlsEnabled: Bool var tlsFingerprintSha256: String? var cliPath: String? } - var bridges: [DiscoveredBridge] = [] + var gateways: [DiscoveredGateway] = [] var statusText: String = "Idle" private(set) var debugLog: [DebugLogEntry] = [] private var browsers: [String: NWBrowser] = [:] - private var bridgesByDomain: [String: [DiscoveredBridge]] = [:] + private var gatewaysByDomain: [String: [DiscoveredGateway]] = [:] private var statesByDomain: [String: NWBrowser.State] = [:] private var debugLoggingEnabled = false private var lastStableIDs = Set() @@ -45,7 +44,7 @@ final class BridgeDiscoveryModel { self.debugLog = [] } else if !wasEnabled { self.appendDebugLog("debug logging enabled") - self.appendDebugLog("snapshot: status=\(self.statusText) bridges=\(self.bridges.count)") + self.appendDebugLog("snapshot: status=\(self.statusText) gateways=\(self.gateways.count)") } } @@ -72,7 +71,7 @@ final class BridgeDiscoveryModel { browser.browseResultsChangedHandler = { [weak self] results, _ in Task { @MainActor in guard let self else { return } - self.bridgesByDomain[domain] = results.compactMap { result -> DiscoveredBridge? in + self.gatewaysByDomain[domain] = results.compactMap { result -> DiscoveredGateway? in switch result.endpoint { case let .service(name, _, _, _): let decodedName = BonjourEscapes.decode(name) @@ -82,18 +81,17 @@ final class BridgeDiscoveryModel { .map(Self.prettifyInstanceName) .flatMap { $0.isEmpty ? nil : $0 } let prettyName = prettyAdvertised ?? Self.prettifyInstanceName(decodedName) - return DiscoveredBridge( + return DiscoveredGateway( name: prettyName, endpoint: result.endpoint, - stableID: BridgeEndpointID.stableID(result.endpoint), - debugID: BridgeEndpointID.prettyDescription(result.endpoint), + stableID: GatewayEndpointID.stableID(result.endpoint), + debugID: GatewayEndpointID.prettyDescription(result.endpoint), lanHost: Self.txtValue(txt, key: "lanHost"), tailnetDns: Self.txtValue(txt, key: "tailnetDns"), gatewayPort: Self.txtIntValue(txt, key: "gatewayPort"), - bridgePort: Self.txtIntValue(txt, key: "bridgePort"), canvasPort: Self.txtIntValue(txt, key: "canvasPort"), - tlsEnabled: Self.txtBoolValue(txt, key: "bridgeTls"), - tlsFingerprintSha256: Self.txtValue(txt, key: "bridgeTlsSha256"), + tlsEnabled: Self.txtBoolValue(txt, key: "gatewayTls"), + tlsFingerprintSha256: Self.txtValue(txt, key: "gatewayTlsSha256"), cliPath: Self.txtValue(txt, key: "cliPath")) default: return nil @@ -101,12 +99,12 @@ final class BridgeDiscoveryModel { } .sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } - self.recomputeBridges() + self.recomputeGateways() } } self.browsers[domain] = browser - browser.start(queue: DispatchQueue(label: "com.clawdbot.ios.bridge-discovery.\(domain)")) + browser.start(queue: DispatchQueue(label: "com.clawdbot.ios.gateway-discovery.\(domain)")) } } @@ -116,14 +114,14 @@ final class BridgeDiscoveryModel { browser.cancel() } self.browsers = [:] - self.bridgesByDomain = [:] + self.gatewaysByDomain = [:] self.statesByDomain = [:] - self.bridges = [] + self.gateways = [] self.statusText = "Stopped" } - private func recomputeBridges() { - let next = self.bridgesByDomain.values + private func recomputeGateways() { + let next = self.gatewaysByDomain.values .flatMap(\.self) .sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } @@ -134,7 +132,7 @@ final class BridgeDiscoveryModel { self.appendDebugLog("results: total=\(next.count) added=\(added.count) removed=\(removed.count)") } self.lastStableIDs = nextIDs - self.bridges = next + self.gateways = next } private func updateStatusText() { diff --git a/apps/ios/Sources/Gateway/GatewaySettingsStore.swift b/apps/ios/Sources/Gateway/GatewaySettingsStore.swift new file mode 100644 index 000000000..faf03e520 --- /dev/null +++ b/apps/ios/Sources/Gateway/GatewaySettingsStore.swift @@ -0,0 +1,220 @@ +import Foundation + +enum GatewaySettingsStore { + private static let gatewayService = "com.clawdbot.gateway" + private static let legacyBridgeService = "com.clawdbot.bridge" + private static let nodeService = "com.clawdbot.node" + + private static let instanceIdDefaultsKey = "node.instanceId" + private static let preferredGatewayStableIDDefaultsKey = "gateway.preferredStableID" + private static let lastDiscoveredGatewayStableIDDefaultsKey = "gateway.lastDiscoveredStableID" + private static let manualEnabledDefaultsKey = "gateway.manual.enabled" + private static let manualHostDefaultsKey = "gateway.manual.host" + private static let manualPortDefaultsKey = "gateway.manual.port" + private static let manualTlsDefaultsKey = "gateway.manual.tls" + private static let discoveryDebugLogsDefaultsKey = "gateway.discovery.debugLogs" + + private static let legacyPreferredBridgeStableIDDefaultsKey = "bridge.preferredStableID" + private static let legacyLastDiscoveredBridgeStableIDDefaultsKey = "bridge.lastDiscoveredStableID" + private static let legacyManualEnabledDefaultsKey = "bridge.manual.enabled" + private static let legacyManualHostDefaultsKey = "bridge.manual.host" + private static let legacyManualPortDefaultsKey = "bridge.manual.port" + private static let legacyDiscoveryDebugLogsDefaultsKey = "bridge.discovery.debugLogs" + + private static let instanceIdAccount = "instanceId" + private static let preferredGatewayStableIDAccount = "preferredStableID" + private static let lastDiscoveredGatewayStableIDAccount = "lastDiscoveredStableID" + + static func bootstrapPersistence() { + self.ensureStableInstanceID() + self.ensurePreferredGatewayStableID() + self.ensureLastDiscoveredGatewayStableID() + self.migrateLegacyDefaults() + } + + static func loadStableInstanceID() -> String? { + KeychainStore.loadString(service: self.nodeService, account: self.instanceIdAccount)? + .trimmingCharacters(in: .whitespacesAndNewlines) + } + + static func saveStableInstanceID(_ instanceId: String) { + _ = KeychainStore.saveString(instanceId, service: self.nodeService, account: self.instanceIdAccount) + } + + static func loadPreferredGatewayStableID() -> String? { + KeychainStore.loadString(service: self.gatewayService, account: self.preferredGatewayStableIDAccount)? + .trimmingCharacters(in: .whitespacesAndNewlines) + } + + static func savePreferredGatewayStableID(_ stableID: String) { + _ = KeychainStore.saveString( + stableID, + service: self.gatewayService, + account: self.preferredGatewayStableIDAccount) + } + + static func loadLastDiscoveredGatewayStableID() -> String? { + KeychainStore.loadString(service: self.gatewayService, account: self.lastDiscoveredGatewayStableIDAccount)? + .trimmingCharacters(in: .whitespacesAndNewlines) + } + + static func saveLastDiscoveredGatewayStableID(_ stableID: String) { + _ = KeychainStore.saveString( + stableID, + service: self.gatewayService, + account: self.lastDiscoveredGatewayStableIDAccount) + } + + static func loadGatewayToken(instanceId: String) -> String? { + let account = self.gatewayTokenAccount(instanceId: instanceId) + let token = KeychainStore.loadString(service: self.gatewayService, account: account)? + .trimmingCharacters(in: .whitespacesAndNewlines) + if token?.isEmpty == false { return token } + + let legacyAccount = self.legacyBridgeTokenAccount(instanceId: instanceId) + let legacy = KeychainStore.loadString(service: self.legacyBridgeService, account: legacyAccount)? + .trimmingCharacters(in: .whitespacesAndNewlines) + if let legacy, !legacy.isEmpty { + _ = KeychainStore.saveString(legacy, service: self.gatewayService, account: account) + return legacy + } + return nil + } + + static func saveGatewayToken(_ token: String, instanceId: String) { + _ = KeychainStore.saveString( + token, + service: self.gatewayService, + account: self.gatewayTokenAccount(instanceId: instanceId)) + } + + static func loadGatewayPassword(instanceId: String) -> String? { + KeychainStore.loadString(service: self.gatewayService, account: self.gatewayPasswordAccount(instanceId: instanceId))? + .trimmingCharacters(in: .whitespacesAndNewlines) + } + + static func saveGatewayPassword(_ password: String, instanceId: String) { + _ = KeychainStore.saveString( + password, + service: self.gatewayService, + account: self.gatewayPasswordAccount(instanceId: instanceId)) + } + + private static func gatewayTokenAccount(instanceId: String) -> String { + "gateway-token.\(instanceId)" + } + + private static func legacyBridgeTokenAccount(instanceId: String) -> String { + "bridge-token.\(instanceId)" + } + + private static func gatewayPasswordAccount(instanceId: String) -> String { + "gateway-password.\(instanceId)" + } + + private static func ensureStableInstanceID() { + let defaults = UserDefaults.standard + + if let existing = defaults.string(forKey: self.instanceIdDefaultsKey)? + .trimmingCharacters(in: .whitespacesAndNewlines), + !existing.isEmpty + { + if self.loadStableInstanceID() == nil { + self.saveStableInstanceID(existing) + } + return + } + + if let stored = self.loadStableInstanceID(), !stored.isEmpty { + defaults.set(stored, forKey: self.instanceIdDefaultsKey) + return + } + + let fresh = UUID().uuidString + self.saveStableInstanceID(fresh) + defaults.set(fresh, forKey: self.instanceIdDefaultsKey) + } + + private static func ensurePreferredGatewayStableID() { + let defaults = UserDefaults.standard + + if let existing = defaults.string(forKey: self.preferredGatewayStableIDDefaultsKey)? + .trimmingCharacters(in: .whitespacesAndNewlines), + !existing.isEmpty + { + if self.loadPreferredGatewayStableID() == nil { + self.savePreferredGatewayStableID(existing) + } + return + } + + if let stored = self.loadPreferredGatewayStableID(), !stored.isEmpty { + defaults.set(stored, forKey: self.preferredGatewayStableIDDefaultsKey) + } + } + + private static func ensureLastDiscoveredGatewayStableID() { + let defaults = UserDefaults.standard + + if let existing = defaults.string(forKey: self.lastDiscoveredGatewayStableIDDefaultsKey)? + .trimmingCharacters(in: .whitespacesAndNewlines), + !existing.isEmpty + { + if self.loadLastDiscoveredGatewayStableID() == nil { + self.saveLastDiscoveredGatewayStableID(existing) + } + return + } + + if let stored = self.loadLastDiscoveredGatewayStableID(), !stored.isEmpty { + defaults.set(stored, forKey: self.lastDiscoveredGatewayStableIDDefaultsKey) + } + } + + private static func migrateLegacyDefaults() { + let defaults = UserDefaults.standard + + if defaults.string(forKey: self.preferredGatewayStableIDDefaultsKey)?.isEmpty != false, + let legacy = defaults.string(forKey: self.legacyPreferredBridgeStableIDDefaultsKey), + !legacy.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + { + defaults.set(legacy, forKey: self.preferredGatewayStableIDDefaultsKey) + self.savePreferredGatewayStableID(legacy) + } + + if defaults.string(forKey: self.lastDiscoveredGatewayStableIDDefaultsKey)?.isEmpty != false, + let legacy = defaults.string(forKey: self.legacyLastDiscoveredBridgeStableIDDefaultsKey), + !legacy.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + { + defaults.set(legacy, forKey: self.lastDiscoveredGatewayStableIDDefaultsKey) + self.saveLastDiscoveredGatewayStableID(legacy) + } + + if defaults.object(forKey: self.manualEnabledDefaultsKey) == nil, + defaults.object(forKey: self.legacyManualEnabledDefaultsKey) != nil + { + defaults.set(defaults.bool(forKey: self.legacyManualEnabledDefaultsKey), forKey: self.manualEnabledDefaultsKey) + } + + if defaults.string(forKey: self.manualHostDefaultsKey)?.isEmpty != false, + let legacy = defaults.string(forKey: self.legacyManualHostDefaultsKey), + !legacy.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + { + defaults.set(legacy, forKey: self.manualHostDefaultsKey) + } + + if defaults.integer(forKey: self.manualPortDefaultsKey) == 0, + defaults.integer(forKey: self.legacyManualPortDefaultsKey) > 0 + { + defaults.set(defaults.integer(forKey: self.legacyManualPortDefaultsKey), forKey: self.manualPortDefaultsKey) + } + + if defaults.object(forKey: self.discoveryDebugLogsDefaultsKey) == nil, + defaults.object(forKey: self.legacyDiscoveryDebugLogsDefaultsKey) != nil + { + defaults.set( + defaults.bool(forKey: self.legacyDiscoveryDebugLogsDefaultsKey), + forKey: self.discoveryDebugLogsDefaultsKey) + } + } +} diff --git a/apps/ios/Sources/Bridge/KeychainStore.swift b/apps/ios/Sources/Gateway/KeychainStore.swift similarity index 100% rename from apps/ios/Sources/Bridge/KeychainStore.swift rename to apps/ios/Sources/Gateway/KeychainStore.swift diff --git a/apps/ios/Sources/Info.plist b/apps/ios/Sources/Info.plist index a75e27741..e1ea41706 100644 --- a/apps/ios/Sources/Info.plist +++ b/apps/ios/Sources/Info.plist @@ -29,12 +29,12 @@ NSBonjourServices - _clawdbot-bridge._tcp + _clawdbot-gateway._tcp NSCameraUsageDescription - Clawdbot can capture photos or short video clips when requested via the bridge. + Clawdbot can capture photos or short video clips when requested via the gateway. NSLocalNetworkUsageDescription - Clawdbot discovers and connects to your Clawdbot bridge on the local network. + Clawdbot discovers and connects to your Clawdbot gateway on the local network. NSLocationAlwaysAndWhenInUseUsageDescription Clawdbot can share your location in the background when you enable Always. NSLocationWhenInUseUsageDescription diff --git a/apps/ios/Sources/Model/NodeAppModel.swift b/apps/ios/Sources/Model/NodeAppModel.swift index 258d79f4a..3e1a70ffb 100644 --- a/apps/ios/Sources/Model/NodeAppModel.swift +++ b/apps/ios/Sources/Model/NodeAppModel.swift @@ -18,15 +18,15 @@ final class NodeAppModel { let screen = ScreenController() let camera = CameraController() private let screenRecorder = ScreenRecordService() - var bridgeStatusText: String = "Offline" - var bridgeServerName: String? - var bridgeRemoteAddress: String? - var connectedBridgeID: String? + var gatewayStatusText: String = "Offline" + var gatewayServerName: String? + var gatewayRemoteAddress: String? + var connectedGatewayID: String? var seamColorHex: String? var mainSessionKey: String = "main" - private let bridge = BridgeSession() - private var bridgeTask: Task? + private let gateway = GatewayNodeSession() + private var gatewayTask: Task? private var voiceWakeSyncTask: Task? @ObservationIgnored private var cameraHUDDismissTask: Task? let voiceWake = VoiceWakeManager() @@ -34,7 +34,8 @@ final class NodeAppModel { private let locationService = LocationService() private var lastAutoA2uiURL: String? - var bridgeSession: BridgeSession { self.bridge } + private var gatewayConnected = false + var gatewaySession: GatewayNodeSession { self.gateway } var cameraHUDText: String? var cameraHUDKind: CameraHUDKind? @@ -54,7 +55,7 @@ final class NodeAppModel { let enabled = UserDefaults.standard.bool(forKey: "voiceWake.enabled") self.voiceWake.setEnabled(enabled) - self.talkMode.attachBridge(self.bridge) + self.talkMode.attachGateway(self.gateway) let talkEnabled = UserDefaults.standard.bool(forKey: "talk.enabled") self.talkMode.setEnabled(talkEnabled) @@ -120,9 +121,9 @@ final class NodeAppModel { let ok: Bool var errorText: String? - if await !self.isBridgeConnected() { + if await !self.isGatewayConnected() { ok = false - errorText = "bridge not connected" + errorText = "gateway not connected" } else { do { try await self.sendAgentRequest(link: AgentDeepLink( @@ -150,7 +151,7 @@ final class NodeAppModel { } private func resolveA2UIHostURL() async -> String? { - guard let raw = await self.bridge.currentCanvasHostUrl() else { return nil } + guard let raw = await self.gateway.currentCanvasHostUrl() else { return nil } let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty, let base = URL(string: trimmed) else { return nil } return base.appendingPathComponent("__clawdbot__/a2ui/").absoluteString + "?platform=ios" @@ -202,56 +203,70 @@ final class NodeAppModel { } } - func connectToBridge( - endpoint: NWEndpoint, - bridgeStableID: String, - tls: BridgeTLSParams?, - hello: BridgeHello) + func connectToGateway( + url: URL, + gatewayStableID: String, + tls: GatewayTLSParams?, + token: String?, + password: String?, + connectOptions: GatewayConnectOptions) { - self.bridgeTask?.cancel() - self.bridgeServerName = nil - self.bridgeRemoteAddress = nil - let id = bridgeStableID.trimmingCharacters(in: .whitespacesAndNewlines) - self.connectedBridgeID = id.isEmpty ? BridgeEndpointID.stableID(endpoint) : id + self.gatewayTask?.cancel() + self.gatewayServerName = nil + self.gatewayRemoteAddress = nil + let id = gatewayStableID.trimmingCharacters(in: .whitespacesAndNewlines) + self.connectedGatewayID = id.isEmpty ? url.absoluteString : id + self.gatewayConnected = false self.voiceWakeSyncTask?.cancel() self.voiceWakeSyncTask = nil + let sessionBox = tls.map { WebSocketSessionBox(session: GatewayTLSPinningSession(params: $0)) } - self.bridgeTask = Task { + self.gatewayTask = Task { var attempt = 0 while !Task.isCancelled { await MainActor.run { if attempt == 0 { - self.bridgeStatusText = "Connecting…" + self.gatewayStatusText = "Connecting…" } else { - self.bridgeStatusText = "Reconnecting…" + self.gatewayStatusText = "Reconnecting…" } - self.bridgeServerName = nil - self.bridgeRemoteAddress = nil + self.gatewayServerName = nil + self.gatewayRemoteAddress = nil } do { - try await self.bridge.connect( - endpoint: endpoint, - hello: hello, - tls: tls, - onConnected: { [weak self] serverName, mainSessionKey in + try await self.gateway.connect( + url: url, + token: token, + password: password, + connectOptions: connectOptions, + sessionBox: sessionBox, + onConnected: { [weak self] in guard let self else { return } await MainActor.run { - self.bridgeStatusText = "Connected" - self.bridgeServerName = serverName + self.gatewayStatusText = "Connected" + self.gatewayServerName = url.host ?? "gateway" + self.gatewayConnected = true } - await MainActor.run { - self.applyMainSessionKey(mainSessionKey) - } - if let addr = await self.bridge.currentRemoteAddress() { + if let addr = await self.gateway.currentRemoteAddress() { await MainActor.run { - self.bridgeRemoteAddress = addr + self.gatewayRemoteAddress = addr } } await self.refreshBrandingFromGateway() await self.startVoiceWakeSync() await self.showA2UIOnConnectIfNeeded() }, + onDisconnected: { [weak self] reason in + guard let self else { return } + await MainActor.run { + self.gatewayStatusText = "Disconnected" + self.gatewayRemoteAddress = nil + self.gatewayConnected = false + self.showLocalCanvasOnDisconnect() + } + self.gatewayStatusText = "Disconnected: \(reason)" + }, onInvoke: { [weak self] req in guard let self else { return BridgeInvokeResponse( @@ -265,19 +280,16 @@ final class NodeAppModel { }) if Task.isCancelled { break } - await MainActor.run { - self.showLocalCanvasOnDisconnect() - } - attempt += 1 - let sleepSeconds = min(6.0, 0.35 * pow(1.7, Double(attempt))) - try? await Task.sleep(nanoseconds: UInt64(sleepSeconds * 1_000_000_000)) + attempt = 0 + try? await Task.sleep(nanoseconds: 1_000_000_000) } catch { if Task.isCancelled { break } attempt += 1 await MainActor.run { - self.bridgeStatusText = "Bridge error: \(error.localizedDescription)" - self.bridgeServerName = nil - self.bridgeRemoteAddress = nil + self.gatewayStatusText = "Gateway error: \(error.localizedDescription)" + self.gatewayServerName = nil + self.gatewayRemoteAddress = nil + self.gatewayConnected = false self.showLocalCanvasOnDisconnect() } let sleepSeconds = min(8.0, 0.5 * pow(1.7, Double(attempt))) @@ -286,10 +298,11 @@ final class NodeAppModel { } await MainActor.run { - self.bridgeStatusText = "Offline" - self.bridgeServerName = nil - self.bridgeRemoteAddress = nil - self.connectedBridgeID = nil + self.gatewayStatusText = "Offline" + self.gatewayServerName = nil + self.gatewayRemoteAddress = nil + self.connectedGatewayID = nil + self.gatewayConnected = false self.seamColorHex = nil if !SessionKey.isCanonicalMainSessionKey(self.mainSessionKey) { self.mainSessionKey = "main" @@ -300,16 +313,17 @@ final class NodeAppModel { } } - func disconnectBridge() { - self.bridgeTask?.cancel() - self.bridgeTask = nil + func disconnectGateway() { + self.gatewayTask?.cancel() + self.gatewayTask = nil self.voiceWakeSyncTask?.cancel() self.voiceWakeSyncTask = nil - Task { await self.bridge.disconnect() } - self.bridgeStatusText = "Offline" - self.bridgeServerName = nil - self.bridgeRemoteAddress = nil - self.connectedBridgeID = nil + Task { await self.gateway.disconnect() } + self.gatewayStatusText = "Offline" + self.gatewayServerName = nil + self.gatewayRemoteAddress = nil + self.connectedGatewayID = nil + self.gatewayConnected = false self.seamColorHex = nil if !SessionKey.isCanonicalMainSessionKey(self.mainSessionKey) { self.mainSessionKey = "main" @@ -347,7 +361,7 @@ final class NodeAppModel { private func refreshBrandingFromGateway() async { do { - let res = try await self.bridge.request(method: "config.get", paramsJSON: "{}", timeoutSeconds: 8) + let res = try await self.gateway.request(method: "config.get", paramsJSON: "{}", timeoutSeconds: 8) guard let json = try JSONSerialization.jsonObject(with: res) as? [String: Any] else { return } guard let config = json["config"] as? [String: Any] else { return } let ui = config["ui"] as? [String: Any] @@ -378,7 +392,7 @@ final class NodeAppModel { else { return } do { - _ = try await self.bridge.request(method: "voicewake.set", paramsJSON: json, timeoutSeconds: 12) + _ = try await self.gateway.request(method: "voicewake.set", paramsJSON: json, timeoutSeconds: 12) } catch { // Best-effort only. } @@ -391,7 +405,7 @@ final class NodeAppModel { await self.refreshWakeWordsFromGateway() - let stream = await self.bridge.subscribeServerEvents(bufferingNewest: 200) + let stream = await self.gateway.subscribeServerEvents(bufferingNewest: 200) for await evt in stream { if Task.isCancelled { return } guard evt.event == "voicewake.changed" else { continue } @@ -404,7 +418,7 @@ final class NodeAppModel { private func refreshWakeWordsFromGateway() async { do { - let data = try await self.bridge.request(method: "voicewake.get", paramsJSON: "{}", timeoutSeconds: 8) + let data = try await self.gateway.request(method: "voicewake.get", paramsJSON: "{}", timeoutSeconds: 8) guard let triggers = VoiceWakePreferences.decodeGatewayTriggers(from: data) else { return } VoiceWakePreferences.saveTriggerWords(triggers) } catch { @@ -413,6 +427,11 @@ final class NodeAppModel { } func sendVoiceTranscript(text: String, sessionKey: String?) async throws { + if await !self.isGatewayConnected() { + throw NSError(domain: "Gateway", code: 10, userInfo: [ + NSLocalizedDescriptionKey: "Gateway not connected", + ]) + } struct Payload: Codable { var text: String var sessionKey: String? @@ -424,7 +443,7 @@ final class NodeAppModel { NSLocalizedDescriptionKey: "Failed to encode voice transcript payload as UTF-8", ]) } - try await self.bridge.sendEvent(event: "voice.transcript", payloadJSON: json) + await self.gateway.sendEvent(event: "voice.transcript", payloadJSON: json) } func handleDeepLink(url: URL) async { @@ -445,8 +464,8 @@ final class NodeAppModel { return } - guard await self.isBridgeConnected() else { - self.screen.errorText = "Bridge not connected (cannot forward deep link)." + guard await self.isGatewayConnected() else { + self.screen.errorText = "Gateway not connected (cannot forward deep link)." return } @@ -465,7 +484,7 @@ final class NodeAppModel { ]) } - // iOS bridge forwards to the gateway; no local auth prompts here. + // iOS gateway forwards to the gateway; no local auth prompts here. // (Key-based unattended auth is handled on macOS for clawdbot:// links.) let data = try JSONEncoder().encode(link) guard let json = String(bytes: data, encoding: .utf8) else { @@ -473,12 +492,11 @@ final class NodeAppModel { NSLocalizedDescriptionKey: "Failed to encode agent request payload as UTF-8", ]) } - try await self.bridge.sendEvent(event: "agent.request", payloadJSON: json) + await self.gateway.sendEvent(event: "agent.request", payloadJSON: json) } - private func isBridgeConnected() async -> Bool { - if case .connected = await self.bridge.state { return true } - return false + private func isGatewayConnected() async -> Bool { + self.gatewayConnected } private func handleInvoke(_ req: BridgeInvokeRequest) async -> BridgeInvokeResponse { @@ -849,7 +867,7 @@ final class NodeAppModel { private static func decodeParams(_ type: T.Type, from json: String?) throws -> T { guard let json, let data = json.data(using: .utf8) else { - throw NSError(domain: "Bridge", code: 20, userInfo: [ + throw NSError(domain: "Gateway", code: 20, userInfo: [ NSLocalizedDescriptionKey: "INVALID_REQUEST: paramsJSON required", ]) } diff --git a/apps/ios/Sources/RootCanvas.swift b/apps/ios/Sources/RootCanvas.swift index bd3fefc52..93cb81627 100644 --- a/apps/ios/Sources/RootCanvas.swift +++ b/apps/ios/Sources/RootCanvas.swift @@ -29,7 +29,7 @@ struct RootCanvas: View { ZStack { CanvasContent( systemColorScheme: self.systemColorScheme, - bridgeStatus: self.bridgeStatus, + gatewayStatus: self.gatewayStatus, voiceWakeEnabled: self.voiceWakeEnabled, voiceWakeToastText: self.voiceWakeToastText, cameraHUDText: self.appModel.cameraHUDText, @@ -52,7 +52,7 @@ struct RootCanvas: View { SettingsTab() case .chat: ChatSheet( - bridge: self.appModel.bridgeSession, + gateway: self.appModel.gatewaySession, sessionKey: self.appModel.mainSessionKey, userAccent: self.appModel.seamColor) } @@ -62,9 +62,9 @@ struct RootCanvas: View { .onChange(of: self.scenePhase) { _, _ in self.updateIdleTimer() } .onAppear { self.updateCanvasDebugStatus() } .onChange(of: self.canvasDebugStatusEnabled) { _, _ in self.updateCanvasDebugStatus() } - .onChange(of: self.appModel.bridgeStatusText) { _, _ in self.updateCanvasDebugStatus() } - .onChange(of: self.appModel.bridgeServerName) { _, _ in self.updateCanvasDebugStatus() } - .onChange(of: self.appModel.bridgeRemoteAddress) { _, _ in self.updateCanvasDebugStatus() } + .onChange(of: self.appModel.gatewayStatusText) { _, _ in self.updateCanvasDebugStatus() } + .onChange(of: self.appModel.gatewayServerName) { _, _ in self.updateCanvasDebugStatus() } + .onChange(of: self.appModel.gatewayRemoteAddress) { _, _ in self.updateCanvasDebugStatus() } .onChange(of: self.voiceWake.lastTriggeredCommand) { _, newValue in guard let newValue else { return } let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines) @@ -91,10 +91,10 @@ struct RootCanvas: View { } } - private var bridgeStatus: StatusPill.BridgeState { - if self.appModel.bridgeServerName != nil { return .connected } + private var gatewayStatus: StatusPill.GatewayState { + if self.appModel.gatewayServerName != nil { return .connected } - let text = self.appModel.bridgeStatusText.trimmingCharacters(in: .whitespacesAndNewlines) + let text = self.appModel.gatewayStatusText.trimmingCharacters(in: .whitespacesAndNewlines) if text.localizedCaseInsensitiveContains("connecting") || text.localizedCaseInsensitiveContains("reconnecting") { @@ -115,8 +115,8 @@ struct RootCanvas: View { private func updateCanvasDebugStatus() { self.appModel.screen.setDebugStatusEnabled(self.canvasDebugStatusEnabled) guard self.canvasDebugStatusEnabled else { return } - let title = self.appModel.bridgeStatusText.trimmingCharacters(in: .whitespacesAndNewlines) - let subtitle = self.appModel.bridgeServerName ?? self.appModel.bridgeRemoteAddress + let title = self.appModel.gatewayStatusText.trimmingCharacters(in: .whitespacesAndNewlines) + let subtitle = self.appModel.gatewayServerName ?? self.appModel.gatewayRemoteAddress self.appModel.screen.updateDebugStatus(title: title, subtitle: subtitle) } } @@ -126,7 +126,7 @@ private struct CanvasContent: View { @AppStorage("talk.enabled") private var talkEnabled: Bool = false @AppStorage("talk.button.enabled") private var talkButtonEnabled: Bool = true var systemColorScheme: ColorScheme - var bridgeStatus: StatusPill.BridgeState + var gatewayStatus: StatusPill.GatewayState var voiceWakeEnabled: Bool var voiceWakeToastText: String? var cameraHUDText: String? @@ -177,7 +177,7 @@ private struct CanvasContent: View { } .overlay(alignment: .topLeading) { StatusPill( - bridge: self.bridgeStatus, + gateway: self.gatewayStatus, voiceWakeEnabled: self.voiceWakeEnabled, activity: self.statusActivity, brighten: self.brightenButtons, @@ -208,15 +208,15 @@ private struct CanvasContent: View { tint: .orange) } - let bridgeStatus = self.appModel.bridgeStatusText.trimmingCharacters(in: .whitespacesAndNewlines) - let bridgeLower = bridgeStatus.lowercased() - if bridgeLower.contains("repair") { + let gatewayStatus = self.appModel.gatewayStatusText.trimmingCharacters(in: .whitespacesAndNewlines) + let gatewayLower = gatewayStatus.lowercased() + if gatewayLower.contains("repair") { return StatusPill.Activity(title: "Repairing…", systemImage: "wrench.and.screwdriver", tint: .orange) } - if bridgeLower.contains("approval") || bridgeLower.contains("pairing") { + if gatewayLower.contains("approval") || gatewayLower.contains("pairing") { return StatusPill.Activity(title: "Approval pending", systemImage: "person.crop.circle.badge.clock") } - // Avoid duplicating the primary bridge status ("Connecting…") in the activity slot. + // Avoid duplicating the primary gateway status ("Connecting…") in the activity slot. if self.appModel.screenRecordActive { return StatusPill.Activity(title: "Recording screen…", systemImage: "record.circle.fill", tint: .red) diff --git a/apps/ios/Sources/RootTabs.swift b/apps/ios/Sources/RootTabs.swift index e76d357a0..f7b3fd822 100644 --- a/apps/ios/Sources/RootTabs.swift +++ b/apps/ios/Sources/RootTabs.swift @@ -24,7 +24,7 @@ struct RootTabs: View { } .overlay(alignment: .topLeading) { StatusPill( - bridge: self.bridgeStatus, + gateway: self.gatewayStatus, voiceWakeEnabled: self.voiceWakeEnabled, activity: self.statusActivity, onTap: { self.selectedTab = 2 }) @@ -64,10 +64,10 @@ struct RootTabs: View { } } - private var bridgeStatus: StatusPill.BridgeState { - if self.appModel.bridgeServerName != nil { return .connected } + private var gatewayStatus: StatusPill.GatewayState { + if self.appModel.gatewayServerName != nil { return .connected } - let text = self.appModel.bridgeStatusText.trimmingCharacters(in: .whitespacesAndNewlines) + let text = self.appModel.gatewayStatusText.trimmingCharacters(in: .whitespacesAndNewlines) if text.localizedCaseInsensitiveContains("connecting") || text.localizedCaseInsensitiveContains("reconnecting") { @@ -90,15 +90,15 @@ struct RootTabs: View { tint: .orange) } - let bridgeStatus = self.appModel.bridgeStatusText.trimmingCharacters(in: .whitespacesAndNewlines) - let bridgeLower = bridgeStatus.lowercased() - if bridgeLower.contains("repair") { + let gatewayStatus = self.appModel.gatewayStatusText.trimmingCharacters(in: .whitespacesAndNewlines) + let gatewayLower = gatewayStatus.lowercased() + if gatewayLower.contains("repair") { return StatusPill.Activity(title: "Repairing…", systemImage: "wrench.and.screwdriver", tint: .orange) } - if bridgeLower.contains("approval") || bridgeLower.contains("pairing") { + if gatewayLower.contains("approval") || gatewayLower.contains("pairing") { return StatusPill.Activity(title: "Approval pending", systemImage: "person.crop.circle.badge.clock") } - // Avoid duplicating the primary bridge status ("Connecting…") in the activity slot. + // Avoid duplicating the primary gateway status ("Connecting…") in the activity slot. if self.appModel.screenRecordActive { return StatusPill.Activity(title: "Recording screen…", systemImage: "record.circle.fill", tint: .red) diff --git a/apps/ios/Sources/Settings/SettingsTab.swift b/apps/ios/Sources/Settings/SettingsTab.swift index bc474ff00..431761617 100644 --- a/apps/ios/Sources/Settings/SettingsTab.swift +++ b/apps/ios/Sources/Settings/SettingsTab.swift @@ -15,7 +15,7 @@ extension ConnectStatusStore: @unchecked Sendable {} struct SettingsTab: View { @Environment(NodeAppModel.self) private var appModel: NodeAppModel @Environment(VoiceWakeManager.self) private var voiceWake: VoiceWakeManager - @Environment(BridgeConnectionController.self) private var bridgeController: BridgeConnectionController + @Environment(GatewayConnectionController.self) private var gatewayController: GatewayConnectionController @Environment(\.dismiss) private var dismiss @AppStorage("node.displayName") private var displayName: String = "iOS Node" @AppStorage("node.instanceId") private var instanceId: String = UUID().uuidString @@ -26,17 +26,20 @@ struct SettingsTab: View { @AppStorage("location.enabledMode") private var locationEnabledModeRaw: String = ClawdbotLocationMode.off.rawValue @AppStorage("location.preciseEnabled") private var locationPreciseEnabled: Bool = true @AppStorage("screen.preventSleep") private var preventSleep: Bool = true - @AppStorage("bridge.preferredStableID") private var preferredBridgeStableID: String = "" - @AppStorage("bridge.lastDiscoveredStableID") private var lastDiscoveredBridgeStableID: String = "" - @AppStorage("bridge.manual.enabled") private var manualBridgeEnabled: Bool = false - @AppStorage("bridge.manual.host") private var manualBridgeHost: String = "" - @AppStorage("bridge.manual.port") private var manualBridgePort: Int = 18790 - @AppStorage("bridge.discovery.debugLogs") private var discoveryDebugLogsEnabled: Bool = false + @AppStorage("gateway.preferredStableID") private var preferredGatewayStableID: String = "" + @AppStorage("gateway.lastDiscoveredStableID") private var lastDiscoveredGatewayStableID: String = "" + @AppStorage("gateway.manual.enabled") private var manualGatewayEnabled: Bool = false + @AppStorage("gateway.manual.host") private var manualGatewayHost: String = "" + @AppStorage("gateway.manual.port") private var manualGatewayPort: Int = 18789 + @AppStorage("gateway.manual.tls") private var manualGatewayTLS: Bool = true + @AppStorage("gateway.discovery.debugLogs") private var discoveryDebugLogsEnabled: Bool = false @AppStorage("canvas.debugStatusEnabled") private var canvasDebugStatusEnabled: Bool = false @State private var connectStatus = ConnectStatusStore() - @State private var connectingBridgeID: String? + @State private var connectingGatewayID: String? @State private var localIPAddress: String? @State private var lastLocationModeRaw: String = ClawdbotLocationMode.off.rawValue + @State private var gatewayToken: String = "" + @State private var gatewayPassword: String = "" var body: some View { NavigationStack { @@ -61,12 +64,12 @@ struct SettingsTab: View { LabeledContent("Model", value: self.modelIdentifier()) } - Section("Bridge") { - LabeledContent("Discovery", value: self.bridgeController.discoveryStatusText) - LabeledContent("Status", value: self.appModel.bridgeStatusText) - if let serverName = self.appModel.bridgeServerName { + Section("Gateway") { + LabeledContent("Discovery", value: self.gatewayController.discoveryStatusText) + LabeledContent("Status", value: self.appModel.gatewayStatusText) + if let serverName = self.appModel.gatewayServerName { LabeledContent("Server", value: serverName) - if let addr = self.appModel.bridgeRemoteAddress { + if let addr = self.appModel.gatewayRemoteAddress { let parts = Self.parseHostPort(from: addr) let urlString = Self.httpURLString(host: parts?.host, port: parts?.port, fallback: addr) LabeledContent("Address") { @@ -96,12 +99,12 @@ struct SettingsTab: View { } Button("Disconnect", role: .destructive) { - self.appModel.disconnectBridge() + self.appModel.disconnectGateway() } - self.bridgeList(showing: .availableOnly) + self.gatewayList(showing: .availableOnly) } else { - self.bridgeList(showing: .all) + self.gatewayList(showing: .all) } if let text = self.connectStatus.text { @@ -111,19 +114,21 @@ struct SettingsTab: View { } DisclosureGroup("Advanced") { - Toggle("Use Manual Bridge", isOn: self.$manualBridgeEnabled) + Toggle("Use Manual Gateway", isOn: self.$manualGatewayEnabled) - TextField("Host", text: self.$manualBridgeHost) + TextField("Host", text: self.$manualGatewayHost) .textInputAutocapitalization(.never) .autocorrectionDisabled() - TextField("Port", value: self.$manualBridgePort, format: .number) + TextField("Port", value: self.$manualGatewayPort, format: .number) .keyboardType(.numberPad) + Toggle("Use TLS", isOn: self.$manualGatewayTLS) + Button { Task { await self.connectManual() } } label: { - if self.connectingBridgeID == "manual" { + if self.connectingGatewayID == "manual" { HStack(spacing: 8) { ProgressView() .progressViewStyle(.circular) @@ -133,26 +138,32 @@ struct SettingsTab: View { Text("Connect (Manual)") } } - .disabled(self.connectingBridgeID != nil || self.manualBridgeHost + .disabled(self.connectingGatewayID != nil || self.manualGatewayHost .trimmingCharacters(in: .whitespacesAndNewlines) - .isEmpty || self.manualBridgePort <= 0 || self.manualBridgePort > 65535) + .isEmpty || self.manualGatewayPort <= 0 || self.manualGatewayPort > 65535) Text( "Use this when mDNS/Bonjour discovery is blocked. " - + "The bridge runs on the gateway (default port 18790).") + + "The gateway WebSocket listens on port 18789 by default.") .font(.footnote) .foregroundStyle(.secondary) Toggle("Discovery Debug Logs", isOn: self.$discoveryDebugLogsEnabled) .onChange(of: self.discoveryDebugLogsEnabled) { _, newValue in - self.bridgeController.setDiscoveryDebugLoggingEnabled(newValue) + self.gatewayController.setDiscoveryDebugLoggingEnabled(newValue) } NavigationLink("Discovery Logs") { - BridgeDiscoveryDebugLogView() + GatewayDiscoveryDebugLogView() } Toggle("Debug Canvas Status", isOn: self.$canvasDebugStatusEnabled) + + TextField("Gateway Token", text: self.$gatewayToken) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + + SecureField("Gateway Password", text: self.$gatewayPassword) } } @@ -179,7 +190,7 @@ struct SettingsTab: View { Section("Camera") { Toggle("Allow Camera", isOn: self.$cameraEnabled) - Text("Allows the bridge to request photos or short video clips (foreground only).") + Text("Allows the gateway to request photos or short video clips (foreground only).") .font(.footnote) .foregroundStyle(.secondary) } @@ -221,13 +232,30 @@ struct SettingsTab: View { .onAppear { self.localIPAddress = Self.primaryIPv4Address() self.lastLocationModeRaw = self.locationEnabledModeRaw + let trimmedInstanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmedInstanceId.isEmpty { + self.gatewayToken = GatewaySettingsStore.loadGatewayToken(instanceId: trimmedInstanceId) ?? "" + self.gatewayPassword = GatewaySettingsStore.loadGatewayPassword(instanceId: trimmedInstanceId) ?? "" + } } - .onChange(of: self.preferredBridgeStableID) { _, newValue in + .onChange(of: self.preferredGatewayStableID) { _, newValue in let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return } - BridgeSettingsStore.savePreferredBridgeStableID(trimmed) + GatewaySettingsStore.savePreferredGatewayStableID(trimmed) } - .onChange(of: self.appModel.bridgeServerName) { _, _ in + .onChange(of: self.gatewayToken) { _, newValue in + let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines) + let instanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines) + guard !instanceId.isEmpty else { return } + GatewaySettingsStore.saveGatewayToken(trimmed, instanceId: instanceId) + } + .onChange(of: self.gatewayPassword) { _, newValue in + let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines) + let instanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines) + guard !instanceId.isEmpty else { return } + GatewaySettingsStore.saveGatewayPassword(trimmed, instanceId: instanceId) + } + .onChange(of: self.appModel.gatewayServerName) { _, _ in self.connectStatus.text = nil } .onChange(of: self.locationEnabledModeRaw) { _, newValue in @@ -248,14 +276,14 @@ struct SettingsTab: View { } @ViewBuilder - private func bridgeList(showing: BridgeListMode) -> some View { - if self.bridgeController.bridges.isEmpty { - Text("No bridges found yet.") + private func gatewayList(showing: GatewayListMode) -> some View { + if self.gatewayController.gateways.isEmpty { + Text("No gateways found yet.") .foregroundStyle(.secondary) } else { - let connectedID = self.appModel.connectedBridgeID - let rows = self.bridgeController.bridges.filter { bridge in - let isConnected = bridge.stableID == connectedID + let connectedID = self.appModel.connectedGatewayID + let rows = self.gatewayController.gateways.filter { gateway in + let isConnected = gateway.stableID == connectedID switch showing { case .all: return true @@ -265,14 +293,14 @@ struct SettingsTab: View { } if rows.isEmpty, showing == .availableOnly { - Text("No other bridges found.") + Text("No other gateways found.") .foregroundStyle(.secondary) } else { - ForEach(rows) { bridge in + ForEach(rows) { gateway in HStack { VStack(alignment: .leading, spacing: 2) { - Text(bridge.name) - let detailLines = self.bridgeDetailLines(bridge) + Text(gateway.name) + let detailLines = self.gatewayDetailLines(gateway) ForEach(detailLines, id: \.self) { line in Text(line) .font(.footnote) @@ -282,31 +310,27 @@ struct SettingsTab: View { Spacer() Button { - Task { await self.connect(bridge) } + Task { await self.connect(gateway) } } label: { - if self.connectingBridgeID == bridge.id { + if self.connectingGatewayID == gateway.id { ProgressView() .progressViewStyle(.circular) } else { Text("Connect") } } - .disabled(self.connectingBridgeID != nil) + .disabled(self.connectingGatewayID != nil) } } } } } - private enum BridgeListMode: Equatable { + private enum GatewayListMode: Equatable { case all case availableOnly } - private func keychainAccount() -> String { - "bridge-token.\(self.instanceId)" - } - private func platformString() -> String { let v = ProcessInfo.processInfo.operatingSystemVersion return "iOS \(v.majorVersion).\(v.minorVersion).\(v.patchVersion)" @@ -341,228 +365,37 @@ struct SettingsTab: View { return trimmed.isEmpty ? "unknown" : trimmed } - private func currentCaps() -> [String] { - var caps = [ClawdbotCapability.canvas.rawValue, ClawdbotCapability.screen.rawValue] + private func connect(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) async { + self.connectingGatewayID = gateway.id + self.manualGatewayEnabled = false + self.preferredGatewayStableID = gateway.stableID + GatewaySettingsStore.savePreferredGatewayStableID(gateway.stableID) + self.lastDiscoveredGatewayStableID = gateway.stableID + GatewaySettingsStore.saveLastDiscoveredGatewayStableID(gateway.stableID) + defer { self.connectingGatewayID = nil } - let cameraEnabled = - UserDefaults.standard.object(forKey: "camera.enabled") == nil - ? true - : UserDefaults.standard.bool(forKey: "camera.enabled") - if cameraEnabled { caps.append(ClawdbotCapability.camera.rawValue) } - - let voiceWakeEnabled = UserDefaults.standard.bool(forKey: VoiceWakePreferences.enabledKey) - if voiceWakeEnabled { caps.append(ClawdbotCapability.voiceWake.rawValue) } - - return caps - } - - private func currentCommands() -> [String] { - var commands: [String] = [ - ClawdbotCanvasCommand.present.rawValue, - ClawdbotCanvasCommand.hide.rawValue, - ClawdbotCanvasCommand.navigate.rawValue, - ClawdbotCanvasCommand.evalJS.rawValue, - ClawdbotCanvasCommand.snapshot.rawValue, - ClawdbotCanvasA2UICommand.push.rawValue, - ClawdbotCanvasA2UICommand.pushJSONL.rawValue, - ClawdbotCanvasA2UICommand.reset.rawValue, - ClawdbotScreenCommand.record.rawValue, - ] - - let caps = Set(self.currentCaps()) - if caps.contains(ClawdbotCapability.camera.rawValue) { - commands.append(ClawdbotCameraCommand.list.rawValue) - commands.append(ClawdbotCameraCommand.snap.rawValue) - commands.append(ClawdbotCameraCommand.clip.rawValue) - } - - return commands - } - - private func connect(_ bridge: BridgeDiscoveryModel.DiscoveredBridge) async { - self.connectingBridgeID = bridge.id - self.manualBridgeEnabled = false - self.preferredBridgeStableID = bridge.stableID - BridgeSettingsStore.savePreferredBridgeStableID(bridge.stableID) - self.lastDiscoveredBridgeStableID = bridge.stableID - BridgeSettingsStore.saveLastDiscoveredBridgeStableID(bridge.stableID) - defer { self.connectingBridgeID = nil } - - do { - let statusStore = self.connectStatus - let existing = KeychainStore.loadString( - service: "com.clawdbot.bridge", - account: self.keychainAccount()) - let existingToken = (existing?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false) ? - existing : - nil - - let hello = BridgeHello( - nodeId: self.instanceId, - displayName: self.displayName, - token: existingToken, - platform: self.platformString(), - version: self.appVersion(), - deviceFamily: self.deviceFamily(), - modelIdentifier: self.modelIdentifier(), - caps: self.currentCaps(), - commands: self.currentCommands()) - let tlsParams = self.resolveDiscoveredTLSParams(bridge: bridge) - let token = try await BridgeClient().pairAndHello( - endpoint: bridge.endpoint, - hello: hello, - tls: tlsParams, - onStatus: { status in - Task { @MainActor in - statusStore.text = status - } - }) - - if !token.isEmpty, token != existingToken { - _ = KeychainStore.saveString( - token, - service: "com.clawdbot.bridge", - account: self.keychainAccount()) - } - - self.appModel.connectToBridge( - endpoint: bridge.endpoint, - bridgeStableID: bridge.stableID, - tls: tlsParams, - hello: BridgeHello( - nodeId: self.instanceId, - displayName: self.displayName, - token: token, - platform: self.platformString(), - version: self.appVersion(), - deviceFamily: self.deviceFamily(), - modelIdentifier: self.modelIdentifier(), - caps: self.currentCaps(), - commands: self.currentCommands())) - - } catch { - self.connectStatus.text = "Failed: \(error.localizedDescription)" - } + await self.gatewayController.connect(gateway) } private func connectManual() async { - let host = self.manualBridgeHost.trimmingCharacters(in: .whitespacesAndNewlines) + let host = self.manualGatewayHost.trimmingCharacters(in: .whitespacesAndNewlines) guard !host.isEmpty else { self.connectStatus.text = "Failed: host required" return } - guard self.manualBridgePort > 0, self.manualBridgePort <= 65535 else { - self.connectStatus.text = "Failed: invalid port" - return - } - guard let port = NWEndpoint.Port(rawValue: UInt16(self.manualBridgePort)) else { + guard self.manualGatewayPort > 0, self.manualGatewayPort <= 65535 else { self.connectStatus.text = "Failed: invalid port" return } - self.connectingBridgeID = "manual" - self.manualBridgeEnabled = true - defer { self.connectingBridgeID = nil } + self.connectingGatewayID = "manual" + self.manualGatewayEnabled = true + defer { self.connectingGatewayID = nil } - let endpoint: NWEndpoint = .hostPort(host: NWEndpoint.Host(host), port: port) - let stableID = BridgeEndpointID.stableID(endpoint) - let tlsParams = self.resolveManualTLSParams(stableID: stableID) - - do { - let statusStore = self.connectStatus - let existing = KeychainStore.loadString( - service: "com.clawdbot.bridge", - account: self.keychainAccount()) - let existingToken = (existing?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false) ? - existing : - nil - - let hello = BridgeHello( - nodeId: self.instanceId, - displayName: self.displayName, - token: existingToken, - platform: self.platformString(), - version: self.appVersion(), - deviceFamily: self.deviceFamily(), - modelIdentifier: self.modelIdentifier(), - caps: self.currentCaps(), - commands: self.currentCommands()) - let token = try await BridgeClient().pairAndHello( - endpoint: endpoint, - hello: hello, - tls: tlsParams, - onStatus: { status in - Task { @MainActor in - statusStore.text = status - } - }) - - if !token.isEmpty, token != existingToken { - _ = KeychainStore.saveString( - token, - service: "com.clawdbot.bridge", - account: self.keychainAccount()) - } - - self.appModel.connectToBridge( - endpoint: endpoint, - bridgeStableID: stableID, - tls: tlsParams, - hello: BridgeHello( - nodeId: self.instanceId, - displayName: self.displayName, - token: token, - platform: self.platformString(), - version: self.appVersion(), - deviceFamily: self.deviceFamily(), - modelIdentifier: self.modelIdentifier(), - caps: self.currentCaps(), - commands: self.currentCommands())) - - } catch { - self.connectStatus.text = "Failed: \(error.localizedDescription)" - } - } - - private func resolveDiscoveredTLSParams( - bridge: BridgeDiscoveryModel.DiscoveredBridge) -> BridgeTLSParams? - { - let stableID = bridge.stableID - let stored = BridgeTLSStore.loadFingerprint(stableID: stableID) - - if bridge.tlsEnabled || bridge.tlsFingerprintSha256 != nil { - return BridgeTLSParams( - required: true, - expectedFingerprint: bridge.tlsFingerprintSha256 ?? stored, - allowTOFU: stored == nil, - storeKey: stableID) - } - - if let stored { - return BridgeTLSParams( - required: true, - expectedFingerprint: stored, - allowTOFU: false, - storeKey: stableID) - } - - return nil - } - - private func resolveManualTLSParams(stableID: String) -> BridgeTLSParams? { - if let stored = BridgeTLSStore.loadFingerprint(stableID: stableID) { - return BridgeTLSParams( - required: true, - expectedFingerprint: stored, - allowTOFU: false, - storeKey: stableID) - } - - return BridgeTLSParams( - required: false, - expectedFingerprint: nil, - allowTOFU: true, - storeKey: stableID) + await self.gatewayController.connectManual( + host: host, + port: self.manualGatewayPort, + useTLS: self.manualGatewayTLS) } private static func primaryIPv4Address() -> String? { @@ -611,23 +444,21 @@ struct SettingsTab: View { SettingsNetworkingHelpers.httpURLString(host: host, port: port, fallback: fallback) } - private func bridgeDetailLines(_ bridge: BridgeDiscoveryModel.DiscoveredBridge) -> [String] { + private func gatewayDetailLines(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) -> [String] { var lines: [String] = [] - if let lanHost = bridge.lanHost { lines.append("LAN: \(lanHost)") } - if let tailnet = bridge.tailnetDns { lines.append("Tailnet: \(tailnet)") } + if let lanHost = gateway.lanHost { lines.append("LAN: \(lanHost)") } + if let tailnet = gateway.tailnetDns { lines.append("Tailnet: \(tailnet)") } - let gatewayPort = bridge.gatewayPort - let bridgePort = bridge.bridgePort - let canvasPort = bridge.canvasPort - if gatewayPort != nil || bridgePort != nil || canvasPort != nil { + let gatewayPort = gateway.gatewayPort + let canvasPort = gateway.canvasPort + if gatewayPort != nil || canvasPort != nil { let gw = gatewayPort.map(String.init) ?? "—" - let br = bridgePort.map(String.init) ?? "—" let canvas = canvasPort.map(String.init) ?? "—" - lines.append("Ports: gw \(gw) · bridge \(br) · canvas \(canvas)") + lines.append("Ports: gateway \(gw) · canvas \(canvas)") } if lines.isEmpty { - lines.append(bridge.debugID) + lines.append(gateway.debugID) } return lines diff --git a/apps/ios/Sources/Settings/VoiceWakeWordsSettingsView.swift b/apps/ios/Sources/Settings/VoiceWakeWordsSettingsView.swift index 405444534..d13edafe2 100644 --- a/apps/ios/Sources/Settings/VoiceWakeWordsSettingsView.swift +++ b/apps/ios/Sources/Settings/VoiceWakeWordsSettingsView.swift @@ -42,7 +42,7 @@ struct VoiceWakeWordsSettingsView: View { } } .onChange(of: self.triggerWords) { _, newValue in - // Keep local voice wake responsive even if bridge isn't connected yet. + // Keep local voice wake responsive even if the gateway isn't connected yet. VoiceWakePreferences.saveTriggerWords(newValue) let snapshot = VoiceWakePreferences.sanitizeTriggerWords(newValue) diff --git a/apps/ios/Sources/Status/StatusPill.swift b/apps/ios/Sources/Status/StatusPill.swift index 1e30ad16d..cd81c011b 100644 --- a/apps/ios/Sources/Status/StatusPill.swift +++ b/apps/ios/Sources/Status/StatusPill.swift @@ -3,7 +3,7 @@ import SwiftUI struct StatusPill: View { @Environment(\.scenePhase) private var scenePhase - enum BridgeState: Equatable { + enum GatewayState: Equatable { case connected case connecting case error @@ -34,7 +34,7 @@ struct StatusPill: View { var tint: Color? } - var bridge: BridgeState + var gateway: GatewayState var voiceWakeEnabled: Bool var activity: Activity? var brighten: Bool = false @@ -47,12 +47,12 @@ struct StatusPill: View { HStack(spacing: 10) { HStack(spacing: 8) { Circle() - .fill(self.bridge.color) + .fill(self.gateway.color) .frame(width: 9, height: 9) - .scaleEffect(self.bridge == .connecting ? (self.pulse ? 1.15 : 0.85) : 1.0) - .opacity(self.bridge == .connecting ? (self.pulse ? 1.0 : 0.6) : 1.0) + .scaleEffect(self.gateway == .connecting ? (self.pulse ? 1.15 : 0.85) : 1.0) + .opacity(self.gateway == .connecting ? (self.pulse ? 1.0 : 0.6) : 1.0) - Text(self.bridge.title) + Text(self.gateway.title) .font(.system(size: 13, weight: .semibold)) .foregroundStyle(.primary) } @@ -95,26 +95,26 @@ struct StatusPill: View { .buttonStyle(.plain) .accessibilityLabel("Status") .accessibilityValue(self.accessibilityValue) - .onAppear { self.updatePulse(for: self.bridge, scenePhase: self.scenePhase) } + .onAppear { self.updatePulse(for: self.gateway, scenePhase: self.scenePhase) } .onDisappear { self.pulse = false } - .onChange(of: self.bridge) { _, newValue in + .onChange(of: self.gateway) { _, newValue in self.updatePulse(for: newValue, scenePhase: self.scenePhase) } .onChange(of: self.scenePhase) { _, newValue in - self.updatePulse(for: self.bridge, scenePhase: newValue) + self.updatePulse(for: self.gateway, scenePhase: newValue) } .animation(.easeInOut(duration: 0.18), value: self.activity?.title) } private var accessibilityValue: String { if let activity { - return "\(self.bridge.title), \(activity.title)" + return "\(self.gateway.title), \(activity.title)" } - return "\(self.bridge.title), Voice Wake \(self.voiceWakeEnabled ? "enabled" : "disabled")" + return "\(self.gateway.title), Voice Wake \(self.voiceWakeEnabled ? "enabled" : "disabled")" } - private func updatePulse(for bridge: BridgeState, scenePhase: ScenePhase) { - guard bridge == .connecting, scenePhase == .active else { + private func updatePulse(for gateway: GatewayState, scenePhase: ScenePhase) { + guard gateway == .connecting, scenePhase == .active else { withAnimation(.easeOut(duration: 0.2)) { self.pulse = false } return } diff --git a/apps/ios/Sources/Voice/TalkModeManager.swift b/apps/ios/Sources/Voice/TalkModeManager.swift index 9c0e1303d..082ed3d89 100644 --- a/apps/ios/Sources/Voice/TalkModeManager.swift +++ b/apps/ios/Sources/Voice/TalkModeManager.swift @@ -1,5 +1,6 @@ import AVFAudio import ClawdbotKit +import ClawdbotProtocol import Foundation import Observation import OSLog @@ -42,15 +43,15 @@ final class TalkModeManager: NSObject { var pcmPlayer: PCMStreamingAudioPlaying = PCMStreamingAudioPlayer.shared var mp3Player: StreamingAudioPlaying = StreamingAudioPlayer.shared - private var bridge: BridgeSession? + private var gateway: GatewayNodeSession? private let silenceWindow: TimeInterval = 0.7 private var chatSubscribedSessionKeys = Set() private let logger = Logger(subsystem: "com.clawdbot", category: "TalkMode") - func attachBridge(_ bridge: BridgeSession) { - self.bridge = bridge + func attachGateway(_ gateway: GatewayNodeSession) { + self.gateway = gateway } func updateMainSessionKey(_ sessionKey: String?) { @@ -232,9 +233,9 @@ final class TalkModeManager: NSObject { await self.reloadConfig() let prompt = self.buildPrompt(transcript: transcript) - guard let bridge else { - self.statusText = "Bridge not connected" - self.logger.warning("finalize: bridge not connected") + guard let gateway else { + self.statusText = "Gateway not connected" + self.logger.warning("finalize: gateway not connected") await self.start() return } @@ -245,9 +246,9 @@ final class TalkModeManager: NSObject { await self.subscribeChatIfNeeded(sessionKey: sessionKey) self.logger.info( "chat.send start sessionKey=\(sessionKey, privacy: .public) chars=\(prompt.count, privacy: .public)") - let runId = try await self.sendChat(prompt, bridge: bridge) + let runId = try await self.sendChat(prompt, gateway: gateway) self.logger.info("chat.send ok runId=\(runId, privacy: .public)") - let completion = await self.waitForChatCompletion(runId: runId, bridge: bridge, timeoutSeconds: 120) + let completion = await self.waitForChatCompletion(runId: runId, gateway: gateway, timeoutSeconds: 120) if completion == .timeout { self.logger.warning( "chat completion timeout runId=\(runId, privacy: .public); attempting history fallback") @@ -264,7 +265,7 @@ final class TalkModeManager: NSObject { } guard let assistantText = try await self.waitForAssistantText( - bridge: bridge, + gateway: gateway, since: startedAt, timeoutSeconds: completion == .final ? 12 : 25) else { @@ -286,31 +287,22 @@ final class TalkModeManager: NSObject { private func subscribeChatIfNeeded(sessionKey: String) async { let key = sessionKey.trimmingCharacters(in: .whitespacesAndNewlines) guard !key.isEmpty else { return } - guard let bridge else { return } + guard let gateway else { return } guard !self.chatSubscribedSessionKeys.contains(key) else { return } - do { - let payload = "{\"sessionKey\":\"\(key)\"}" - try await bridge.sendEvent(event: "chat.subscribe", payloadJSON: payload) - self.chatSubscribedSessionKeys.insert(key) - self.logger.info("chat.subscribe ok sessionKey=\(key, privacy: .public)") - } catch { - let err = error.localizedDescription - self.logger.warning("chat.subscribe failed key=\(key, privacy: .public) err=\(err, privacy: .public)") - } + let payload = "{\"sessionKey\":\"\(key)\"}" + await gateway.sendEvent(event: "chat.subscribe", payloadJSON: payload) + self.chatSubscribedSessionKeys.insert(key) + self.logger.info("chat.subscribe ok sessionKey=\(key, privacy: .public)") } private func unsubscribeAllChats() async { - guard let bridge else { return } + guard let gateway else { return } let keys = self.chatSubscribedSessionKeys self.chatSubscribedSessionKeys.removeAll() for key in keys { - do { - let payload = "{\"sessionKey\":\"\(key)\"}" - try await bridge.sendEvent(event: "chat.unsubscribe", payloadJSON: payload) - } catch { - // ignore - } + let payload = "{\"sessionKey\":\"\(key)\"}" + await gateway.sendEvent(event: "chat.unsubscribe", payloadJSON: payload) } } @@ -336,7 +328,7 @@ final class TalkModeManager: NSObject { } } - private func sendChat(_ message: String, bridge: BridgeSession) async throws -> String { + private func sendChat(_ message: String, gateway: GatewayNodeSession) async throws -> String { struct SendResponse: Decodable { let runId: String } let payload: [String: Any] = [ "sessionKey": self.mainSessionKey, @@ -352,26 +344,27 @@ final class TalkModeManager: NSObject { code: 1, userInfo: [NSLocalizedDescriptionKey: "Failed to encode chat payload"]) } - let res = try await bridge.request(method: "chat.send", paramsJSON: json, timeoutSeconds: 30) + let res = try await gateway.request(method: "chat.send", paramsJSON: json, timeoutSeconds: 30) let decoded = try JSONDecoder().decode(SendResponse.self, from: res) return decoded.runId } private func waitForChatCompletion( runId: String, - bridge: BridgeSession, + gateway: GatewayNodeSession, timeoutSeconds: Int = 120) async -> ChatCompletionState { - let stream = await bridge.subscribeServerEvents(bufferingNewest: 200) + let stream = await gateway.subscribeServerEvents(bufferingNewest: 200) return await withTaskGroup(of: ChatCompletionState.self) { group in group.addTask { [runId] in for await evt in stream { if Task.isCancelled { return .timeout } - guard evt.event == "chat", let payload = evt.payloadJSON else { continue } - guard let data = payload.data(using: .utf8) else { continue } - guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { continue } - if (json["runId"] as? String) != runId { continue } - if let state = json["state"] as? String { + guard evt.event == "chat", let payload = evt.payload else { continue } + guard let chatEvent = try? GatewayPayloadDecoding.decode(payload, as: ChatEvent.self) else { + continue + } + guard chatEvent.runid == runId else { continue } + if let state = chatEvent.state.value as? String { switch state { case "final": return .final case "aborted": return .aborted @@ -393,13 +386,13 @@ final class TalkModeManager: NSObject { } private func waitForAssistantText( - bridge: BridgeSession, + gateway: GatewayNodeSession, since: Double, timeoutSeconds: Int) async throws -> String? { let deadline = Date().addingTimeInterval(TimeInterval(timeoutSeconds)) while Date() < deadline { - if let text = try await self.fetchLatestAssistantText(bridge: bridge, since: since) { + if let text = try await self.fetchLatestAssistantText(gateway: gateway, since: since) { return text } try? await Task.sleep(nanoseconds: 300_000_000) @@ -407,8 +400,8 @@ final class TalkModeManager: NSObject { return nil } - private func fetchLatestAssistantText(bridge: BridgeSession, since: Double? = nil) async throws -> String? { - let res = try await bridge.request( + private func fetchLatestAssistantText(gateway: GatewayNodeSession, since: Double? = nil) async throws -> String? { + let res = try await gateway.request( method: "chat.history", paramsJSON: "{\"sessionKey\":\"\(self.mainSessionKey)\"}", timeoutSeconds: 15) @@ -649,9 +642,9 @@ final class TalkModeManager: NSObject { } private func reloadConfig() async { - guard let bridge else { return } + guard let gateway else { return } do { - let res = try await bridge.request(method: "config.get", paramsJSON: "{}", timeoutSeconds: 8) + let res = try await gateway.request(method: "config.get", paramsJSON: "{}", timeoutSeconds: 8) guard let json = try JSONSerialization.jsonObject(with: res) as? [String: Any] else { return } guard let config = json["config"] as? [String: Any] else { return } let talk = config["talk"] as? [String: Any] diff --git a/apps/ios/Tests/BridgeClientTests.swift b/apps/ios/Tests/BridgeClientTests.swift deleted file mode 100644 index 21869b1cf..000000000 --- a/apps/ios/Tests/BridgeClientTests.swift +++ /dev/null @@ -1,196 +0,0 @@ -import ClawdbotKit -import Foundation -import Network -import Testing -@testable import Clawdbot - -@Suite struct BridgeClientTests { - private final class LineServer: @unchecked Sendable { - private let queue = DispatchQueue(label: "com.clawdbot.tests.bridge-client-server") - private let listener: NWListener - private var connection: NWConnection? - private var buffer = Data() - - init() throws { - self.listener = try NWListener(using: .tcp, on: .any) - } - - func start() async throws -> NWEndpoint.Port { - try await withCheckedThrowingContinuation(isolation: nil) { cont in - self.listener.stateUpdateHandler = { state in - switch state { - case .ready: - if let port = self.listener.port { - cont.resume(returning: port) - } else { - cont.resume( - throwing: NSError(domain: "LineServer", code: 1, userInfo: [ - NSLocalizedDescriptionKey: "listener missing port", - ])) - } - case let .failed(err): - cont.resume(throwing: err) - default: - break - } - } - - self.listener.newConnectionHandler = { [weak self] conn in - guard let self else { return } - self.connection = conn - conn.start(queue: self.queue) - } - - self.listener.start(queue: self.queue) - } - } - - func stop() { - self.connection?.cancel() - self.connection = nil - self.listener.cancel() - } - - func waitForConnection(timeoutMs: Int = 2000) async throws -> NWConnection { - let deadline = Date().addingTimeInterval(Double(timeoutMs) / 1000.0) - while Date() < deadline { - if let connection = self.connection { return connection } - try await Task.sleep(nanoseconds: 10_000_000) - } - throw NSError(domain: "LineServer", code: 2, userInfo: [ - NSLocalizedDescriptionKey: "timed out waiting for connection", - ]) - } - - func receiveLine(timeoutMs: Int = 2000) async throws -> Data? { - let connection = try await self.waitForConnection(timeoutMs: timeoutMs) - let deadline = Date().addingTimeInterval(Double(timeoutMs) / 1000.0) - - while Date() < deadline { - if let idx = self.buffer.firstIndex(of: 0x0A) { - let line = self.buffer.prefix(upTo: idx) - self.buffer.removeSubrange(...idx) - return Data(line) - } - - let chunk = try await withCheckedThrowingContinuation(isolation: nil) { (cont: CheckedContinuation< - Data, - Error, - >) in - connection - .receive(minimumIncompleteLength: 1, maximumLength: 64 * 1024) { data, _, isComplete, error in - if let error { - cont.resume(throwing: error) - return - } - if isComplete { - cont.resume(returning: Data()) - return - } - cont.resume(returning: data ?? Data()) - } - } - - if chunk.isEmpty { return nil } - self.buffer.append(chunk) - } - - throw NSError(domain: "LineServer", code: 3, userInfo: [ - NSLocalizedDescriptionKey: "timed out waiting for line", - ]) - } - - func sendLine(_ line: String) async throws { - let connection = try await self.waitForConnection() - var data = Data(line.utf8) - data.append(0x0A) - try await withCheckedThrowingContinuation(isolation: nil) { (cont: CheckedContinuation) in - connection.send(content: data, completion: .contentProcessed { err in - if let err { cont.resume(throwing: err) } else { cont.resume(returning: ()) } - }) - } - } - } - - @Test func helloOkReturnsExistingToken() async throws { - let server = try LineServer() - let port = try await server.start() - defer { server.stop() } - - let serverTask = Task { - let line = try await server.receiveLine() - #expect(line != nil) - _ = try JSONDecoder().decode(BridgeHello.self, from: line ?? Data()) - try await server.sendLine(#"{"type":"hello-ok","serverName":"Test Gateway"}"#) - } - defer { serverTask.cancel() } - - let client = BridgeClient() - let token = try await client.pairAndHello( - endpoint: .hostPort(host: NWEndpoint.Host("127.0.0.1"), port: port), - hello: BridgeHello( - nodeId: "ios-node", - displayName: "iOS", - token: "existing-token", - platform: "ios", - version: "1"), - onStatus: nil) - - #expect(token == "existing-token") - _ = try await serverTask.value - } - - @Test func notPairedTriggersPairRequestAndReturnsToken() async throws { - let server = try LineServer() - let port = try await server.start() - defer { server.stop() } - - let serverTask = Task { - let helloLine = try await server.receiveLine() - #expect(helloLine != nil) - _ = try JSONDecoder().decode(BridgeHello.self, from: helloLine ?? Data()) - try await server.sendLine(#"{"type":"error","code":"NOT_PAIRED","message":"not paired"}"#) - - let pairLine = try await server.receiveLine() - #expect(pairLine != nil) - _ = try JSONDecoder().decode(BridgePairRequest.self, from: pairLine ?? Data()) - try await server.sendLine(#"{"type":"pair-ok","token":"paired-token"}"#) - } - defer { serverTask.cancel() } - - let client = BridgeClient() - let token = try await client.pairAndHello( - endpoint: .hostPort(host: NWEndpoint.Host("127.0.0.1"), port: port), - hello: BridgeHello(nodeId: "ios-node", displayName: "iOS", token: nil, platform: "ios", version: "1"), - onStatus: nil) - - #expect(token == "paired-token") - _ = try await serverTask.value - } - - @Test func unexpectedErrorIsSurfaced() async { - do { - let server = try LineServer() - let port = try await server.start() - defer { server.stop() } - - let serverTask = Task { - let helloLine = try await server.receiveLine() - #expect(helloLine != nil) - _ = try JSONDecoder().decode(BridgeHello.self, from: helloLine ?? Data()) - try await server.sendLine(#"{"type":"error","code":"NOPE","message":"nope"}"#) - } - defer { serverTask.cancel() } - - let client = BridgeClient() - _ = try await client.pairAndHello( - endpoint: .hostPort(host: NWEndpoint.Host("127.0.0.1"), port: port), - hello: BridgeHello(nodeId: "ios-node", displayName: "iOS", token: nil, platform: "ios", version: "1"), - onStatus: nil) - - Issue.record("Expected pairAndHello to throw for unexpected error code") - } catch { - #expect(error.localizedDescription.contains("NOPE")) - } - } -} diff --git a/apps/ios/Tests/BridgeConnectionControllerTests.swift b/apps/ios/Tests/BridgeConnectionControllerTests.swift deleted file mode 100644 index df6198623..000000000 --- a/apps/ios/Tests/BridgeConnectionControllerTests.swift +++ /dev/null @@ -1,347 +0,0 @@ -import ClawdbotKit -import Foundation -import Network -import Testing -import UIKit -@testable import Clawdbot - -private struct KeychainEntry: Hashable { - let service: String - let account: String -} - -private let bridgeService = "com.clawdbot.bridge" -private let nodeService = "com.clawdbot.node" -private let instanceIdEntry = KeychainEntry(service: nodeService, account: "instanceId") -private let preferredBridgeEntry = KeychainEntry(service: bridgeService, account: "preferredStableID") -private let lastBridgeEntry = KeychainEntry(service: bridgeService, account: "lastDiscoveredStableID") - -private actor MockBridgePairingClient: BridgePairingClient { - private(set) var lastToken: String? - private let resultToken: String - - init(resultToken: String) { - self.resultToken = resultToken - } - - func pairAndHello( - endpoint: NWEndpoint, - hello: BridgeHello, - tls: BridgeTLSParams?, - onStatus: (@Sendable (String) -> Void)?) async throws -> String - { - self.lastToken = hello.token - onStatus?("Testing…") - return self.resultToken - } -} - -private func withUserDefaults(_ updates: [String: Any?], _ body: () throws -> T) rethrows -> T { - let defaults = UserDefaults.standard - var snapshot: [String: Any?] = [:] - for key in updates.keys { - snapshot[key] = defaults.object(forKey: key) - } - for (key, value) in updates { - if let value { - defaults.set(value, forKey: key) - } else { - defaults.removeObject(forKey: key) - } - } - defer { - for (key, value) in snapshot { - if let value { - defaults.set(value, forKey: key) - } else { - defaults.removeObject(forKey: key) - } - } - } - return try body() -} - -@MainActor -private func withUserDefaults( - _ updates: [String: Any?], - _ body: () async throws -> T) async rethrows -> T -{ - let defaults = UserDefaults.standard - var snapshot: [String: Any?] = [:] - for key in updates.keys { - snapshot[key] = defaults.object(forKey: key) - } - for (key, value) in updates { - if let value { - defaults.set(value, forKey: key) - } else { - defaults.removeObject(forKey: key) - } - } - defer { - for (key, value) in snapshot { - if let value { - defaults.set(value, forKey: key) - } else { - defaults.removeObject(forKey: key) - } - } - } - return try await body() -} - -private func withKeychainValues(_ updates: [KeychainEntry: String?], _ body: () throws -> T) rethrows -> T { - var snapshot: [KeychainEntry: String?] = [:] - for entry in updates.keys { - snapshot[entry] = KeychainStore.loadString(service: entry.service, account: entry.account) - } - for (entry, value) in updates { - if let value { - _ = KeychainStore.saveString(value, service: entry.service, account: entry.account) - } else { - _ = KeychainStore.delete(service: entry.service, account: entry.account) - } - } - defer { - for (entry, value) in snapshot { - if let value { - _ = KeychainStore.saveString(value, service: entry.service, account: entry.account) - } else { - _ = KeychainStore.delete(service: entry.service, account: entry.account) - } - } - } - return try body() -} - -@MainActor -private func withKeychainValues( - _ updates: [KeychainEntry: String?], - _ body: () async throws -> T) async rethrows -> T -{ - var snapshot: [KeychainEntry: String?] = [:] - for entry in updates.keys { - snapshot[entry] = KeychainStore.loadString(service: entry.service, account: entry.account) - } - for (entry, value) in updates { - if let value { - _ = KeychainStore.saveString(value, service: entry.service, account: entry.account) - } else { - _ = KeychainStore.delete(service: entry.service, account: entry.account) - } - } - defer { - for (entry, value) in snapshot { - if let value { - _ = KeychainStore.saveString(value, service: entry.service, account: entry.account) - } else { - _ = KeychainStore.delete(service: entry.service, account: entry.account) - } - } - } - return try await body() -} - -@Suite(.serialized) struct BridgeConnectionControllerTests { - @Test @MainActor func resolvedDisplayNameSetsDefaultWhenMissing() { - let defaults = UserDefaults.standard - let displayKey = "node.displayName" - - withKeychainValues([instanceIdEntry: nil, preferredBridgeEntry: nil, lastBridgeEntry: nil]) { - withUserDefaults([displayKey: nil, "node.instanceId": "ios-test"]) { - let appModel = NodeAppModel() - let controller = BridgeConnectionController(appModel: appModel, startDiscovery: false) - - let resolved = controller._test_resolvedDisplayName(defaults: defaults) - #expect(!resolved.isEmpty) - #expect(defaults.string(forKey: displayKey) == resolved) - } - } - } - - @Test @MainActor func resolvedDisplayNamePreservesCustomValue() { - let defaults = UserDefaults.standard - let displayKey = "node.displayName" - - withKeychainValues([instanceIdEntry: nil, preferredBridgeEntry: nil, lastBridgeEntry: nil]) { - withUserDefaults([displayKey: "My iOS Node", "node.instanceId": "ios-test"]) { - let appModel = NodeAppModel() - let controller = BridgeConnectionController(appModel: appModel, startDiscovery: false) - - let resolved = controller._test_resolvedDisplayName(defaults: defaults) - #expect(resolved == "My iOS Node") - #expect(defaults.string(forKey: displayKey) == "My iOS Node") - } - } - } - - @Test @MainActor func makeHelloBuildsCapsAndCommands() { - let voiceWakeKey = VoiceWakePreferences.enabledKey - - withKeychainValues([instanceIdEntry: nil, preferredBridgeEntry: nil, lastBridgeEntry: nil]) { - withUserDefaults([ - "node.instanceId": "ios-test", - "node.displayName": "Test Node", - "camera.enabled": false, - voiceWakeKey: true, - ]) { - let appModel = NodeAppModel() - let controller = BridgeConnectionController(appModel: appModel, startDiscovery: false) - let hello = controller._test_makeHello(token: "token-123") - - #expect(hello.nodeId == "ios-test") - #expect(hello.displayName == "Test Node") - #expect(hello.token == "token-123") - - let caps = Set(hello.caps ?? []) - #expect(caps.contains(ClawdbotCapability.canvas.rawValue)) - #expect(caps.contains(ClawdbotCapability.screen.rawValue)) - #expect(caps.contains(ClawdbotCapability.voiceWake.rawValue)) - #expect(!caps.contains(ClawdbotCapability.camera.rawValue)) - - let commands = Set(hello.commands ?? []) - #expect(commands.contains(ClawdbotCanvasCommand.present.rawValue)) - #expect(commands.contains(ClawdbotScreenCommand.record.rawValue)) - #expect(!commands.contains(ClawdbotCameraCommand.snap.rawValue)) - - #expect(!(hello.platform ?? "").isEmpty) - #expect(!(hello.deviceFamily ?? "").isEmpty) - #expect(!(hello.modelIdentifier ?? "").isEmpty) - #expect(!(hello.version ?? "").isEmpty) - } - } - } - - @Test @MainActor func makeHelloIncludesCameraCommandsWhenEnabled() { - withKeychainValues([instanceIdEntry: nil, preferredBridgeEntry: nil, lastBridgeEntry: nil]) { - withUserDefaults([ - "node.instanceId": "ios-test", - "node.displayName": "Test Node", - "camera.enabled": true, - VoiceWakePreferences.enabledKey: false, - ]) { - let appModel = NodeAppModel() - let controller = BridgeConnectionController(appModel: appModel, startDiscovery: false) - let hello = controller._test_makeHello(token: "token-456") - - let caps = Set(hello.caps ?? []) - #expect(caps.contains(ClawdbotCapability.camera.rawValue)) - - let commands = Set(hello.commands ?? []) - #expect(commands.contains(ClawdbotCameraCommand.snap.rawValue)) - #expect(commands.contains(ClawdbotCameraCommand.clip.rawValue)) - } - } - } - - @Test @MainActor func autoConnectRefreshesTokenOnUnauthorized() async { - let bridge = BridgeDiscoveryModel.DiscoveredBridge( - name: "Gateway", - endpoint: .hostPort(host: NWEndpoint.Host("127.0.0.1"), port: 18790), - stableID: "bridge-1", - debugID: "bridge-debug", - lanHost: "Mac.local", - tailnetDns: nil, - gatewayPort: 18789, - bridgePort: 18790, - canvasPort: 18793, - tlsEnabled: false, - tlsFingerprintSha256: nil, - cliPath: nil) - let mock = MockBridgePairingClient(resultToken: "new-token") - let account = "bridge-token.ios-test" - - await withKeychainValues([ - instanceIdEntry: nil, - preferredBridgeEntry: nil, - lastBridgeEntry: nil, - KeychainEntry(service: bridgeService, account: account): "old-token", - ]) { - await withUserDefaults([ - "node.instanceId": "ios-test", - "bridge.lastDiscoveredStableID": "bridge-1", - "bridge.manual.enabled": false, - ]) { - let appModel = NodeAppModel() - let controller = BridgeConnectionController( - appModel: appModel, - startDiscovery: false, - bridgeClientFactory: { mock }) - controller._test_setBridges([bridge]) - controller._test_triggerAutoConnect() - - for _ in 0..<20 { - if appModel.connectedBridgeID == bridge.stableID { break } - try? await Task.sleep(nanoseconds: 50_000_000) - } - - #expect(appModel.connectedBridgeID == bridge.stableID) - let stored = KeychainStore.loadString(service: bridgeService, account: account) - #expect(stored == "new-token") - let lastToken = await mock.lastToken - #expect(lastToken == "old-token") - } - } - } - - @Test @MainActor func autoConnectPrefersPreferredBridgeOverLastDiscovered() async { - let bridgeA = BridgeDiscoveryModel.DiscoveredBridge( - name: "Gateway A", - endpoint: .hostPort(host: NWEndpoint.Host("127.0.0.1"), port: 18790), - stableID: "bridge-1", - debugID: "bridge-a", - lanHost: "MacA.local", - tailnetDns: nil, - gatewayPort: 18789, - bridgePort: 18790, - canvasPort: 18793, - tlsEnabled: false, - tlsFingerprintSha256: nil, - cliPath: nil) - let bridgeB = BridgeDiscoveryModel.DiscoveredBridge( - name: "Gateway B", - endpoint: .hostPort(host: NWEndpoint.Host("127.0.0.1"), port: 28790), - stableID: "bridge-2", - debugID: "bridge-b", - lanHost: "MacB.local", - tailnetDns: nil, - gatewayPort: 28789, - bridgePort: 28790, - canvasPort: 28793, - tlsEnabled: false, - tlsFingerprintSha256: nil, - cliPath: nil) - - let mock = MockBridgePairingClient(resultToken: "token-ok") - let account = "bridge-token.ios-test" - - await withKeychainValues([ - instanceIdEntry: nil, - preferredBridgeEntry: nil, - lastBridgeEntry: nil, - KeychainEntry(service: bridgeService, account: account): "old-token", - ]) { - await withUserDefaults([ - "node.instanceId": "ios-test", - "bridge.preferredStableID": "bridge-2", - "bridge.lastDiscoveredStableID": "bridge-1", - "bridge.manual.enabled": false, - ]) { - let appModel = NodeAppModel() - let controller = BridgeConnectionController( - appModel: appModel, - startDiscovery: false, - bridgeClientFactory: { mock }) - controller._test_setBridges([bridgeA, bridgeB]) - controller._test_triggerAutoConnect() - - for _ in 0..<20 { - if appModel.connectedBridgeID == bridgeB.stableID { break } - try? await Task.sleep(nanoseconds: 50_000_000) - } - - #expect(appModel.connectedBridgeID == bridgeB.stableID) - } - } - } -} diff --git a/apps/ios/Tests/BridgeSessionTests.swift b/apps/ios/Tests/BridgeSessionTests.swift deleted file mode 100644 index 470441251..000000000 --- a/apps/ios/Tests/BridgeSessionTests.swift +++ /dev/null @@ -1,48 +0,0 @@ -import Foundation -import Testing -@testable import Clawdbot - -@Suite struct BridgeSessionTests { - @Test func initialStateIsIdle() async { - let session = BridgeSession() - #expect(await session.state == .idle) - } - - @Test func requestFailsWhenNotConnected() async { - let session = BridgeSession() - - do { - _ = try await session.request(method: "health", paramsJSON: nil, timeoutSeconds: 1) - Issue.record("Expected request to throw when not connected") - } catch let error as NSError { - #expect(error.domain == "Bridge") - #expect(error.code == 11) - } - } - - @Test func sendEventFailsWhenNotConnected() async { - let session = BridgeSession() - - do { - try await session.sendEvent(event: "tick", payloadJSON: nil) - Issue.record("Expected sendEvent to throw when not connected") - } catch let error as NSError { - #expect(error.domain == "Bridge") - #expect(error.code == 10) - } - } - - @Test func disconnectFinishesServerEventStreams() async throws { - let session = BridgeSession() - let stream = await session.subscribeServerEvents(bufferingNewest: 1) - - let consumer = Task { @Sendable in - for await _ in stream {} - } - - await session.disconnect() - - _ = await consumer.result - #expect(await session.state == .idle) - } -} diff --git a/apps/ios/Tests/GatewayConnectionControllerTests.swift b/apps/ios/Tests/GatewayConnectionControllerTests.swift new file mode 100644 index 000000000..3e892a7b2 --- /dev/null +++ b/apps/ios/Tests/GatewayConnectionControllerTests.swift @@ -0,0 +1,79 @@ +import ClawdbotKit +import Foundation +import Testing +import UIKit +@testable import Clawdbot + +private func withUserDefaults(_ updates: [String: Any?], _ body: () throws -> T) rethrows -> T { + let defaults = UserDefaults.standard + var snapshot: [String: Any?] = [:] + for key in updates.keys { + snapshot[key] = defaults.object(forKey: key) + } + for (key, value) in updates { + if let value { + defaults.set(value, forKey: key) + } else { + defaults.removeObject(forKey: key) + } + } + defer { + for (key, value) in snapshot { + if let value { + defaults.set(value, forKey: key) + } else { + defaults.removeObject(forKey: key) + } + } + } + return try body() +} + +@Suite(.serialized) struct GatewayConnectionControllerTests { + @Test @MainActor func resolvedDisplayNameSetsDefaultWhenMissing() { + let defaults = UserDefaults.standard + let displayKey = "node.displayName" + + withUserDefaults([displayKey: nil, "node.instanceId": "ios-test"]) { + let appModel = NodeAppModel() + let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false) + + let resolved = controller._test_resolvedDisplayName(defaults: defaults) + #expect(!resolved.isEmpty) + #expect(defaults.string(forKey: displayKey) == resolved) + } + } + + @Test @MainActor func currentCapsReflectToggles() { + withUserDefaults([ + "node.instanceId": "ios-test", + "node.displayName": "Test Node", + "camera.enabled": true, + "location.enabledMode": ClawdbotLocationMode.always.rawValue, + VoiceWakePreferences.enabledKey: true, + ]) { + let appModel = NodeAppModel() + let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false) + let caps = Set(controller._test_currentCaps()) + + #expect(caps.contains(ClawdbotCapability.canvas.rawValue)) + #expect(caps.contains(ClawdbotCapability.screen.rawValue)) + #expect(caps.contains(ClawdbotCapability.camera.rawValue)) + #expect(caps.contains(ClawdbotCapability.location.rawValue)) + #expect(caps.contains(ClawdbotCapability.voiceWake.rawValue)) + } + } + + @Test @MainActor func currentCommandsIncludeLocationWhenEnabled() { + withUserDefaults([ + "node.instanceId": "ios-test", + "location.enabledMode": ClawdbotLocationMode.whileUsing.rawValue, + ]) { + let appModel = NodeAppModel() + let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false) + let commands = Set(controller._test_currentCommands()) + + #expect(commands.contains(ClawdbotLocationCommand.get.rawValue)) + } + } +} diff --git a/apps/ios/Tests/BridgeDiscoveryModelTests.swift b/apps/ios/Tests/GatewayDiscoveryModelTests.swift similarity index 77% rename from apps/ios/Tests/BridgeDiscoveryModelTests.swift rename to apps/ios/Tests/GatewayDiscoveryModelTests.swift index 0b9f8793a..2298647c8 100644 --- a/apps/ios/Tests/BridgeDiscoveryModelTests.swift +++ b/apps/ios/Tests/GatewayDiscoveryModelTests.swift @@ -1,9 +1,9 @@ import Testing @testable import Clawdbot -@Suite(.serialized) struct BridgeDiscoveryModelTests { +@Suite(.serialized) struct GatewayDiscoveryModelTests { @Test @MainActor func debugLoggingCapturesLifecycleAndResets() { - let model = BridgeDiscoveryModel() + let model = GatewayDiscoveryModel() #expect(model.debugLog.isEmpty) #expect(model.statusText == "Idle") @@ -13,7 +13,7 @@ import Testing model.stop() #expect(model.statusText == "Stopped") - #expect(model.bridges.isEmpty) + #expect(model.gateways.isEmpty) #expect(model.debugLog.count >= 3) model.setDebugLoggingEnabled(false) diff --git a/apps/ios/Tests/BridgeEndpointIDTests.swift b/apps/ios/Tests/GatewayEndpointIDTests.swift similarity index 59% rename from apps/ios/Tests/BridgeEndpointIDTests.swift rename to apps/ios/Tests/GatewayEndpointIDTests.swift index b1fe95cf8..173adf8b6 100644 --- a/apps/ios/Tests/BridgeEndpointIDTests.swift +++ b/apps/ios/Tests/GatewayEndpointIDTests.swift @@ -3,30 +3,30 @@ import Network import Testing @testable import Clawdbot -@Suite struct BridgeEndpointIDTests { +@Suite struct GatewayEndpointIDTests { @Test func stableIDForServiceDecodesAndNormalizesName() { let endpoint = NWEndpoint.service( - name: "Clawdbot\\032Bridge \\032 Node\n", - type: "_clawdbot-bridge._tcp", + name: "Clawdbot\\032Gateway \\032 Node\n", + type: "_clawdbot-gateway._tcp", domain: "local.", interface: nil) - #expect(BridgeEndpointID.stableID(endpoint) == "_clawdbot-bridge._tcp|local.|Clawdbot Bridge Node") + #expect(GatewayEndpointID.stableID(endpoint) == "_clawdbot-gateway._tcp|local.|Clawdbot Gateway Node") } @Test func stableIDForNonServiceUsesEndpointDescription() { let endpoint = NWEndpoint.hostPort(host: NWEndpoint.Host("127.0.0.1"), port: 4242) - #expect(BridgeEndpointID.stableID(endpoint) == String(describing: endpoint)) + #expect(GatewayEndpointID.stableID(endpoint) == String(describing: endpoint)) } @Test func prettyDescriptionDecodesBonjourEscapes() { let endpoint = NWEndpoint.service( - name: "Clawdbot\\032Bridge", - type: "_clawdbot-bridge._tcp", + name: "Clawdbot\\032Gateway", + type: "_clawdbot-gateway._tcp", domain: "local.", interface: nil) - let pretty = BridgeEndpointID.prettyDescription(endpoint) + let pretty = GatewayEndpointID.prettyDescription(endpoint) #expect(pretty == BonjourEscapes.decode(String(describing: endpoint))) #expect(!pretty.localizedCaseInsensitiveContains("\\032")) } diff --git a/apps/ios/Tests/BridgeSettingsStoreTests.swift b/apps/ios/Tests/GatewaySettingsStoreTests.swift similarity index 64% rename from apps/ios/Tests/BridgeSettingsStoreTests.swift rename to apps/ios/Tests/GatewaySettingsStoreTests.swift index d2840c11f..a93072ef6 100644 --- a/apps/ios/Tests/BridgeSettingsStoreTests.swift +++ b/apps/ios/Tests/GatewaySettingsStoreTests.swift @@ -7,11 +7,11 @@ private struct KeychainEntry: Hashable { let account: String } -private let bridgeService = "com.clawdbot.bridge" +private let gatewayService = "com.clawdbot.gateway" private let nodeService = "com.clawdbot.node" private let instanceIdEntry = KeychainEntry(service: nodeService, account: "instanceId") -private let preferredBridgeEntry = KeychainEntry(service: bridgeService, account: "preferredStableID") -private let lastBridgeEntry = KeychainEntry(service: bridgeService, account: "lastDiscoveredStableID") +private let preferredGatewayEntry = KeychainEntry(service: gatewayService, account: "preferredStableID") +private let lastGatewayEntry = KeychainEntry(service: gatewayService, account: "lastDiscoveredStableID") private func snapshotDefaults(_ keys: [String]) -> [String: Any?] { let defaults = UserDefaults.standard @@ -59,14 +59,14 @@ private func restoreKeychain(_ snapshot: [KeychainEntry: String?]) { applyKeychain(snapshot) } -@Suite(.serialized) struct BridgeSettingsStoreTests { +@Suite(.serialized) struct GatewaySettingsStoreTests { @Test func bootstrapCopiesDefaultsToKeychainWhenMissing() { let defaultsKeys = [ "node.instanceId", - "bridge.preferredStableID", - "bridge.lastDiscoveredStableID", + "gateway.preferredStableID", + "gateway.lastDiscoveredStableID", ] - let entries = [instanceIdEntry, preferredBridgeEntry, lastBridgeEntry] + let entries = [instanceIdEntry, preferredGatewayEntry, lastGatewayEntry] let defaultsSnapshot = snapshotDefaults(defaultsKeys) let keychainSnapshot = snapshotKeychain(entries) defer { @@ -76,29 +76,29 @@ private func restoreKeychain(_ snapshot: [KeychainEntry: String?]) { applyDefaults([ "node.instanceId": "node-test", - "bridge.preferredStableID": "preferred-test", - "bridge.lastDiscoveredStableID": "last-test", + "gateway.preferredStableID": "preferred-test", + "gateway.lastDiscoveredStableID": "last-test", ]) applyKeychain([ instanceIdEntry: nil, - preferredBridgeEntry: nil, - lastBridgeEntry: nil, + preferredGatewayEntry: nil, + lastGatewayEntry: nil, ]) - BridgeSettingsStore.bootstrapPersistence() + GatewaySettingsStore.bootstrapPersistence() #expect(KeychainStore.loadString(service: nodeService, account: "instanceId") == "node-test") - #expect(KeychainStore.loadString(service: bridgeService, account: "preferredStableID") == "preferred-test") - #expect(KeychainStore.loadString(service: bridgeService, account: "lastDiscoveredStableID") == "last-test") + #expect(KeychainStore.loadString(service: gatewayService, account: "preferredStableID") == "preferred-test") + #expect(KeychainStore.loadString(service: gatewayService, account: "lastDiscoveredStableID") == "last-test") } @Test func bootstrapCopiesKeychainToDefaultsWhenMissing() { let defaultsKeys = [ "node.instanceId", - "bridge.preferredStableID", - "bridge.lastDiscoveredStableID", + "gateway.preferredStableID", + "gateway.lastDiscoveredStableID", ] - let entries = [instanceIdEntry, preferredBridgeEntry, lastBridgeEntry] + let entries = [instanceIdEntry, preferredGatewayEntry, lastGatewayEntry] let defaultsSnapshot = snapshotDefaults(defaultsKeys) let keychainSnapshot = snapshotKeychain(entries) defer { @@ -108,20 +108,20 @@ private func restoreKeychain(_ snapshot: [KeychainEntry: String?]) { applyDefaults([ "node.instanceId": nil, - "bridge.preferredStableID": nil, - "bridge.lastDiscoveredStableID": nil, + "gateway.preferredStableID": nil, + "gateway.lastDiscoveredStableID": nil, ]) applyKeychain([ instanceIdEntry: "node-from-keychain", - preferredBridgeEntry: "preferred-from-keychain", - lastBridgeEntry: "last-from-keychain", + preferredGatewayEntry: "preferred-from-keychain", + lastGatewayEntry: "last-from-keychain", ]) - BridgeSettingsStore.bootstrapPersistence() + GatewaySettingsStore.bootstrapPersistence() let defaults = UserDefaults.standard #expect(defaults.string(forKey: "node.instanceId") == "node-from-keychain") - #expect(defaults.string(forKey: "bridge.preferredStableID") == "preferred-from-keychain") - #expect(defaults.string(forKey: "bridge.lastDiscoveredStableID") == "last-from-keychain") + #expect(defaults.string(forKey: "gateway.preferredStableID") == "preferred-from-keychain") + #expect(defaults.string(forKey: "gateway.lastDiscoveredStableID") == "last-from-keychain") } } diff --git a/apps/ios/Tests/IOSBridgeChatTransportTests.swift b/apps/ios/Tests/IOSGatewayChatTransportTests.swift similarity index 54% rename from apps/ios/Tests/IOSBridgeChatTransportTests.swift rename to apps/ios/Tests/IOSGatewayChatTransportTests.swift index 437437119..723a93146 100644 --- a/apps/ios/Tests/IOSBridgeChatTransportTests.swift +++ b/apps/ios/Tests/IOSGatewayChatTransportTests.swift @@ -1,19 +1,15 @@ +import ClawdbotKit import Testing @testable import Clawdbot -@Suite struct IOSBridgeChatTransportTests { - @Test func requestsFailFastWhenBridgeNotConnected() async { - let bridge = BridgeSession() - let transport = IOSBridgeChatTransport(bridge: bridge) - - do { - try await transport.setActiveSessionKey("node-test") - Issue.record("Expected setActiveSessionKey to throw when bridge not connected") - } catch {} +@Suite struct IOSGatewayChatTransportTests { + @Test func requestsFailFastWhenGatewayNotConnected() async { + let gateway = GatewayNodeSession() + let transport = IOSGatewayChatTransport(gateway: gateway) do { _ = try await transport.requestHistory(sessionKey: "node-test") - Issue.record("Expected requestHistory to throw when bridge not connected") + Issue.record("Expected requestHistory to throw when gateway not connected") } catch {} do { @@ -23,11 +19,12 @@ import Testing thinking: "low", idempotencyKey: "idempotency", attachments: []) - Issue.record("Expected sendMessage to throw when bridge not connected") + Issue.record("Expected sendMessage to throw when gateway not connected") } catch {} do { _ = try await transport.requestHealth(timeoutMs: 250) + Issue.record("Expected requestHealth to throw when gateway not connected") } catch {} } } diff --git a/apps/ios/Tests/NodeAppModelInvokeTests.swift b/apps/ios/Tests/NodeAppModelInvokeTests.swift index 436635a84..6f0d99906 100644 --- a/apps/ios/Tests/NodeAppModelInvokeTests.swift +++ b/apps/ios/Tests/NodeAppModelInvokeTests.swift @@ -159,7 +159,7 @@ private func withUserDefaults(_ updates: [String: Any?], _ body: () throws -> let appModel = NodeAppModel() let url = URL(string: "clawdbot://agent?message=hello")! await appModel.handleDeepLink(url: url) - #expect(appModel.screen.errorText?.contains("Bridge not connected") == true) + #expect(appModel.screen.errorText?.contains("Gateway not connected") == true) } @Test @MainActor func handleDeepLinkRejectsOversizedMessage() async { @@ -170,7 +170,7 @@ private func withUserDefaults(_ updates: [String: Any?], _ body: () throws -> #expect(appModel.screen.errorText?.contains("Deep link too large") == true) } - @Test @MainActor func sendVoiceTranscriptThrowsWhenBridgeOffline() async { + @Test @MainActor func sendVoiceTranscriptThrowsWhenGatewayOffline() async { let appModel = NodeAppModel() await #expect(throws: Error.self) { try await appModel.sendVoiceTranscript(text: "hello", sessionKey: "main") diff --git a/apps/ios/Tests/SwiftUIRenderSmokeTests.swift b/apps/ios/Tests/SwiftUIRenderSmokeTests.swift index 53e100448..4fe7fe88e 100644 --- a/apps/ios/Tests/SwiftUIRenderSmokeTests.swift +++ b/apps/ios/Tests/SwiftUIRenderSmokeTests.swift @@ -1,3 +1,4 @@ +import ClawdbotKit import SwiftUI import Testing import UIKit @@ -14,35 +15,35 @@ import UIKit } @Test @MainActor func statusPillConnectingBuildsAViewHierarchy() { - let root = StatusPill(bridge: .connecting, voiceWakeEnabled: true, brighten: true) {} + let root = StatusPill(gateway: .connecting, voiceWakeEnabled: true, brighten: true) {} _ = Self.host(root) } @Test @MainActor func statusPillDisconnectedBuildsAViewHierarchy() { - let root = StatusPill(bridge: .disconnected, voiceWakeEnabled: false) {} + let root = StatusPill(gateway: .disconnected, voiceWakeEnabled: false) {} _ = Self.host(root) } @Test @MainActor func settingsTabBuildsAViewHierarchy() { let appModel = NodeAppModel() - let bridgeController = BridgeConnectionController(appModel: appModel, startDiscovery: false) + let gatewayController = GatewayConnectionController(appModel: appModel, startDiscovery: false) let root = SettingsTab() .environment(appModel) .environment(appModel.voiceWake) - .environment(bridgeController) + .environment(gatewayController) _ = Self.host(root) } @Test @MainActor func rootTabsBuildAViewHierarchy() { let appModel = NodeAppModel() - let bridgeController = BridgeConnectionController(appModel: appModel, startDiscovery: false) + let gatewayController = GatewayConnectionController(appModel: appModel, startDiscovery: false) let root = RootTabs() .environment(appModel) .environment(appModel.voiceWake) - .environment(bridgeController) + .environment(gatewayController) _ = Self.host(root) } @@ -66,8 +67,8 @@ import UIKit @Test @MainActor func chatSheetBuildsAViewHierarchy() { let appModel = NodeAppModel() - let bridge = BridgeSession() - let root = ChatSheet(bridge: bridge, sessionKey: "test") + let gateway = GatewayNodeSession() + let root = ChatSheet(gateway: gateway, sessionKey: "test") .environment(appModel) .environment(appModel.voiceWake) _ = Self.host(root) diff --git a/apps/ios/project.yml b/apps/ios/project.yml index d1b0c2fee..ae00eca02 100644 --- a/apps/ios/project.yml +++ b/apps/ios/project.yml @@ -35,6 +35,8 @@ targets: - package: ClawdbotKit - package: ClawdbotKit product: ClawdbotChatUI + - package: ClawdbotKit + product: ClawdbotProtocol - package: Swabble product: SwabbleKit - sdk: AppIntents.framework @@ -86,12 +88,12 @@ targets: UIApplicationSupportsMultipleScenes: false UIBackgroundModes: - audio - NSLocalNetworkUsageDescription: Clawdbot discovers and connects to your Clawdbot bridge on the local network. + NSLocalNetworkUsageDescription: Clawdbot discovers and connects to your Clawdbot gateway on the local network. NSAppTransportSecurity: NSAllowsArbitraryLoadsInWebContent: true NSBonjourServices: - - _clawdbot-bridge._tcp - NSCameraUsageDescription: Clawdbot can capture photos or short video clips when requested via the bridge. + - _clawdbot-gateway._tcp + NSCameraUsageDescription: Clawdbot can capture photos or short video clips when requested via the gateway. NSLocationWhenInUseUsageDescription: Clawdbot uses your location when you allow location sharing. NSLocationAlwaysAndWhenInUseUsageDescription: Clawdbot can share your location in the background when you enable Always. NSMicrophoneUsageDescription: Clawdbot needs microphone access for voice wake. diff --git a/apps/macos/Package.swift b/apps/macos/Package.swift index 3cd135e50..731d32e53 100644 --- a/apps/macos/Package.swift +++ b/apps/macos/Package.swift @@ -25,13 +25,6 @@ let package = Package( .package(path: "../../Swabble"), ], targets: [ - .target( - name: "ClawdbotProtocol", - dependencies: [], - path: "Sources/ClawdbotProtocol", - swiftSettings: [ - .enableUpcomingFeature("StrictConcurrency"), - ]), .target( name: "ClawdbotIPC", dependencies: [], @@ -52,9 +45,9 @@ let package = Package( dependencies: [ "ClawdbotIPC", "ClawdbotDiscovery", - "ClawdbotProtocol", .product(name: "ClawdbotKit", package: "ClawdbotKit"), .product(name: "ClawdbotChatUI", package: "ClawdbotKit"), + .product(name: "ClawdbotProtocol", package: "ClawdbotKit"), .product(name: "SwabbleKit", package: "swabble"), .product(name: "MenuBarExtraAccess", package: "MenuBarExtraAccess"), .product(name: "Subprocess", package: "swift-subprocess"), @@ -85,7 +78,7 @@ let package = Package( .executableTarget( name: "ClawdbotWizardCLI", dependencies: [ - "ClawdbotProtocol", + .product(name: "ClawdbotProtocol", package: "ClawdbotKit"), ], path: "Sources/ClawdbotWizardCLI", swiftSettings: [ @@ -97,7 +90,7 @@ let package = Package( "ClawdbotIPC", "Clawdbot", "ClawdbotDiscovery", - "ClawdbotProtocol", + .product(name: "ClawdbotProtocol", package: "ClawdbotKit"), .product(name: "SwabbleKit", package: "swabble"), ], swiftSettings: [ diff --git a/apps/macos/Sources/Clawdbot/CanvasManager.swift b/apps/macos/Sources/Clawdbot/CanvasManager.swift index 2c0f73253..868e71142 100644 --- a/apps/macos/Sources/Clawdbot/CanvasManager.swift +++ b/apps/macos/Sources/Clawdbot/CanvasManager.swift @@ -1,5 +1,6 @@ import AppKit import ClawdbotIPC +import ClawdbotKit import Foundation import OSLog diff --git a/apps/macos/Sources/Clawdbot/ControlChannel.swift b/apps/macos/Sources/Clawdbot/ControlChannel.swift index 78160a43a..8aaca85a2 100644 --- a/apps/macos/Sources/Clawdbot/ControlChannel.swift +++ b/apps/macos/Sources/Clawdbot/ControlChannel.swift @@ -1,3 +1,4 @@ +import ClawdbotKit import ClawdbotProtocol import Foundation import Observation diff --git a/apps/macos/Sources/Clawdbot/CronJobsStore.swift b/apps/macos/Sources/Clawdbot/CronJobsStore.swift index b3e363bf9..b44f9cb3a 100644 --- a/apps/macos/Sources/Clawdbot/CronJobsStore.swift +++ b/apps/macos/Sources/Clawdbot/CronJobsStore.swift @@ -1,3 +1,4 @@ +import ClawdbotKit import ClawdbotProtocol import Foundation import Observation diff --git a/apps/macos/Sources/Clawdbot/DevicePairingApprovalPrompter.swift b/apps/macos/Sources/Clawdbot/DevicePairingApprovalPrompter.swift index bbe8e0fc6..cbcaca07d 100644 --- a/apps/macos/Sources/Clawdbot/DevicePairingApprovalPrompter.swift +++ b/apps/macos/Sources/Clawdbot/DevicePairingApprovalPrompter.swift @@ -1,4 +1,5 @@ import AppKit +import ClawdbotKit import ClawdbotProtocol import Foundation import Observation diff --git a/apps/macos/Sources/Clawdbot/ExecApprovalsGatewayPrompter.swift b/apps/macos/Sources/Clawdbot/ExecApprovalsGatewayPrompter.swift index efdb4be68..90eab9108 100644 --- a/apps/macos/Sources/Clawdbot/ExecApprovalsGatewayPrompter.swift +++ b/apps/macos/Sources/Clawdbot/ExecApprovalsGatewayPrompter.swift @@ -1,3 +1,4 @@ +import ClawdbotKit import ClawdbotProtocol import Foundation import OSLog diff --git a/apps/macos/Sources/Clawdbot/GatewayConnection.swift b/apps/macos/Sources/Clawdbot/GatewayConnection.swift index c8e5ddd7a..a5c055103 100644 --- a/apps/macos/Sources/Clawdbot/GatewayConnection.swift +++ b/apps/macos/Sources/Clawdbot/GatewayConnection.swift @@ -1,4 +1,5 @@ import ClawdbotChatUI +import ClawdbotKit import ClawdbotProtocol import Foundation import OSLog diff --git a/apps/macos/Sources/Clawdbot/GatewayPayloadDecoding.swift b/apps/macos/Sources/Clawdbot/GatewayPayloadDecoding.swift deleted file mode 100644 index e0ad02aa4..000000000 --- a/apps/macos/Sources/Clawdbot/GatewayPayloadDecoding.swift +++ /dev/null @@ -1,16 +0,0 @@ -import ClawdbotProtocol -import Foundation - -enum GatewayPayloadDecoding { - static func decode(_ payload: ClawdbotProtocol.AnyCodable, as _: T.Type = T.self) throws -> T { - let data = try JSONEncoder().encode(payload) - return try JSONDecoder().decode(T.self, from: data) - } - - static func decodeIfPresent(_ payload: ClawdbotProtocol.AnyCodable?, as _: T.Type = T.self) throws - -> T? - { - guard let payload else { return nil } - return try self.decode(payload, as: T.self) - } -} diff --git a/apps/macos/Sources/Clawdbot/InstanceIdentity.swift b/apps/macos/Sources/Clawdbot/InstanceIdentity.swift deleted file mode 100644 index 2bd2e6ea6..000000000 --- a/apps/macos/Sources/Clawdbot/InstanceIdentity.swift +++ /dev/null @@ -1,47 +0,0 @@ -import Darwin -import Foundation - -enum InstanceIdentity { - private static let suiteName = "com.clawdbot.shared" - private static let instanceIdKey = "instanceId" - - private static var defaults: UserDefaults { - UserDefaults(suiteName: suiteName) ?? .standard - } - - static let instanceId: String = { - let defaults = Self.defaults - if let existing = defaults.string(forKey: instanceIdKey)? - .trimmingCharacters(in: .whitespacesAndNewlines), - !existing.isEmpty - { - return existing - } - - let id = UUID().uuidString.lowercased() - defaults.set(id, forKey: instanceIdKey) - return id - }() - - static let displayName: String = { - if let name = Host.current().localizedName?.trimmingCharacters(in: .whitespacesAndNewlines), - !name.isEmpty - { - return name - } - return "clawdbot" - }() - - static let modelIdentifier: String? = { - var size = 0 - guard sysctlbyname("hw.model", nil, &size, nil, 0) == 0, size > 1 else { return nil } - - var buffer = [CChar](repeating: 0, count: size) - guard sysctlbyname("hw.model", &buffer, &size, nil, 0) == 0 else { return nil } - - let bytes = buffer.prefix { $0 != 0 }.map { UInt8(bitPattern: $0) } - guard let raw = String(bytes: bytes, encoding: .utf8) else { return nil } - let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) - return trimmed.isEmpty ? nil : trimmed - }() -} diff --git a/apps/macos/Sources/Clawdbot/InstancesStore.swift b/apps/macos/Sources/Clawdbot/InstancesStore.swift index 196737f07..c12f8c2e0 100644 --- a/apps/macos/Sources/Clawdbot/InstancesStore.swift +++ b/apps/macos/Sources/Clawdbot/InstancesStore.swift @@ -1,3 +1,4 @@ +import ClawdbotKit import ClawdbotProtocol import Cocoa import Foundation diff --git a/apps/macos/Sources/Clawdbot/NodeMode/MacNodeModeCoordinator.swift b/apps/macos/Sources/Clawdbot/NodeMode/MacNodeModeCoordinator.swift index 995d39a58..3d86bc4e9 100644 --- a/apps/macos/Sources/Clawdbot/NodeMode/MacNodeModeCoordinator.swift +++ b/apps/macos/Sources/Clawdbot/NodeMode/MacNodeModeCoordinator.swift @@ -9,7 +9,7 @@ final class MacNodeModeCoordinator { private let logger = Logger(subsystem: "com.clawdbot", category: "mac-node") private var task: Task? private let runtime = MacNodeRuntime() - private let session = MacNodeGatewaySession() + private let session = GatewayNodeSession() func start() { guard self.task == nil else { return } diff --git a/apps/macos/Sources/Clawdbot/NodePairingApprovalPrompter.swift b/apps/macos/Sources/Clawdbot/NodePairingApprovalPrompter.swift index 6f7b43156..b3f7e9295 100644 --- a/apps/macos/Sources/Clawdbot/NodePairingApprovalPrompter.swift +++ b/apps/macos/Sources/Clawdbot/NodePairingApprovalPrompter.swift @@ -1,6 +1,7 @@ import AppKit import ClawdbotDiscovery import ClawdbotIPC +import ClawdbotKit import ClawdbotProtocol import Foundation import Observation diff --git a/apps/macos/Sources/Clawdbot/OnboardingWizard.swift b/apps/macos/Sources/Clawdbot/OnboardingWizard.swift index dc37ac86b..34e153233 100644 --- a/apps/macos/Sources/Clawdbot/OnboardingWizard.swift +++ b/apps/macos/Sources/Clawdbot/OnboardingWizard.swift @@ -1,3 +1,4 @@ +import ClawdbotKit import ClawdbotProtocol import Foundation import Observation diff --git a/apps/macos/Sources/Clawdbot/VoiceWakeGlobalSettingsSync.swift b/apps/macos/Sources/Clawdbot/VoiceWakeGlobalSettingsSync.swift index 95ea5e364..9a12486cb 100644 --- a/apps/macos/Sources/Clawdbot/VoiceWakeGlobalSettingsSync.swift +++ b/apps/macos/Sources/Clawdbot/VoiceWakeGlobalSettingsSync.swift @@ -1,3 +1,4 @@ +import ClawdbotKit import Foundation import OSLog diff --git a/apps/macos/Sources/Clawdbot/WebChatSwiftUI.swift b/apps/macos/Sources/Clawdbot/WebChatSwiftUI.swift index 0910fb798..665416d31 100644 --- a/apps/macos/Sources/Clawdbot/WebChatSwiftUI.swift +++ b/apps/macos/Sources/Clawdbot/WebChatSwiftUI.swift @@ -1,5 +1,6 @@ import AppKit import ClawdbotChatUI +import ClawdbotKit import ClawdbotProtocol import Foundation import OSLog diff --git a/apps/shared/ClawdbotKit/Package.swift b/apps/shared/ClawdbotKit/Package.swift index b094e0a1c..076842fce 100644 --- a/apps/shared/ClawdbotKit/Package.swift +++ b/apps/shared/ClawdbotKit/Package.swift @@ -9,6 +9,7 @@ let package = Package( .macOS(.v15), ], products: [ + .library(name: "ClawdbotProtocol", targets: ["ClawdbotProtocol"]), .library(name: "ClawdbotKit", targets: ["ClawdbotKit"]), .library(name: "ClawdbotChatUI", targets: ["ClawdbotChatUI"]), ], @@ -17,9 +18,15 @@ let package = Package( .package(url: "https://github.com/gonzalezreal/textual", exact: "0.2.0"), ], targets: [ + .target( + name: "ClawdbotProtocol", + swiftSettings: [ + .enableUpcomingFeature("StrictConcurrency"), + ]), .target( name: "ClawdbotKit", dependencies: [ + "ClawdbotProtocol", .product(name: "ElevenLabsKit", package: "ElevenLabsKit"), ], resources: [ diff --git a/apps/macos/Sources/Clawdbot/DeviceIdentity.swift b/apps/shared/ClawdbotKit/Sources/ClawdbotKit/DeviceIdentity.swift similarity index 79% rename from apps/macos/Sources/Clawdbot/DeviceIdentity.swift rename to apps/shared/ClawdbotKit/Sources/ClawdbotKit/DeviceIdentity.swift index ad49f16d8..57bf98bfd 100644 --- a/apps/macos/Sources/Clawdbot/DeviceIdentity.swift +++ b/apps/shared/ClawdbotKit/Sources/ClawdbotKit/DeviceIdentity.swift @@ -8,6 +8,25 @@ struct DeviceIdentity: Codable, Sendable { var createdAtMs: Int } +enum DeviceIdentityPaths { + private static let stateDirEnv = "CLAWDBOT_STATE_DIR" + + static func stateDirURL() -> URL { + if let raw = getenv(self.stateDirEnv) { + let value = String(cString: raw).trimmingCharacters(in: .whitespacesAndNewlines) + if !value.isEmpty { + return URL(fileURLWithPath: value, isDirectory: true) + } + } + + if let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first { + return appSupport.appendingPathComponent("clawdbot", isDirectory: true) + } + + return FileManager.default.temporaryDirectory.appendingPathComponent("clawdbot", isDirectory: true) + } +} + enum DeviceIdentityStore { private static let fileName = "device.json" @@ -76,7 +95,7 @@ enum DeviceIdentityStore { } private static func fileURL() -> URL { - let base = ClawdbotPaths.stateDirURL + let base = DeviceIdentityPaths.stateDirURL() return base .appendingPathComponent("identity", isDirectory: true) .appendingPathComponent(fileName, isDirectory: false) diff --git a/apps/macos/Sources/Clawdbot/GatewayChannel.swift b/apps/shared/ClawdbotKit/Sources/ClawdbotKit/GatewayChannel.swift similarity index 91% rename from apps/macos/Sources/Clawdbot/GatewayChannel.swift rename to apps/shared/ClawdbotKit/Sources/ClawdbotKit/GatewayChannel.swift index ff893f1e0..23e8fa1c2 100644 --- a/apps/macos/Sources/Clawdbot/GatewayChannel.swift +++ b/apps/shared/ClawdbotKit/Sources/ClawdbotKit/GatewayChannel.swift @@ -1,9 +1,8 @@ -import ClawdbotKit import ClawdbotProtocol import Foundation import OSLog -protocol WebSocketTasking: AnyObject { +public protocol WebSocketTasking: AnyObject { var state: URLSessionTask.State { get } func resume() func cancel(with closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) @@ -14,31 +13,33 @@ protocol WebSocketTasking: AnyObject { extension URLSessionWebSocketTask: WebSocketTasking {} -struct WebSocketTaskBox: @unchecked Sendable { - let task: any WebSocketTasking +public struct WebSocketTaskBox: @unchecked Sendable { + public let task: any WebSocketTasking - var state: URLSessionTask.State { self.task.state } + public var state: URLSessionTask.State { self.task.state } - func resume() { self.task.resume() } + public func resume() { self.task.resume() } - func cancel(with closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) { + public func cancel(with closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) { self.task.cancel(with: closeCode, reason: reason) } - func send(_ message: URLSessionWebSocketTask.Message) async throws { + public func send(_ message: URLSessionWebSocketTask.Message) async throws { try await self.task.send(message) } - func receive() async throws -> URLSessionWebSocketTask.Message { + public func receive() async throws -> URLSessionWebSocketTask.Message { try await self.task.receive() } - func receive(completionHandler: @escaping @Sendable (Result) -> Void) { + public func receive( + completionHandler: @escaping @Sendable (Result) -> Void) + { self.task.receive(completionHandler: completionHandler) } } -protocol WebSocketSessioning: AnyObject { +public protocol WebSocketSessioning: AnyObject { func makeWebSocketTask(url: URL) -> WebSocketTaskBox } @@ -51,25 +52,45 @@ extension URLSession: WebSocketSessioning { } } -struct WebSocketSessionBox: @unchecked Sendable { - let session: any WebSocketSessioning +public struct WebSocketSessionBox: @unchecked Sendable { + public let session: any WebSocketSessioning } -struct GatewayConnectOptions: Sendable { - var role: String - var scopes: [String] - var caps: [String] - var commands: [String] - var permissions: [String: Bool] - var clientId: String - var clientMode: String - var clientDisplayName: String? +public struct GatewayConnectOptions: Sendable { + public var role: String + public var scopes: [String] + public var caps: [String] + public var commands: [String] + public var permissions: [String: Bool] + public var clientId: String + public var clientMode: String + public var clientDisplayName: String? + + public init( + role: String, + scopes: [String], + caps: [String], + commands: [String], + permissions: [String: Bool], + clientId: String, + clientMode: String, + clientDisplayName: String?) + { + self.role = role + self.scopes = scopes + self.caps = caps + self.commands = commands + self.permissions = permissions + self.clientId = clientId + self.clientMode = clientMode + self.clientDisplayName = clientDisplayName + } } // Avoid ambiguity with the app's own AnyCodable type. private typealias ProtoAnyCodable = ClawdbotProtocol.AnyCodable -actor GatewayChannelActor { +public actor GatewayChannelActor { private let logger = Logger(subsystem: "com.clawdbot", category: "gateway") private var task: WebSocketTaskBox? private var pending: [String: CheckedContinuation] = [:] @@ -95,7 +116,7 @@ actor GatewayChannelActor { private let connectOptions: GatewayConnectOptions? private let disconnectHandler: (@Sendable (String) async -> Void)? - init( + public init( url: URL, token: String?, password: String? = nil, @@ -116,7 +137,7 @@ actor GatewayChannelActor { } } - func shutdown() async { + public func shutdown() async { self.shouldReconnect = false self.connected = false @@ -167,7 +188,7 @@ actor GatewayChannelActor { } } - func connect() async throws { + public func connect() async throws { if self.connected, self.task?.state == .running { return } if self.isConnecting { try await withCheckedThrowingContinuation { cont in @@ -217,8 +238,7 @@ actor GatewayChannelActor { } private func sendConnect() async throws { - let osVersion = ProcessInfo.processInfo.operatingSystemVersion - let platform = "macos \(osVersion.majorVersion).\(osVersion.minorVersion).\(osVersion.patchVersion)" + let platform = InstanceIdentity.platformString let primaryLocale = Locale.preferredLanguages.first ?? Locale.current.identifier let options = self.connectOptions ?? GatewayConnectOptions( role: "operator", @@ -243,7 +263,7 @@ actor GatewayChannelActor { "mode": ProtoAnyCodable(clientMode), "instanceId": ProtoAnyCodable(InstanceIdentity.instanceId), ] - client["deviceFamily"] = ProtoAnyCodable("Mac") + client["deviceFamily"] = ProtoAnyCodable(InstanceIdentity.deviceFamily) if let model = InstanceIdentity.modelIdentifier { client["modelIdentifier"] = ProtoAnyCodable(model) } @@ -450,7 +470,7 @@ actor GatewayChannelActor { } } - func request( + public func request( method: String, params: [String: ClawdbotProtocol.AnyCodable]?, timeoutMs: Double? = nil) async throws -> Data diff --git a/apps/macos/Sources/ClawdbotDiscovery/GatewayEndpointID.swift b/apps/shared/ClawdbotKit/Sources/ClawdbotKit/GatewayEndpointID.swift similarity index 98% rename from apps/macos/Sources/ClawdbotDiscovery/GatewayEndpointID.swift rename to apps/shared/ClawdbotKit/Sources/ClawdbotKit/GatewayEndpointID.swift index 4163f28fe..eb2e94f51 100644 --- a/apps/macos/Sources/ClawdbotDiscovery/GatewayEndpointID.swift +++ b/apps/shared/ClawdbotKit/Sources/ClawdbotKit/GatewayEndpointID.swift @@ -1,4 +1,3 @@ -import ClawdbotKit import Foundation import Network diff --git a/apps/macos/Sources/Clawdbot/GatewayErrors.swift b/apps/shared/ClawdbotKit/Sources/ClawdbotKit/GatewayErrors.swift similarity index 57% rename from apps/macos/Sources/Clawdbot/GatewayErrors.swift rename to apps/shared/ClawdbotKit/Sources/ClawdbotKit/GatewayErrors.swift index 961880d76..2b876467f 100644 --- a/apps/macos/Sources/Clawdbot/GatewayErrors.swift +++ b/apps/shared/ClawdbotKit/Sources/ClawdbotKit/GatewayErrors.swift @@ -2,13 +2,13 @@ import ClawdbotProtocol import Foundation /// Structured error surfaced when the gateway responds with `{ ok: false }`. -struct GatewayResponseError: LocalizedError, @unchecked Sendable { - let method: String - let code: String - let message: String - let details: [String: AnyCodable] +public struct GatewayResponseError: LocalizedError, @unchecked Sendable { + public let method: String + public let code: String + public let message: String + public let details: [String: AnyCodable] - init(method: String, code: String?, message: String?, details: [String: AnyCodable]?) { + public init(method: String, code: String?, message: String?, details: [String: AnyCodable]?) { self.method = method self.code = (code?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false) ? code!.trimmingCharacters(in: .whitespacesAndNewlines) @@ -19,15 +19,15 @@ struct GatewayResponseError: LocalizedError, @unchecked Sendable { self.details = details ?? [:] } - var errorDescription: String? { + public var errorDescription: String? { if self.code == "GATEWAY_ERROR" { return "\(self.method): \(self.message)" } return "\(self.method): [\(self.code)] \(self.message)" } } -struct GatewayDecodingError: LocalizedError, Sendable { - let method: String - let message: String +public struct GatewayDecodingError: LocalizedError, Sendable { + public let method: String + public let message: String - var errorDescription: String? { "\(self.method): \(self.message)" } + public var errorDescription: String? { "\(self.method): \(self.message)" } } diff --git a/apps/macos/Sources/Clawdbot/NodeMode/MacNodeGatewaySession.swift b/apps/shared/ClawdbotKit/Sources/ClawdbotKit/GatewayNodeSession.swift similarity index 63% rename from apps/macos/Sources/Clawdbot/NodeMode/MacNodeGatewaySession.swift rename to apps/shared/ClawdbotKit/Sources/ClawdbotKit/GatewayNodeSession.swift index 237410456..26d758596 100644 --- a/apps/macos/Sources/Clawdbot/NodeMode/MacNodeGatewaySession.swift +++ b/apps/shared/ClawdbotKit/Sources/ClawdbotKit/GatewayNodeSession.swift @@ -1,4 +1,3 @@ -import ClawdbotKit import ClawdbotProtocol import Foundation import OSLog @@ -12,7 +11,7 @@ private struct NodeInvokeRequestPayload: Codable, Sendable { var idempotencyKey: String? } -actor MacNodeGatewaySession { +public actor GatewayNodeSession { private let logger = Logger(subsystem: "com.clawdbot", category: "node.gateway") private let decoder = JSONDecoder() private let encoder = JSONEncoder() @@ -24,8 +23,10 @@ actor MacNodeGatewaySession { private var onConnected: (@Sendable () async -> Void)? private var onDisconnected: (@Sendable (String) async -> Void)? private var onInvoke: (@Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse)? + private var serverEventSubscribers: [UUID: AsyncStream.Continuation] = [:] + private var canvasHostUrl: String? - func connect( + public func connect( url: URL, token: String?, password: String?, @@ -82,7 +83,7 @@ actor MacNodeGatewaySession { } } - func disconnect() async { + public func disconnect() async { await self.channel?.shutdown() self.channel = nil self.activeURL = nil @@ -90,7 +91,21 @@ actor MacNodeGatewaySession { self.activePassword = nil } - func sendEvent(event: String, payloadJSON: String?) async { + public func currentCanvasHostUrl() -> String? { + self.canvasHostUrl + } + + public func currentRemoteAddress() -> String? { + guard let url = self.activeURL else { return nil } + guard let host = url.host else { return url.absoluteString } + let port = url.port ?? (url.scheme == "wss" ? 443 : 80) + if host.contains(":") { + return "[\(host)]:\(port)" + } + return "\(host):\(port)" + } + + public func sendEvent(event: String, payloadJSON: String?) async { guard let channel = self.channel else { return } let params: [String: ClawdbotProtocol.AnyCodable] = [ "event": ClawdbotProtocol.AnyCodable(event), @@ -103,8 +118,37 @@ actor MacNodeGatewaySession { } } + public func request(method: String, paramsJSON: String?, timeoutSeconds: Int = 15) async throws -> Data { + guard let channel = self.channel else { + throw NSError(domain: "Gateway", code: 11, userInfo: [ + NSLocalizedDescriptionKey: "not connected", + ]) + } + + let params = try self.decodeParamsJSON(paramsJSON) + return try await channel.request( + method: method, + params: params, + timeoutMs: Double(timeoutSeconds * 1000)) + } + + public func subscribeServerEvents(bufferingNewest: Int = 200) -> AsyncStream { + let id = UUID() + let session = self + return AsyncStream(bufferingPolicy: .bufferingNewest(bufferingNewest)) { continuation in + self.serverEventSubscribers[id] = continuation + continuation.onTermination = { @Sendable _ in + Task { await session.removeServerEventSubscriber(id) } + } + } + } + private func handlePush(_ push: GatewayPush) async { switch push { + case let .snapshot(ok): + let raw = ok.canvashosturl?.trimmingCharacters(in: .whitespacesAndNewlines) + self.canvasHostUrl = (raw?.isEmpty == false) ? raw : nil + await self.onConnected?() case let .event(evt): await self.handleEvent(evt) default: @@ -113,6 +157,7 @@ actor MacNodeGatewaySession { } private func handleEvent(_ evt: EventFrame) async { + self.broadcastServerEvent(evt) guard evt.event == "node.invoke.request" else { return } guard let payload = evt.payload else { return } do { @@ -147,4 +192,34 @@ actor MacNodeGatewaySession { self.logger.error("node invoke result failed: \(error.localizedDescription, privacy: .public)") } } + + private func decodeParamsJSON( + _ paramsJSON: String?) throws -> [String: ClawdbotProtocol.AnyCodable]? + { + guard let paramsJSON, !paramsJSON.isEmpty else { return nil } + guard let data = paramsJSON.data(using: .utf8) else { + throw NSError(domain: "Gateway", code: 12, userInfo: [ + NSLocalizedDescriptionKey: "paramsJSON not UTF-8", + ]) + } + let raw = try JSONSerialization.jsonObject(with: data) + guard let dict = raw as? [String: Any] else { + return nil + } + return dict.reduce(into: [:]) { acc, entry in + acc[entry.key] = ClawdbotProtocol.AnyCodable(entry.value) + } + } + + private func broadcastServerEvent(_ evt: EventFrame) { + for (id, continuation) in self.serverEventSubscribers { + if continuation.yield(evt) == .terminated { + self.serverEventSubscribers.removeValue(forKey: id) + } + } + } + + private func removeServerEventSubscriber(_ id: UUID) { + self.serverEventSubscribers.removeValue(forKey: id) + } } diff --git a/apps/shared/ClawdbotKit/Sources/ClawdbotKit/GatewayPayloadDecoding.swift b/apps/shared/ClawdbotKit/Sources/ClawdbotKit/GatewayPayloadDecoding.swift new file mode 100644 index 000000000..4b1cc1659 --- /dev/null +++ b/apps/shared/ClawdbotKit/Sources/ClawdbotKit/GatewayPayloadDecoding.swift @@ -0,0 +1,20 @@ +import ClawdbotProtocol +import Foundation + +public enum GatewayPayloadDecoding { + public static func decode( + _ payload: ClawdbotProtocol.AnyCodable, + as _: T.Type = T.self) throws -> T + { + let data = try JSONEncoder().encode(payload) + return try JSONDecoder().decode(T.self, from: data) + } + + public static func decodeIfPresent( + _ payload: ClawdbotProtocol.AnyCodable?, + as _: T.Type = T.self) throws -> T? + { + guard let payload else { return nil } + return try self.decode(payload, as: T.self) + } +} diff --git a/apps/macos/Sources/Clawdbot/GatewayPush.swift b/apps/shared/ClawdbotKit/Sources/ClawdbotKit/GatewayPush.swift similarity index 92% rename from apps/macos/Sources/Clawdbot/GatewayPush.swift rename to apps/shared/ClawdbotKit/Sources/ClawdbotKit/GatewayPush.swift index 2183977fe..1e9b0e43b 100644 --- a/apps/macos/Sources/Clawdbot/GatewayPush.swift +++ b/apps/shared/ClawdbotKit/Sources/ClawdbotKit/GatewayPush.swift @@ -3,7 +3,7 @@ import ClawdbotProtocol /// Server-push messages from the gateway websocket. /// /// This is the in-process replacement for the legacy `NotificationCenter` fan-out. -enum GatewayPush: Sendable { +public enum GatewayPush: Sendable { /// A full snapshot that arrives on connect (or reconnect). case snapshot(HelloOk) /// A server push event frame. diff --git a/apps/macos/Sources/Clawdbot/NodeMode/GatewayTLSPinning.swift b/apps/shared/ClawdbotKit/Sources/ClawdbotKit/GatewayTLSPinning.swift similarity index 78% rename from apps/macos/Sources/Clawdbot/NodeMode/GatewayTLSPinning.swift rename to apps/shared/ClawdbotKit/Sources/ClawdbotKit/GatewayTLSPinning.swift index 69f36bdb8..26552b3bd 100644 --- a/apps/macos/Sources/Clawdbot/NodeMode/GatewayTLSPinning.swift +++ b/apps/shared/ClawdbotKit/Sources/ClawdbotKit/GatewayTLSPinning.swift @@ -2,14 +2,21 @@ import CryptoKit import Foundation import Security -struct GatewayTLSParams: Sendable { - let required: Bool - let expectedFingerprint: String? - let allowTOFU: Bool - let storeKey: String? +public struct GatewayTLSParams: Sendable { + public let required: Bool + public let expectedFingerprint: String? + public let allowTOFU: Bool + public let storeKey: String? + + public init(required: Bool, expectedFingerprint: String?, allowTOFU: Bool, storeKey: String?) { + self.required = required + self.expectedFingerprint = expectedFingerprint + self.allowTOFU = allowTOFU + self.storeKey = storeKey + } } -enum GatewayTLSStore { +public enum GatewayTLSStore { private static let suiteName = "com.clawdbot.shared" private static let keyPrefix = "gateway.tls." @@ -17,19 +24,19 @@ enum GatewayTLSStore { UserDefaults(suiteName: suiteName) ?? .standard } - static func loadFingerprint(stableID: String) -> String? { + public static func loadFingerprint(stableID: String) -> String? { let key = self.keyPrefix + stableID let raw = self.defaults.string(forKey: key)?.trimmingCharacters(in: .whitespacesAndNewlines) return raw?.isEmpty == false ? raw : nil } - static func saveFingerprint(_ value: String, stableID: String) { + public static func saveFingerprint(_ value: String, stableID: String) { let key = self.keyPrefix + stableID self.defaults.set(value, forKey: key) } } -final class GatewayTLSPinningSession: NSObject, WebSocketSessioning, URLSessionDelegate { +public final class GatewayTLSPinningSession: NSObject, WebSocketSessioning, URLSessionDelegate { private let params: GatewayTLSParams private lazy var session: URLSession = { let config = URLSessionConfiguration.default @@ -37,18 +44,18 @@ final class GatewayTLSPinningSession: NSObject, WebSocketSessioning, URLSessionD return URLSession(configuration: config, delegate: self, delegateQueue: nil) }() - init(params: GatewayTLSParams) { + public init(params: GatewayTLSParams) { self.params = params super.init() } - func makeWebSocketTask(url: URL) -> WebSocketTaskBox { + public func makeWebSocketTask(url: URL) -> WebSocketTaskBox { let task = self.session.webSocketTask(with: url) task.maximumMessageSize = 16 * 1024 * 1024 return WebSocketTaskBox(task: task) } - func urlSession( + public func urlSession( _ session: URLSession, didReceive challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void diff --git a/apps/shared/ClawdbotKit/Sources/ClawdbotKit/InstanceIdentity.swift b/apps/shared/ClawdbotKit/Sources/ClawdbotKit/InstanceIdentity.swift new file mode 100644 index 000000000..cbd81ba33 --- /dev/null +++ b/apps/shared/ClawdbotKit/Sources/ClawdbotKit/InstanceIdentity.swift @@ -0,0 +1,92 @@ +import Foundation + +#if canImport(UIKit) +import UIKit +#endif + +public enum InstanceIdentity { + private static let suiteName = "com.clawdbot.shared" + private static let instanceIdKey = "instanceId" + + private static var defaults: UserDefaults { + UserDefaults(suiteName: suiteName) ?? .standard + } + + public static let instanceId: String = { + let defaults = Self.defaults + if let existing = defaults.string(forKey: instanceIdKey)? + .trimmingCharacters(in: .whitespacesAndNewlines), + !existing.isEmpty + { + return existing + } + + let id = UUID().uuidString.lowercased() + defaults.set(id, forKey: instanceIdKey) + return id + }() + + public static let displayName: String = { +#if canImport(UIKit) + let name = UIDevice.current.name.trimmingCharacters(in: .whitespacesAndNewlines) + return name.isEmpty ? "clawdbot" : name +#else + if let name = Host.current().localizedName?.trimmingCharacters(in: .whitespacesAndNewlines), + !name.isEmpty + { + return name + } + return "clawdbot" +#endif + }() + + public static let modelIdentifier: String? = { +#if canImport(UIKit) + var systemInfo = utsname() + uname(&systemInfo) + let machine = withUnsafeBytes(of: &systemInfo.machine) { ptr in + String(bytes: ptr.prefix { $0 != 0 }, encoding: .utf8) + } + let trimmed = machine?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return trimmed.isEmpty ? nil : trimmed +#else + var size = 0 + guard sysctlbyname("hw.model", nil, &size, nil, 0) == 0, size > 1 else { return nil } + + var buffer = [CChar](repeating: 0, count: size) + guard sysctlbyname("hw.model", &buffer, &size, nil, 0) == 0 else { return nil } + + let bytes = buffer.prefix { $0 != 0 }.map { UInt8(bitPattern: $0) } + guard let raw = String(bytes: bytes, encoding: .utf8) else { return nil } + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed +#endif + }() + + public static let deviceFamily: String = { +#if canImport(UIKit) + switch UIDevice.current.userInterfaceIdiom { + case .pad: return "iPad" + case .phone: return "iPhone" + default: return "iOS" + } +#else + return "Mac" +#endif + }() + + public static let platformString: String = { + let v = ProcessInfo.processInfo.operatingSystemVersion +#if canImport(UIKit) + let name: String + switch UIDevice.current.userInterfaceIdiom { + case .pad: name = "iPadOS" + case .phone: name = "iOS" + default: name = "iOS" + } + return "\(name) \(v.majorVersion).\(v.minorVersion).\(v.patchVersion)" +#else + return "macOS \(v.majorVersion).\(v.minorVersion).\(v.patchVersion)" +#endif + }() +} diff --git a/apps/macos/Sources/ClawdbotProtocol/AnyCodable.swift b/apps/shared/ClawdbotKit/Sources/ClawdbotProtocol/AnyCodable.swift similarity index 100% rename from apps/macos/Sources/ClawdbotProtocol/AnyCodable.swift rename to apps/shared/ClawdbotKit/Sources/ClawdbotProtocol/AnyCodable.swift diff --git a/apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift b/apps/shared/ClawdbotKit/Sources/ClawdbotProtocol/GatewayModels.swift similarity index 100% rename from apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift rename to apps/shared/ClawdbotKit/Sources/ClawdbotProtocol/GatewayModels.swift diff --git a/apps/macos/Sources/ClawdbotProtocol/WizardHelpers.swift b/apps/shared/ClawdbotKit/Sources/ClawdbotProtocol/WizardHelpers.swift similarity index 100% rename from apps/macos/Sources/ClawdbotProtocol/WizardHelpers.swift rename to apps/shared/ClawdbotKit/Sources/ClawdbotProtocol/WizardHelpers.swift