refactor: migrate iOS gateway to unified ws

This commit is contained in:
Peter Steinberger 2026-01-19 05:44:36 +00:00
parent 2f8206862a
commit 795985d339
61 changed files with 1150 additions and 2276 deletions

View File

@ -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<Void, Error>) 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<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())
}
}
}
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<T: Sendable>(
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<Void, Error>) 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)
}
}
}

View File

@ -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)
}
}

View File

@ -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<BridgeRPCResponse, Error>] = [:]
private var serverEventSubscribers: [UUID: AsyncStream<BridgeEventFrame>.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[..<percentIndex]
let allowed = CharacterSet(charactersIn: "0123456789abcdefABCDEF:.")
let isIPAddressPrefix = prefix.unicodeScalars.allSatisfy { allowed.contains($0) }
if isIPAddressPrefix {
return String(prefix)
}
return hostString
}
func connect(
endpoint: NWEndpoint,
hello: BridgeHello,
tls: BridgeTLSParams? = nil,
onConnected: (@Sendable (String, String?) async -> 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<BridgeEventFrame> {
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<BridgeRPCResponse, Error>) 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<Void, Error>) 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<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())
}
}
}
private static func withTimeout<T: Sendable>(
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<NWConnection.State> {
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<NWConnection.State>,
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")
}
}
}

View File

@ -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)
}
}
}

View File

@ -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 }
}

View File

@ -44,7 +44,7 @@ actor CameraController {
{ {
let facing = params.facing ?? .front let facing = params.facing ?? .front
let format = params.format ?? .jpg 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. // If you need the full-res photo, explicitly request a larger maxWidth.
let maxWidth = params.maxWidth.flatMap { $0 > 0 ? $0 : nil } ?? 1600 let maxWidth = params.maxWidth.flatMap { $0 > 0 ? $0 : nil } ?? 1600
let quality = Self.clampQuality(params.quality) let quality = Self.clampQuality(params.quality)
@ -270,7 +270,7 @@ actor CameraController {
nonisolated static func clampDurationMs(_ ms: Int?) -> Int { nonisolated static func clampDurationMs(_ ms: Int?) -> Int {
let v = ms ?? 3000 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)) return min(60000, max(250, v))
} }

View File

@ -1,4 +1,5 @@
import ClawdbotChatUI import ClawdbotChatUI
import ClawdbotKit
import SwiftUI import SwiftUI
struct ChatSheet: View { struct ChatSheet: View {
@ -6,8 +7,8 @@ struct ChatSheet: View {
@State private var viewModel: ClawdbotChatViewModel @State private var viewModel: ClawdbotChatViewModel
private let userAccent: Color? private let userAccent: Color?
init(bridge: BridgeSession, sessionKey: String, userAccent: Color? = nil) { init(gateway: GatewayNodeSession, sessionKey: String, userAccent: Color? = nil) {
let transport = IOSBridgeChatTransport(bridge: bridge) let transport = IOSGatewayChatTransport(gateway: gateway)
self._viewModel = State( self._viewModel = State(
initialValue: ClawdbotChatViewModel( initialValue: ClawdbotChatViewModel(
sessionKey: sessionKey, sessionKey: sessionKey,

View File

@ -1,12 +1,13 @@
import ClawdbotChatUI import ClawdbotChatUI
import ClawdbotKit import ClawdbotKit
import ClawdbotProtocol
import Foundation import Foundation
struct IOSBridgeChatTransport: ClawdbotChatTransport, Sendable { struct IOSGatewayChatTransport: ClawdbotChatTransport, Sendable {
private let bridge: BridgeSession private let gateway: GatewayNodeSession
init(bridge: BridgeSession) { init(gateway: GatewayNodeSession) {
self.bridge = bridge self.gateway = gateway
} }
func abortRun(sessionKey: String, runId: String) async throws { 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 data = try JSONEncoder().encode(Params(sessionKey: sessionKey, runId: runId))
let json = String(data: data, encoding: .utf8) 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 { 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 data = try JSONEncoder().encode(Params(includeGlobal: true, includeUnknown: false, limit: limit))
let json = String(data: data, encoding: .utf8) 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) return try JSONDecoder().decode(ClawdbotChatSessionsListResponse.self, from: res)
} }
@ -35,14 +36,14 @@ struct IOSBridgeChatTransport: ClawdbotChatTransport, Sendable {
struct Subscribe: Codable { var sessionKey: String } struct Subscribe: Codable { var sessionKey: String }
let data = try JSONEncoder().encode(Subscribe(sessionKey: sessionKey)) let data = try JSONEncoder().encode(Subscribe(sessionKey: sessionKey))
let json = String(data: data, encoding: .utf8) 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 { func requestHistory(sessionKey: String) async throws -> ClawdbotChatHistoryPayload {
struct Params: Codable { var sessionKey: String } struct Params: Codable { var sessionKey: String }
let data = try JSONEncoder().encode(Params(sessionKey: sessionKey)) let data = try JSONEncoder().encode(Params(sessionKey: sessionKey))
let json = String(data: data, encoding: .utf8) 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) return try JSONDecoder().decode(ClawdbotChatHistoryPayload.self, from: res)
} }
@ -71,20 +72,20 @@ struct IOSBridgeChatTransport: ClawdbotChatTransport, Sendable {
idempotencyKey: idempotencyKey) idempotencyKey: idempotencyKey)
let data = try JSONEncoder().encode(params) let data = try JSONEncoder().encode(params)
let json = String(data: data, encoding: .utf8) 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) return try JSONDecoder().decode(ClawdbotChatSendResponse.self, from: res)
} }
func requestHealth(timeoutMs: Int) async throws -> Bool { func requestHealth(timeoutMs: Int) async throws -> Bool {
let seconds = max(1, Int(ceil(Double(timeoutMs) / 1000.0))) 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 return (try? JSONDecoder().decode(ClawdbotGatewayHealthOK.self, from: res))?.ok ?? true
} }
func events() -> AsyncStream<ClawdbotChatTransportEvent> { func events() -> AsyncStream<ClawdbotChatTransportEvent> {
AsyncStream { continuation in AsyncStream { continuation in
let task = Task { let task = Task {
let stream = await self.bridge.subscribeServerEvents() let stream = await self.gateway.subscribeServerEvents()
for await evt in stream { for await evt in stream {
if Task.isCancelled { return } if Task.isCancelled { return }
switch evt.event { switch evt.event {
@ -93,18 +94,18 @@ struct IOSBridgeChatTransport: ClawdbotChatTransport, Sendable {
case "seqGap": case "seqGap":
continuation.yield(.seqGap) continuation.yield(.seqGap)
case "health": case "health":
guard let json = evt.payloadJSON, let data = json.data(using: .utf8) else { break } guard let payload = evt.payload else { break }
let ok = (try? JSONDecoder().decode(ClawdbotGatewayHealthOK.self, from: data))?.ok ?? true let ok = (try? GatewayPayloadDecoding.decode(payload, as: ClawdbotGatewayHealthOK.self))?.ok ?? true
continuation.yield(.health(ok: ok)) continuation.yield(.health(ok: ok))
case "chat": case "chat":
guard let json = evt.payloadJSON, let data = json.data(using: .utf8) else { break } guard let payload = evt.payload else { break }
if let payload = try? JSONDecoder().decode(ClawdbotChatEventPayload.self, from: data) { if let chatPayload = try? GatewayPayloadDecoding.decode(payload, as: ClawdbotChatEventPayload.self) {
continuation.yield(.chat(payload)) continuation.yield(.chat(chatPayload))
} }
case "agent": case "agent":
guard let json = evt.payloadJSON, let data = json.data(using: .utf8) else { break } guard let payload = evt.payload else { break }
if let payload = try? JSONDecoder().decode(ClawdbotAgentEventPayload.self, from: data) { if let agentPayload = try? GatewayPayloadDecoding.decode(payload, as: ClawdbotAgentEventPayload.self) {
continuation.yield(.agent(payload)) continuation.yield(.agent(agentPayload))
} }
default: default:
break break

View File

@ -3,14 +3,14 @@ import SwiftUI
@main @main
struct ClawdbotApp: App { struct ClawdbotApp: App {
@State private var appModel: NodeAppModel @State private var appModel: NodeAppModel
@State private var bridgeController: BridgeConnectionController @State private var gatewayController: GatewayConnectionController
@Environment(\.scenePhase) private var scenePhase @Environment(\.scenePhase) private var scenePhase
init() { init() {
BridgeSettingsStore.bootstrapPersistence() GatewaySettingsStore.bootstrapPersistence()
let appModel = NodeAppModel() let appModel = NodeAppModel()
_appModel = State(initialValue: appModel) _appModel = State(initialValue: appModel)
_bridgeController = State(initialValue: BridgeConnectionController(appModel: appModel)) _gatewayController = State(initialValue: GatewayConnectionController(appModel: appModel))
} }
var body: some Scene { var body: some Scene {
@ -18,13 +18,13 @@ struct ClawdbotApp: App {
RootCanvas() RootCanvas()
.environment(self.appModel) .environment(self.appModel)
.environment(self.appModel.voiceWake) .environment(self.appModel.voiceWake)
.environment(self.bridgeController) .environment(self.gatewayController)
.onOpenURL { url in .onOpenURL { url in
Task { await self.appModel.handleDeepLink(url: url) } Task { await self.appModel.handleDeepLink(url: url) }
} }
.onChange(of: self.scenePhase) { _, newValue in .onChange(of: self.scenePhase) { _, newValue in
self.appModel.setScenePhase(newValue) self.appModel.setScenePhase(newValue)
self.bridgeController.setScenePhase(newValue) self.gatewayController.setScenePhase(newValue)
} }
} }
} }

View File

@ -6,40 +6,23 @@ import Observation
import SwiftUI import SwiftUI
import UIKit import UIKit
protocol BridgePairingClient: Sendable {
func pairAndHello(
endpoint: NWEndpoint,
hello: BridgeHello,
tls: BridgeTLSParams?,
onStatus: (@Sendable (String) -> Void)?) async throws -> String
}
extension BridgeClient: BridgePairingClient {}
@MainActor @MainActor
@Observable @Observable
final class BridgeConnectionController { final class GatewayConnectionController {
private(set) var bridges: [BridgeDiscoveryModel.DiscoveredBridge] = [] private(set) var gateways: [GatewayDiscoveryModel.DiscoveredGateway] = []
private(set) var discoveryStatusText: String = "Idle" 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 weak var appModel: NodeAppModel?
private var didAutoConnect = false private var didAutoConnect = false
private let bridgeClientFactory: @Sendable () -> any BridgePairingClient init(appModel: NodeAppModel, startDiscovery: Bool = true) {
init(
appModel: NodeAppModel,
startDiscovery: Bool = true,
bridgeClientFactory: @escaping @Sendable () -> any BridgePairingClient = { BridgeClient() })
{
self.appModel = appModel self.appModel = appModel
self.bridgeClientFactory = bridgeClientFactory
BridgeSettingsStore.bootstrapPersistence() GatewaySettingsStore.bootstrapPersistence()
let defaults = UserDefaults.standard 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.updateFromDiscovery()
self.observeDiscovery() 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() { private func updateFromDiscovery() {
let newBridges = self.discovery.bridges let newGateways = self.discovery.gateways
self.bridges = newBridges self.gateways = newGateways
self.discoveryStatusText = self.discovery.statusText self.discoveryStatusText = self.discovery.statusText
self.discoveryDebugLog = self.discovery.debugLog self.discoveryDebugLog = self.discovery.debugLog
self.updateLastDiscoveredBridge(from: newBridges) self.updateLastDiscoveredGateway(from: newGateways)
self.maybeAutoConnect() self.maybeAutoConnect()
} }
private func observeDiscovery() { private func observeDiscovery() {
withObservationTracking { withObservationTracking {
_ = self.discovery.bridges _ = self.discovery.gateways
_ = self.discovery.statusText _ = self.discovery.statusText
_ = self.discovery.debugLog _ = self.discovery.debugLog
} onChange: { [weak self] in } onChange: { [weak self] in
@ -90,181 +108,176 @@ final class BridgeConnectionController {
private func maybeAutoConnect() { private func maybeAutoConnect() {
guard !self.didAutoConnect else { return } guard !self.didAutoConnect else { return }
guard let appModel = self.appModel 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 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")? let instanceId = defaults.string(forKey: "node.instanceId")?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" .trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !instanceId.isEmpty else { return } guard !instanceId.isEmpty else { return }
let token = KeychainStore.loadString( let token = GatewaySettingsStore.loadGatewayToken(instanceId: instanceId)
service: "com.clawdbot.bridge", let password = GatewaySettingsStore.loadGatewayPassword(instanceId: instanceId)
account: self.keychainAccount(instanceId: instanceId))?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !token.isEmpty else { return }
if manualEnabled { if manualEnabled {
let manualHost = defaults.string(forKey: "bridge.manual.host")? let manualHost = defaults.string(forKey: "gateway.manual.host")?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" .trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !manualHost.isEmpty else { return } guard !manualHost.isEmpty else { return }
let manualPort = defaults.integer(forKey: "bridge.manual.port") let manualPort = defaults.integer(forKey: "gateway.manual.port")
let resolvedPort = manualPort > 0 ? manualPort : 18790 let resolvedPort = manualPort > 0 ? manualPort : 18789
guard let port = NWEndpoint.Port(rawValue: UInt16(resolvedPort)) else { return } 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 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( self.startAutoConnect(
endpoint: endpoint, url: url,
bridgeStableID: stableID, gatewayStableID: stableID,
tls: tlsParams, tls: tlsParams,
token: token, token: token,
instanceId: instanceId) password: password)
return return
} }
let preferredStableID = defaults.string(forKey: "bridge.preferredStableID")? let preferredStableID = defaults.string(forKey: "gateway.preferredStableID")?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" .trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let lastDiscoveredStableID = defaults.string(forKey: "bridge.lastDiscoveredStableID")? let lastDiscoveredStableID = defaults.string(forKey: "gateway.lastDiscoveredStableID")?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" .trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let candidates = [preferredStableID, lastDiscoveredStableID].filter { !$0.isEmpty } let candidates = [preferredStableID, lastDiscoveredStableID].filter { !$0.isEmpty }
guard let targetStableID = candidates.first(where: { id in guard let targetStableID = candidates.first(where: { id in
self.bridges.contains(where: { $0.stableID == id }) self.gateways.contains(where: { $0.stableID == id })
}) else { return } }) 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.didAutoConnect = true
self.startAutoConnect( self.startAutoConnect(
endpoint: target.endpoint, url: url,
bridgeStableID: target.stableID, gatewayStableID: target.stableID,
tls: tlsParams, tls: tlsParams,
token: token, 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 defaults = UserDefaults.standard
let preferred = defaults.string(forKey: "bridge.preferredStableID")? let preferred = defaults.string(forKey: "gateway.preferredStableID")?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" .trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let existingLast = defaults.string(forKey: "bridge.lastDiscoveredStableID")? let existingLast = defaults.string(forKey: "gateway.lastDiscoveredStableID")?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" .trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
// Avoid overriding user intent (preferred/lastDiscovered are also set on manual Connect). // Avoid overriding user intent (preferred/lastDiscovered are also set on manual Connect).
guard preferred.isEmpty, existingLast.isEmpty else { return } 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") defaults.set(first.stableID, forKey: "gateway.lastDiscoveredStableID")
BridgeSettingsStore.saveLastDiscoveredBridgeStableID(first.stableID) GatewaySettingsStore.saveLastDiscoveredGatewayStableID(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)"
} }
private func startAutoConnect( private func startAutoConnect(
endpoint: NWEndpoint, url: URL,
bridgeStableID: String, gatewayStableID: String,
tls: BridgeTLSParams?, tls: GatewayTLSParams?,
token: String, token: String?,
instanceId: String) password: String?)
{ {
guard let appModel else { return } guard let appModel else { return }
let connectOptions = self.makeConnectOptions()
Task { [weak self] in Task { [weak self] in
guard let self else { return } guard let self else { return }
do { await MainActor.run {
let hello = self.makeHello(token: token) appModel.gatewayStatusText = "Connecting…"
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)"
}
} }
appModel.connectToGateway(
url: url,
gatewayStableID: gatewayStableID,
tls: tls,
token: token,
password: password,
connectOptions: connectOptions)
} }
} }
private func resolveDiscoveredTLSParams( private func resolveDiscoveredTLSParams(gateway: GatewayDiscoveryModel.DiscoveredGateway) -> GatewayTLSParams? {
bridge: BridgeDiscoveryModel.DiscoveredBridge) -> BridgeTLSParams? let stableID = gateway.stableID
{ let stored = GatewayTLSStore.loadFingerprint(stableID: stableID)
let stableID = bridge.stableID
let stored = BridgeTLSStore.loadFingerprint(stableID: stableID)
if bridge.tlsEnabled || bridge.tlsFingerprintSha256 != nil { if gateway.tlsEnabled || gateway.tlsFingerprintSha256 != nil || stored != nil {
return BridgeTLSParams( return GatewayTLSParams(
required: true, required: true,
expectedFingerprint: bridge.tlsFingerprintSha256 ?? stored, expectedFingerprint: gateway.tlsFingerprintSha256 ?? stored,
allowTOFU: stored == nil, allowTOFU: stored == nil,
storeKey: stableID) storeKey: stableID)
} }
if let stored {
return BridgeTLSParams(
required: true,
expectedFingerprint: stored,
allowTOFU: false,
storeKey: stableID)
}
return nil return nil
} }
private func resolveManualTLSParams(stableID: String) -> BridgeTLSParams? { private func resolveManualTLSParams(stableID: String, tlsEnabled: Bool) -> GatewayTLSParams? {
if let stored = BridgeTLSStore.loadFingerprint(stableID: stableID) { let stored = GatewayTLSStore.loadFingerprint(stableID: stableID)
return BridgeTLSParams( if tlsEnabled || stored != nil {
return GatewayTLSParams(
required: true, required: true,
expectedFingerprint: stored, expectedFingerprint: stored,
allowTOFU: false, allowTOFU: stored == nil,
storeKey: stableID) storeKey: stableID)
} }
return BridgeTLSParams( return nil
required: false, }
expectedFingerprint: nil,
allowTOFU: true, private func resolveGatewayHost(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) -> String? {
storeKey: stableID) 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 { private func resolvedDisplayName(defaults: UserDefaults) -> String {
@ -313,6 +326,11 @@ final class BridgeConnectionController {
ClawdbotCanvasA2UICommand.pushJSONL.rawValue, ClawdbotCanvasA2UICommand.pushJSONL.rawValue,
ClawdbotCanvasA2UICommand.reset.rawValue, ClawdbotCanvasA2UICommand.reset.rawValue,
ClawdbotScreenCommand.record.rawValue, ClawdbotScreenCommand.record.rawValue,
ClawdbotSystemCommand.notify.rawValue,
ClawdbotSystemCommand.which.rawValue,
ClawdbotSystemCommand.run.rawValue,
ClawdbotSystemCommand.execApprovalsGet.rawValue,
ClawdbotSystemCommand.execApprovalsSet.rawValue,
] ]
let caps = Set(self.currentCaps()) let caps = Set(self.currentCaps())
@ -368,11 +386,7 @@ final class BridgeConnectionController {
} }
#if DEBUG #if DEBUG
extension BridgeConnectionController { extension GatewayConnectionController {
func _test_makeHello(token: String) -> BridgeHello {
self.makeHello(token: token)
}
func _test_resolvedDisplayName(defaults: UserDefaults) -> String { func _test_resolvedDisplayName(defaults: UserDefaults) -> String {
self.resolvedDisplayName(defaults: defaults) self.resolvedDisplayName(defaults: defaults)
} }
@ -401,8 +415,8 @@ extension BridgeConnectionController {
self.appVersion() self.appVersion()
} }
func _test_setBridges(_ bridges: [BridgeDiscoveryModel.DiscoveredBridge]) { func _test_setGateways(_ gateways: [GatewayDiscoveryModel.DiscoveredGateway]) {
self.bridges = bridges self.gateways = gateways
} }
func _test_triggerAutoConnect() { func _test_triggerAutoConnect() {

View File

@ -1,9 +1,9 @@
import SwiftUI import SwiftUI
import UIKit import UIKit
struct BridgeDiscoveryDebugLogView: View { struct GatewayDiscoveryDebugLogView: View {
@Environment(BridgeConnectionController.self) private var bridgeController @Environment(GatewayConnectionController.self) private var gatewayController
@AppStorage("bridge.discovery.debugLogs") private var debugLogsEnabled: Bool = false @AppStorage("gateway.discovery.debugLogs") private var debugLogsEnabled: Bool = false
var body: some View { var body: some View {
List { List {
@ -12,11 +12,11 @@ struct BridgeDiscoveryDebugLogView: View {
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
if self.bridgeController.discoveryDebugLog.isEmpty { if self.gatewayController.discoveryDebugLog.isEmpty {
Text("No log entries yet.") Text("No log entries yet.")
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} else { } else {
ForEach(self.bridgeController.discoveryDebugLog) { entry in ForEach(self.gatewayController.discoveryDebugLog) { entry in
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
Text(Self.formatTime(entry.ts)) Text(Self.formatTime(entry.ts))
.font(.caption) .font(.caption)
@ -35,13 +35,13 @@ struct BridgeDiscoveryDebugLogView: View {
Button("Copy") { Button("Copy") {
UIPasteboard.general.string = self.formattedLog() UIPasteboard.general.string = self.formattedLog()
} }
.disabled(self.bridgeController.discoveryDebugLog.isEmpty) .disabled(self.gatewayController.discoveryDebugLog.isEmpty)
} }
} }
} }
private func formattedLog() -> String { private func formattedLog() -> String {
self.bridgeController.discoveryDebugLog self.gatewayController.discoveryDebugLog
.map { "\(Self.formatISO($0.ts)) \($0.message)" } .map { "\(Self.formatISO($0.ts)) \($0.message)" }
.joined(separator: "\n") .joined(separator: "\n")
} }

View File

@ -5,14 +5,14 @@ import Observation
@MainActor @MainActor
@Observable @Observable
final class BridgeDiscoveryModel { final class GatewayDiscoveryModel {
struct DebugLogEntry: Identifiable, Equatable { struct DebugLogEntry: Identifiable, Equatable {
var id = UUID() var id = UUID()
var ts: Date var ts: Date
var message: String var message: String
} }
struct DiscoveredBridge: Identifiable, Equatable { struct DiscoveredGateway: Identifiable, Equatable {
var id: String { self.stableID } var id: String { self.stableID }
var name: String var name: String
var endpoint: NWEndpoint var endpoint: NWEndpoint
@ -21,19 +21,18 @@ final class BridgeDiscoveryModel {
var lanHost: String? var lanHost: String?
var tailnetDns: String? var tailnetDns: String?
var gatewayPort: Int? var gatewayPort: Int?
var bridgePort: Int?
var canvasPort: Int? var canvasPort: Int?
var tlsEnabled: Bool var tlsEnabled: Bool
var tlsFingerprintSha256: String? var tlsFingerprintSha256: String?
var cliPath: String? var cliPath: String?
} }
var bridges: [DiscoveredBridge] = [] var gateways: [DiscoveredGateway] = []
var statusText: String = "Idle" var statusText: String = "Idle"
private(set) var debugLog: [DebugLogEntry] = [] private(set) var debugLog: [DebugLogEntry] = []
private var browsers: [String: NWBrowser] = [:] private var browsers: [String: NWBrowser] = [:]
private var bridgesByDomain: [String: [DiscoveredBridge]] = [:] private var gatewaysByDomain: [String: [DiscoveredGateway]] = [:]
private var statesByDomain: [String: NWBrowser.State] = [:] private var statesByDomain: [String: NWBrowser.State] = [:]
private var debugLoggingEnabled = false private var debugLoggingEnabled = false
private var lastStableIDs = Set<String>() private var lastStableIDs = Set<String>()
@ -45,7 +44,7 @@ final class BridgeDiscoveryModel {
self.debugLog = [] self.debugLog = []
} else if !wasEnabled { } else if !wasEnabled {
self.appendDebugLog("debug logging enabled") 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 browser.browseResultsChangedHandler = { [weak self] results, _ in
Task { @MainActor in Task { @MainActor in
guard let self else { return } guard let self else { return }
self.bridgesByDomain[domain] = results.compactMap { result -> DiscoveredBridge? in self.gatewaysByDomain[domain] = results.compactMap { result -> DiscoveredGateway? in
switch result.endpoint { switch result.endpoint {
case let .service(name, _, _, _): case let .service(name, _, _, _):
let decodedName = BonjourEscapes.decode(name) let decodedName = BonjourEscapes.decode(name)
@ -82,18 +81,17 @@ final class BridgeDiscoveryModel {
.map(Self.prettifyInstanceName) .map(Self.prettifyInstanceName)
.flatMap { $0.isEmpty ? nil : $0 } .flatMap { $0.isEmpty ? nil : $0 }
let prettyName = prettyAdvertised ?? Self.prettifyInstanceName(decodedName) let prettyName = prettyAdvertised ?? Self.prettifyInstanceName(decodedName)
return DiscoveredBridge( return DiscoveredGateway(
name: prettyName, name: prettyName,
endpoint: result.endpoint, endpoint: result.endpoint,
stableID: BridgeEndpointID.stableID(result.endpoint), stableID: GatewayEndpointID.stableID(result.endpoint),
debugID: BridgeEndpointID.prettyDescription(result.endpoint), debugID: GatewayEndpointID.prettyDescription(result.endpoint),
lanHost: Self.txtValue(txt, key: "lanHost"), lanHost: Self.txtValue(txt, key: "lanHost"),
tailnetDns: Self.txtValue(txt, key: "tailnetDns"), tailnetDns: Self.txtValue(txt, key: "tailnetDns"),
gatewayPort: Self.txtIntValue(txt, key: "gatewayPort"), gatewayPort: Self.txtIntValue(txt, key: "gatewayPort"),
bridgePort: Self.txtIntValue(txt, key: "bridgePort"),
canvasPort: Self.txtIntValue(txt, key: "canvasPort"), canvasPort: Self.txtIntValue(txt, key: "canvasPort"),
tlsEnabled: Self.txtBoolValue(txt, key: "bridgeTls"), tlsEnabled: Self.txtBoolValue(txt, key: "gatewayTls"),
tlsFingerprintSha256: Self.txtValue(txt, key: "bridgeTlsSha256"), tlsFingerprintSha256: Self.txtValue(txt, key: "gatewayTlsSha256"),
cliPath: Self.txtValue(txt, key: "cliPath")) cliPath: Self.txtValue(txt, key: "cliPath"))
default: default:
return nil return nil
@ -101,12 +99,12 @@ final class BridgeDiscoveryModel {
} }
.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } .sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
self.recomputeBridges() self.recomputeGateways()
} }
} }
self.browsers[domain] = browser 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() browser.cancel()
} }
self.browsers = [:] self.browsers = [:]
self.bridgesByDomain = [:] self.gatewaysByDomain = [:]
self.statesByDomain = [:] self.statesByDomain = [:]
self.bridges = [] self.gateways = []
self.statusText = "Stopped" self.statusText = "Stopped"
} }
private func recomputeBridges() { private func recomputeGateways() {
let next = self.bridgesByDomain.values let next = self.gatewaysByDomain.values
.flatMap(\.self) .flatMap(\.self)
.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } .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.appendDebugLog("results: total=\(next.count) added=\(added.count) removed=\(removed.count)")
} }
self.lastStableIDs = nextIDs self.lastStableIDs = nextIDs
self.bridges = next self.gateways = next
} }
private func updateStatusText() { private func updateStatusText() {

View File

@ -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)
}
}
}

View File

@ -29,12 +29,12 @@
</dict> </dict>
<key>NSBonjourServices</key> <key>NSBonjourServices</key>
<array> <array>
<string>_clawdbot-bridge._tcp</string> <string>_clawdbot-gateway._tcp</string>
</array> </array>
<key>NSCameraUsageDescription</key> <key>NSCameraUsageDescription</key>
<string>Clawdbot can capture photos or short video clips when requested via the bridge.</string> <string>Clawdbot can capture photos or short video clips when requested via the gateway.</string>
<key>NSLocalNetworkUsageDescription</key> <key>NSLocalNetworkUsageDescription</key>
<string>Clawdbot discovers and connects to your Clawdbot bridge on the local network.</string> <string>Clawdbot discovers and connects to your Clawdbot gateway on the local network.</string>
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key> <key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
<string>Clawdbot can share your location in the background when you enable Always.</string> <string>Clawdbot can share your location in the background when you enable Always.</string>
<key>NSLocationWhenInUseUsageDescription</key> <key>NSLocationWhenInUseUsageDescription</key>

View File

@ -18,15 +18,15 @@ final class NodeAppModel {
let screen = ScreenController() let screen = ScreenController()
let camera = CameraController() let camera = CameraController()
private let screenRecorder = ScreenRecordService() private let screenRecorder = ScreenRecordService()
var bridgeStatusText: String = "Offline" var gatewayStatusText: String = "Offline"
var bridgeServerName: String? var gatewayServerName: String?
var bridgeRemoteAddress: String? var gatewayRemoteAddress: String?
var connectedBridgeID: String? var connectedGatewayID: String?
var seamColorHex: String? var seamColorHex: String?
var mainSessionKey: String = "main" var mainSessionKey: String = "main"
private let bridge = BridgeSession() private let gateway = GatewayNodeSession()
private var bridgeTask: Task<Void, Never>? private var gatewayTask: Task<Void, Never>?
private var voiceWakeSyncTask: Task<Void, Never>? private var voiceWakeSyncTask: Task<Void, Never>?
@ObservationIgnored private var cameraHUDDismissTask: Task<Void, Never>? @ObservationIgnored private var cameraHUDDismissTask: Task<Void, Never>?
let voiceWake = VoiceWakeManager() let voiceWake = VoiceWakeManager()
@ -34,7 +34,8 @@ final class NodeAppModel {
private let locationService = LocationService() private let locationService = LocationService()
private var lastAutoA2uiURL: String? private var lastAutoA2uiURL: String?
var bridgeSession: BridgeSession { self.bridge } private var gatewayConnected = false
var gatewaySession: GatewayNodeSession { self.gateway }
var cameraHUDText: String? var cameraHUDText: String?
var cameraHUDKind: CameraHUDKind? var cameraHUDKind: CameraHUDKind?
@ -54,7 +55,7 @@ final class NodeAppModel {
let enabled = UserDefaults.standard.bool(forKey: "voiceWake.enabled") let enabled = UserDefaults.standard.bool(forKey: "voiceWake.enabled")
self.voiceWake.setEnabled(enabled) self.voiceWake.setEnabled(enabled)
self.talkMode.attachBridge(self.bridge) self.talkMode.attachGateway(self.gateway)
let talkEnabled = UserDefaults.standard.bool(forKey: "talk.enabled") let talkEnabled = UserDefaults.standard.bool(forKey: "talk.enabled")
self.talkMode.setEnabled(talkEnabled) self.talkMode.setEnabled(talkEnabled)
@ -120,9 +121,9 @@ final class NodeAppModel {
let ok: Bool let ok: Bool
var errorText: String? var errorText: String?
if await !self.isBridgeConnected() { if await !self.isGatewayConnected() {
ok = false ok = false
errorText = "bridge not connected" errorText = "gateway not connected"
} else { } else {
do { do {
try await self.sendAgentRequest(link: AgentDeepLink( try await self.sendAgentRequest(link: AgentDeepLink(
@ -150,7 +151,7 @@ final class NodeAppModel {
} }
private func resolveA2UIHostURL() async -> String? { 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) let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty, let base = URL(string: trimmed) else { return nil } guard !trimmed.isEmpty, let base = URL(string: trimmed) else { return nil }
return base.appendingPathComponent("__clawdbot__/a2ui/").absoluteString + "?platform=ios" return base.appendingPathComponent("__clawdbot__/a2ui/").absoluteString + "?platform=ios"
@ -202,56 +203,70 @@ final class NodeAppModel {
} }
} }
func connectToBridge( func connectToGateway(
endpoint: NWEndpoint, url: URL,
bridgeStableID: String, gatewayStableID: String,
tls: BridgeTLSParams?, tls: GatewayTLSParams?,
hello: BridgeHello) token: String?,
password: String?,
connectOptions: GatewayConnectOptions)
{ {
self.bridgeTask?.cancel() self.gatewayTask?.cancel()
self.bridgeServerName = nil self.gatewayServerName = nil
self.bridgeRemoteAddress = nil self.gatewayRemoteAddress = nil
let id = bridgeStableID.trimmingCharacters(in: .whitespacesAndNewlines) let id = gatewayStableID.trimmingCharacters(in: .whitespacesAndNewlines)
self.connectedBridgeID = id.isEmpty ? BridgeEndpointID.stableID(endpoint) : id self.connectedGatewayID = id.isEmpty ? url.absoluteString : id
self.gatewayConnected = false
self.voiceWakeSyncTask?.cancel() self.voiceWakeSyncTask?.cancel()
self.voiceWakeSyncTask = nil self.voiceWakeSyncTask = nil
let sessionBox = tls.map { WebSocketSessionBox(session: GatewayTLSPinningSession(params: $0)) }
self.bridgeTask = Task { self.gatewayTask = Task {
var attempt = 0 var attempt = 0
while !Task.isCancelled { while !Task.isCancelled {
await MainActor.run { await MainActor.run {
if attempt == 0 { if attempt == 0 {
self.bridgeStatusText = "Connecting…" self.gatewayStatusText = "Connecting…"
} else { } else {
self.bridgeStatusText = "Reconnecting…" self.gatewayStatusText = "Reconnecting…"
} }
self.bridgeServerName = nil self.gatewayServerName = nil
self.bridgeRemoteAddress = nil self.gatewayRemoteAddress = nil
} }
do { do {
try await self.bridge.connect( try await self.gateway.connect(
endpoint: endpoint, url: url,
hello: hello, token: token,
tls: tls, password: password,
onConnected: { [weak self] serverName, mainSessionKey in connectOptions: connectOptions,
sessionBox: sessionBox,
onConnected: { [weak self] in
guard let self else { return } guard let self else { return }
await MainActor.run { await MainActor.run {
self.bridgeStatusText = "Connected" self.gatewayStatusText = "Connected"
self.bridgeServerName = serverName self.gatewayServerName = url.host ?? "gateway"
self.gatewayConnected = true
} }
await MainActor.run { if let addr = await self.gateway.currentRemoteAddress() {
self.applyMainSessionKey(mainSessionKey)
}
if let addr = await self.bridge.currentRemoteAddress() {
await MainActor.run { await MainActor.run {
self.bridgeRemoteAddress = addr self.gatewayRemoteAddress = addr
} }
} }
await self.refreshBrandingFromGateway() await self.refreshBrandingFromGateway()
await self.startVoiceWakeSync() await self.startVoiceWakeSync()
await self.showA2UIOnConnectIfNeeded() 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 onInvoke: { [weak self] req in
guard let self else { guard let self else {
return BridgeInvokeResponse( return BridgeInvokeResponse(
@ -265,19 +280,16 @@ final class NodeAppModel {
}) })
if Task.isCancelled { break } if Task.isCancelled { break }
await MainActor.run { attempt = 0
self.showLocalCanvasOnDisconnect() try? await Task.sleep(nanoseconds: 1_000_000_000)
}
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))
} catch { } catch {
if Task.isCancelled { break } if Task.isCancelled { break }
attempt += 1 attempt += 1
await MainActor.run { await MainActor.run {
self.bridgeStatusText = "Bridge error: \(error.localizedDescription)" self.gatewayStatusText = "Gateway error: \(error.localizedDescription)"
self.bridgeServerName = nil self.gatewayServerName = nil
self.bridgeRemoteAddress = nil self.gatewayRemoteAddress = nil
self.gatewayConnected = false
self.showLocalCanvasOnDisconnect() self.showLocalCanvasOnDisconnect()
} }
let sleepSeconds = min(8.0, 0.5 * pow(1.7, Double(attempt))) let sleepSeconds = min(8.0, 0.5 * pow(1.7, Double(attempt)))
@ -286,10 +298,11 @@ final class NodeAppModel {
} }
await MainActor.run { await MainActor.run {
self.bridgeStatusText = "Offline" self.gatewayStatusText = "Offline"
self.bridgeServerName = nil self.gatewayServerName = nil
self.bridgeRemoteAddress = nil self.gatewayRemoteAddress = nil
self.connectedBridgeID = nil self.connectedGatewayID = nil
self.gatewayConnected = false
self.seamColorHex = nil self.seamColorHex = nil
if !SessionKey.isCanonicalMainSessionKey(self.mainSessionKey) { if !SessionKey.isCanonicalMainSessionKey(self.mainSessionKey) {
self.mainSessionKey = "main" self.mainSessionKey = "main"
@ -300,16 +313,17 @@ final class NodeAppModel {
} }
} }
func disconnectBridge() { func disconnectGateway() {
self.bridgeTask?.cancel() self.gatewayTask?.cancel()
self.bridgeTask = nil self.gatewayTask = nil
self.voiceWakeSyncTask?.cancel() self.voiceWakeSyncTask?.cancel()
self.voiceWakeSyncTask = nil self.voiceWakeSyncTask = nil
Task { await self.bridge.disconnect() } Task { await self.gateway.disconnect() }
self.bridgeStatusText = "Offline" self.gatewayStatusText = "Offline"
self.bridgeServerName = nil self.gatewayServerName = nil
self.bridgeRemoteAddress = nil self.gatewayRemoteAddress = nil
self.connectedBridgeID = nil self.connectedGatewayID = nil
self.gatewayConnected = false
self.seamColorHex = nil self.seamColorHex = nil
if !SessionKey.isCanonicalMainSessionKey(self.mainSessionKey) { if !SessionKey.isCanonicalMainSessionKey(self.mainSessionKey) {
self.mainSessionKey = "main" self.mainSessionKey = "main"
@ -347,7 +361,7 @@ final class NodeAppModel {
private func refreshBrandingFromGateway() async { private func refreshBrandingFromGateway() async {
do { 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 json = try JSONSerialization.jsonObject(with: res) as? [String: Any] else { return }
guard let config = json["config"] as? [String: Any] else { return } guard let config = json["config"] as? [String: Any] else { return }
let ui = config["ui"] as? [String: Any] let ui = config["ui"] as? [String: Any]
@ -378,7 +392,7 @@ final class NodeAppModel {
else { return } else { return }
do { 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 { } catch {
// Best-effort only. // Best-effort only.
} }
@ -391,7 +405,7 @@ final class NodeAppModel {
await self.refreshWakeWordsFromGateway() await self.refreshWakeWordsFromGateway()
let stream = await self.bridge.subscribeServerEvents(bufferingNewest: 200) let stream = await self.gateway.subscribeServerEvents(bufferingNewest: 200)
for await evt in stream { for await evt in stream {
if Task.isCancelled { return } if Task.isCancelled { return }
guard evt.event == "voicewake.changed" else { continue } guard evt.event == "voicewake.changed" else { continue }
@ -404,7 +418,7 @@ final class NodeAppModel {
private func refreshWakeWordsFromGateway() async { private func refreshWakeWordsFromGateway() async {
do { 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 } guard let triggers = VoiceWakePreferences.decodeGatewayTriggers(from: data) else { return }
VoiceWakePreferences.saveTriggerWords(triggers) VoiceWakePreferences.saveTriggerWords(triggers)
} catch { } catch {
@ -413,6 +427,11 @@ final class NodeAppModel {
} }
func sendVoiceTranscript(text: String, sessionKey: String?) async throws { 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 { struct Payload: Codable {
var text: String var text: String
var sessionKey: String? var sessionKey: String?
@ -424,7 +443,7 @@ final class NodeAppModel {
NSLocalizedDescriptionKey: "Failed to encode voice transcript payload as UTF-8", 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 { func handleDeepLink(url: URL) async {
@ -445,8 +464,8 @@ final class NodeAppModel {
return return
} }
guard await self.isBridgeConnected() else { guard await self.isGatewayConnected() else {
self.screen.errorText = "Bridge not connected (cannot forward deep link)." self.screen.errorText = "Gateway not connected (cannot forward deep link)."
return 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.) // (Key-based unattended auth is handled on macOS for clawdbot:// links.)
let data = try JSONEncoder().encode(link) let data = try JSONEncoder().encode(link)
guard let json = String(bytes: data, encoding: .utf8) else { 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", 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 { private func isGatewayConnected() async -> Bool {
if case .connected = await self.bridge.state { return true } self.gatewayConnected
return false
} }
private func handleInvoke(_ req: BridgeInvokeRequest) async -> BridgeInvokeResponse { private func handleInvoke(_ req: BridgeInvokeRequest) async -> BridgeInvokeResponse {
@ -849,7 +867,7 @@ final class NodeAppModel {
private static func decodeParams<T: Decodable>(_ type: T.Type, from json: String?) throws -> T { private static func decodeParams<T: Decodable>(_ type: T.Type, from json: String?) throws -> T {
guard let json, let data = json.data(using: .utf8) else { 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", NSLocalizedDescriptionKey: "INVALID_REQUEST: paramsJSON required",
]) ])
} }

View File

@ -29,7 +29,7 @@ struct RootCanvas: View {
ZStack { ZStack {
CanvasContent( CanvasContent(
systemColorScheme: self.systemColorScheme, systemColorScheme: self.systemColorScheme,
bridgeStatus: self.bridgeStatus, gatewayStatus: self.gatewayStatus,
voiceWakeEnabled: self.voiceWakeEnabled, voiceWakeEnabled: self.voiceWakeEnabled,
voiceWakeToastText: self.voiceWakeToastText, voiceWakeToastText: self.voiceWakeToastText,
cameraHUDText: self.appModel.cameraHUDText, cameraHUDText: self.appModel.cameraHUDText,
@ -52,7 +52,7 @@ struct RootCanvas: View {
SettingsTab() SettingsTab()
case .chat: case .chat:
ChatSheet( ChatSheet(
bridge: self.appModel.bridgeSession, gateway: self.appModel.gatewaySession,
sessionKey: self.appModel.mainSessionKey, sessionKey: self.appModel.mainSessionKey,
userAccent: self.appModel.seamColor) userAccent: self.appModel.seamColor)
} }
@ -62,9 +62,9 @@ struct RootCanvas: View {
.onChange(of: self.scenePhase) { _, _ in self.updateIdleTimer() } .onChange(of: self.scenePhase) { _, _ in self.updateIdleTimer() }
.onAppear { self.updateCanvasDebugStatus() } .onAppear { self.updateCanvasDebugStatus() }
.onChange(of: self.canvasDebugStatusEnabled) { _, _ in self.updateCanvasDebugStatus() } .onChange(of: self.canvasDebugStatusEnabled) { _, _ in self.updateCanvasDebugStatus() }
.onChange(of: self.appModel.bridgeStatusText) { _, _ in self.updateCanvasDebugStatus() } .onChange(of: self.appModel.gatewayStatusText) { _, _ in self.updateCanvasDebugStatus() }
.onChange(of: self.appModel.bridgeServerName) { _, _ in self.updateCanvasDebugStatus() } .onChange(of: self.appModel.gatewayServerName) { _, _ in self.updateCanvasDebugStatus() }
.onChange(of: self.appModel.bridgeRemoteAddress) { _, _ in self.updateCanvasDebugStatus() } .onChange(of: self.appModel.gatewayRemoteAddress) { _, _ in self.updateCanvasDebugStatus() }
.onChange(of: self.voiceWake.lastTriggeredCommand) { _, newValue in .onChange(of: self.voiceWake.lastTriggeredCommand) { _, newValue in
guard let newValue else { return } guard let newValue else { return }
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines) let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
@ -91,10 +91,10 @@ struct RootCanvas: View {
} }
} }
private var bridgeStatus: StatusPill.BridgeState { private var gatewayStatus: StatusPill.GatewayState {
if self.appModel.bridgeServerName != nil { return .connected } 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") || if text.localizedCaseInsensitiveContains("connecting") ||
text.localizedCaseInsensitiveContains("reconnecting") text.localizedCaseInsensitiveContains("reconnecting")
{ {
@ -115,8 +115,8 @@ struct RootCanvas: View {
private func updateCanvasDebugStatus() { private func updateCanvasDebugStatus() {
self.appModel.screen.setDebugStatusEnabled(self.canvasDebugStatusEnabled) self.appModel.screen.setDebugStatusEnabled(self.canvasDebugStatusEnabled)
guard self.canvasDebugStatusEnabled else { return } guard self.canvasDebugStatusEnabled else { return }
let title = self.appModel.bridgeStatusText.trimmingCharacters(in: .whitespacesAndNewlines) let title = self.appModel.gatewayStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
let subtitle = self.appModel.bridgeServerName ?? self.appModel.bridgeRemoteAddress let subtitle = self.appModel.gatewayServerName ?? self.appModel.gatewayRemoteAddress
self.appModel.screen.updateDebugStatus(title: title, subtitle: subtitle) 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.enabled") private var talkEnabled: Bool = false
@AppStorage("talk.button.enabled") private var talkButtonEnabled: Bool = true @AppStorage("talk.button.enabled") private var talkButtonEnabled: Bool = true
var systemColorScheme: ColorScheme var systemColorScheme: ColorScheme
var bridgeStatus: StatusPill.BridgeState var gatewayStatus: StatusPill.GatewayState
var voiceWakeEnabled: Bool var voiceWakeEnabled: Bool
var voiceWakeToastText: String? var voiceWakeToastText: String?
var cameraHUDText: String? var cameraHUDText: String?
@ -177,7 +177,7 @@ private struct CanvasContent: View {
} }
.overlay(alignment: .topLeading) { .overlay(alignment: .topLeading) {
StatusPill( StatusPill(
bridge: self.bridgeStatus, gateway: self.gatewayStatus,
voiceWakeEnabled: self.voiceWakeEnabled, voiceWakeEnabled: self.voiceWakeEnabled,
activity: self.statusActivity, activity: self.statusActivity,
brighten: self.brightenButtons, brighten: self.brightenButtons,
@ -208,15 +208,15 @@ private struct CanvasContent: View {
tint: .orange) tint: .orange)
} }
let bridgeStatus = self.appModel.bridgeStatusText.trimmingCharacters(in: .whitespacesAndNewlines) let gatewayStatus = self.appModel.gatewayStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
let bridgeLower = bridgeStatus.lowercased() let gatewayLower = gatewayStatus.lowercased()
if bridgeLower.contains("repair") { if gatewayLower.contains("repair") {
return StatusPill.Activity(title: "Repairing…", systemImage: "wrench.and.screwdriver", tint: .orange) 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") 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 { if self.appModel.screenRecordActive {
return StatusPill.Activity(title: "Recording screen…", systemImage: "record.circle.fill", tint: .red) return StatusPill.Activity(title: "Recording screen…", systemImage: "record.circle.fill", tint: .red)

View File

@ -24,7 +24,7 @@ struct RootTabs: View {
} }
.overlay(alignment: .topLeading) { .overlay(alignment: .topLeading) {
StatusPill( StatusPill(
bridge: self.bridgeStatus, gateway: self.gatewayStatus,
voiceWakeEnabled: self.voiceWakeEnabled, voiceWakeEnabled: self.voiceWakeEnabled,
activity: self.statusActivity, activity: self.statusActivity,
onTap: { self.selectedTab = 2 }) onTap: { self.selectedTab = 2 })
@ -64,10 +64,10 @@ struct RootTabs: View {
} }
} }
private var bridgeStatus: StatusPill.BridgeState { private var gatewayStatus: StatusPill.GatewayState {
if self.appModel.bridgeServerName != nil { return .connected } 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") || if text.localizedCaseInsensitiveContains("connecting") ||
text.localizedCaseInsensitiveContains("reconnecting") text.localizedCaseInsensitiveContains("reconnecting")
{ {
@ -90,15 +90,15 @@ struct RootTabs: View {
tint: .orange) tint: .orange)
} }
let bridgeStatus = self.appModel.bridgeStatusText.trimmingCharacters(in: .whitespacesAndNewlines) let gatewayStatus = self.appModel.gatewayStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
let bridgeLower = bridgeStatus.lowercased() let gatewayLower = gatewayStatus.lowercased()
if bridgeLower.contains("repair") { if gatewayLower.contains("repair") {
return StatusPill.Activity(title: "Repairing…", systemImage: "wrench.and.screwdriver", tint: .orange) 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") 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 { if self.appModel.screenRecordActive {
return StatusPill.Activity(title: "Recording screen…", systemImage: "record.circle.fill", tint: .red) return StatusPill.Activity(title: "Recording screen…", systemImage: "record.circle.fill", tint: .red)

View File

@ -15,7 +15,7 @@ extension ConnectStatusStore: @unchecked Sendable {}
struct SettingsTab: View { struct SettingsTab: View {
@Environment(NodeAppModel.self) private var appModel: NodeAppModel @Environment(NodeAppModel.self) private var appModel: NodeAppModel
@Environment(VoiceWakeManager.self) private var voiceWake: VoiceWakeManager @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 @Environment(\.dismiss) private var dismiss
@AppStorage("node.displayName") private var displayName: String = "iOS Node" @AppStorage("node.displayName") private var displayName: String = "iOS Node"
@AppStorage("node.instanceId") private var instanceId: String = UUID().uuidString @AppStorage("node.instanceId") private var instanceId: String = UUID().uuidString
@ -26,17 +26,20 @@ struct SettingsTab: View {
@AppStorage("location.enabledMode") private var locationEnabledModeRaw: String = ClawdbotLocationMode.off.rawValue @AppStorage("location.enabledMode") private var locationEnabledModeRaw: String = ClawdbotLocationMode.off.rawValue
@AppStorage("location.preciseEnabled") private var locationPreciseEnabled: Bool = true @AppStorage("location.preciseEnabled") private var locationPreciseEnabled: Bool = true
@AppStorage("screen.preventSleep") private var preventSleep: Bool = true @AppStorage("screen.preventSleep") private var preventSleep: Bool = true
@AppStorage("bridge.preferredStableID") private var preferredBridgeStableID: String = "" @AppStorage("gateway.preferredStableID") private var preferredGatewayStableID: String = ""
@AppStorage("bridge.lastDiscoveredStableID") private var lastDiscoveredBridgeStableID: String = "" @AppStorage("gateway.lastDiscoveredStableID") private var lastDiscoveredGatewayStableID: String = ""
@AppStorage("bridge.manual.enabled") private var manualBridgeEnabled: Bool = false @AppStorage("gateway.manual.enabled") private var manualGatewayEnabled: Bool = false
@AppStorage("bridge.manual.host") private var manualBridgeHost: String = "" @AppStorage("gateway.manual.host") private var manualGatewayHost: String = ""
@AppStorage("bridge.manual.port") private var manualBridgePort: Int = 18790 @AppStorage("gateway.manual.port") private var manualGatewayPort: Int = 18789
@AppStorage("bridge.discovery.debugLogs") private var discoveryDebugLogsEnabled: Bool = false @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 @AppStorage("canvas.debugStatusEnabled") private var canvasDebugStatusEnabled: Bool = false
@State private var connectStatus = ConnectStatusStore() @State private var connectStatus = ConnectStatusStore()
@State private var connectingBridgeID: String? @State private var connectingGatewayID: String?
@State private var localIPAddress: String? @State private var localIPAddress: String?
@State private var lastLocationModeRaw: String = ClawdbotLocationMode.off.rawValue @State private var lastLocationModeRaw: String = ClawdbotLocationMode.off.rawValue
@State private var gatewayToken: String = ""
@State private var gatewayPassword: String = ""
var body: some View { var body: some View {
NavigationStack { NavigationStack {
@ -61,12 +64,12 @@ struct SettingsTab: View {
LabeledContent("Model", value: self.modelIdentifier()) LabeledContent("Model", value: self.modelIdentifier())
} }
Section("Bridge") { Section("Gateway") {
LabeledContent("Discovery", value: self.bridgeController.discoveryStatusText) LabeledContent("Discovery", value: self.gatewayController.discoveryStatusText)
LabeledContent("Status", value: self.appModel.bridgeStatusText) LabeledContent("Status", value: self.appModel.gatewayStatusText)
if let serverName = self.appModel.bridgeServerName { if let serverName = self.appModel.gatewayServerName {
LabeledContent("Server", value: serverName) LabeledContent("Server", value: serverName)
if let addr = self.appModel.bridgeRemoteAddress { if let addr = self.appModel.gatewayRemoteAddress {
let parts = Self.parseHostPort(from: addr) let parts = Self.parseHostPort(from: addr)
let urlString = Self.httpURLString(host: parts?.host, port: parts?.port, fallback: addr) let urlString = Self.httpURLString(host: parts?.host, port: parts?.port, fallback: addr)
LabeledContent("Address") { LabeledContent("Address") {
@ -96,12 +99,12 @@ struct SettingsTab: View {
} }
Button("Disconnect", role: .destructive) { Button("Disconnect", role: .destructive) {
self.appModel.disconnectBridge() self.appModel.disconnectGateway()
} }
self.bridgeList(showing: .availableOnly) self.gatewayList(showing: .availableOnly)
} else { } else {
self.bridgeList(showing: .all) self.gatewayList(showing: .all)
} }
if let text = self.connectStatus.text { if let text = self.connectStatus.text {
@ -111,19 +114,21 @@ struct SettingsTab: View {
} }
DisclosureGroup("Advanced") { 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) .textInputAutocapitalization(.never)
.autocorrectionDisabled() .autocorrectionDisabled()
TextField("Port", value: self.$manualBridgePort, format: .number) TextField("Port", value: self.$manualGatewayPort, format: .number)
.keyboardType(.numberPad) .keyboardType(.numberPad)
Toggle("Use TLS", isOn: self.$manualGatewayTLS)
Button { Button {
Task { await self.connectManual() } Task { await self.connectManual() }
} label: { } label: {
if self.connectingBridgeID == "manual" { if self.connectingGatewayID == "manual" {
HStack(spacing: 8) { HStack(spacing: 8) {
ProgressView() ProgressView()
.progressViewStyle(.circular) .progressViewStyle(.circular)
@ -133,26 +138,32 @@ struct SettingsTab: View {
Text("Connect (Manual)") Text("Connect (Manual)")
} }
} }
.disabled(self.connectingBridgeID != nil || self.manualBridgeHost .disabled(self.connectingGatewayID != nil || self.manualGatewayHost
.trimmingCharacters(in: .whitespacesAndNewlines) .trimmingCharacters(in: .whitespacesAndNewlines)
.isEmpty || self.manualBridgePort <= 0 || self.manualBridgePort > 65535) .isEmpty || self.manualGatewayPort <= 0 || self.manualGatewayPort > 65535)
Text( Text(
"Use this when mDNS/Bonjour discovery is blocked. " "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) .font(.footnote)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
Toggle("Discovery Debug Logs", isOn: self.$discoveryDebugLogsEnabled) Toggle("Discovery Debug Logs", isOn: self.$discoveryDebugLogsEnabled)
.onChange(of: self.discoveryDebugLogsEnabled) { _, newValue in .onChange(of: self.discoveryDebugLogsEnabled) { _, newValue in
self.bridgeController.setDiscoveryDebugLoggingEnabled(newValue) self.gatewayController.setDiscoveryDebugLoggingEnabled(newValue)
} }
NavigationLink("Discovery Logs") { NavigationLink("Discovery Logs") {
BridgeDiscoveryDebugLogView() GatewayDiscoveryDebugLogView()
} }
Toggle("Debug Canvas Status", isOn: self.$canvasDebugStatusEnabled) 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") { Section("Camera") {
Toggle("Allow Camera", isOn: self.$cameraEnabled) 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) .font(.footnote)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
@ -221,13 +232,30 @@ struct SettingsTab: View {
.onAppear { .onAppear {
self.localIPAddress = Self.primaryIPv4Address() self.localIPAddress = Self.primaryIPv4Address()
self.lastLocationModeRaw = self.locationEnabledModeRaw 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) let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return } 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 self.connectStatus.text = nil
} }
.onChange(of: self.locationEnabledModeRaw) { _, newValue in .onChange(of: self.locationEnabledModeRaw) { _, newValue in
@ -248,14 +276,14 @@ struct SettingsTab: View {
} }
@ViewBuilder @ViewBuilder
private func bridgeList(showing: BridgeListMode) -> some View { private func gatewayList(showing: GatewayListMode) -> some View {
if self.bridgeController.bridges.isEmpty { if self.gatewayController.gateways.isEmpty {
Text("No bridges found yet.") Text("No gateways found yet.")
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} else { } else {
let connectedID = self.appModel.connectedBridgeID let connectedID = self.appModel.connectedGatewayID
let rows = self.bridgeController.bridges.filter { bridge in let rows = self.gatewayController.gateways.filter { gateway in
let isConnected = bridge.stableID == connectedID let isConnected = gateway.stableID == connectedID
switch showing { switch showing {
case .all: case .all:
return true return true
@ -265,14 +293,14 @@ struct SettingsTab: View {
} }
if rows.isEmpty, showing == .availableOnly { if rows.isEmpty, showing == .availableOnly {
Text("No other bridges found.") Text("No other gateways found.")
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} else { } else {
ForEach(rows) { bridge in ForEach(rows) { gateway in
HStack { HStack {
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
Text(bridge.name) Text(gateway.name)
let detailLines = self.bridgeDetailLines(bridge) let detailLines = self.gatewayDetailLines(gateway)
ForEach(detailLines, id: \.self) { line in ForEach(detailLines, id: \.self) { line in
Text(line) Text(line)
.font(.footnote) .font(.footnote)
@ -282,31 +310,27 @@ struct SettingsTab: View {
Spacer() Spacer()
Button { Button {
Task { await self.connect(bridge) } Task { await self.connect(gateway) }
} label: { } label: {
if self.connectingBridgeID == bridge.id { if self.connectingGatewayID == gateway.id {
ProgressView() ProgressView()
.progressViewStyle(.circular) .progressViewStyle(.circular)
} else { } else {
Text("Connect") Text("Connect")
} }
} }
.disabled(self.connectingBridgeID != nil) .disabled(self.connectingGatewayID != nil)
} }
} }
} }
} }
} }
private enum BridgeListMode: Equatable { private enum GatewayListMode: Equatable {
case all case all
case availableOnly case availableOnly
} }
private func keychainAccount() -> String {
"bridge-token.\(self.instanceId)"
}
private func platformString() -> String { private func platformString() -> String {
let v = ProcessInfo.processInfo.operatingSystemVersion let v = ProcessInfo.processInfo.operatingSystemVersion
return "iOS \(v.majorVersion).\(v.minorVersion).\(v.patchVersion)" return "iOS \(v.majorVersion).\(v.minorVersion).\(v.patchVersion)"
@ -341,228 +365,37 @@ struct SettingsTab: View {
return trimmed.isEmpty ? "unknown" : trimmed return trimmed.isEmpty ? "unknown" : trimmed
} }
private func currentCaps() -> [String] { private func connect(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) async {
var caps = [ClawdbotCapability.canvas.rawValue, ClawdbotCapability.screen.rawValue] 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 = await self.gatewayController.connect(gateway)
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)"
}
} }
private func connectManual() async { private func connectManual() async {
let host = self.manualBridgeHost.trimmingCharacters(in: .whitespacesAndNewlines) let host = self.manualGatewayHost.trimmingCharacters(in: .whitespacesAndNewlines)
guard !host.isEmpty else { guard !host.isEmpty else {
self.connectStatus.text = "Failed: host required" self.connectStatus.text = "Failed: host required"
return return
} }
guard self.manualBridgePort > 0, self.manualBridgePort <= 65535 else { guard self.manualGatewayPort > 0, self.manualGatewayPort <= 65535 else {
self.connectStatus.text = "Failed: invalid port"
return
}
guard let port = NWEndpoint.Port(rawValue: UInt16(self.manualBridgePort)) else {
self.connectStatus.text = "Failed: invalid port" self.connectStatus.text = "Failed: invalid port"
return return
} }
self.connectingBridgeID = "manual" self.connectingGatewayID = "manual"
self.manualBridgeEnabled = true self.manualGatewayEnabled = true
defer { self.connectingBridgeID = nil } defer { self.connectingGatewayID = nil }
let endpoint: NWEndpoint = .hostPort(host: NWEndpoint.Host(host), port: port) await self.gatewayController.connectManual(
let stableID = BridgeEndpointID.stableID(endpoint) host: host,
let tlsParams = self.resolveManualTLSParams(stableID: stableID) port: self.manualGatewayPort,
useTLS: self.manualGatewayTLS)
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)
} }
private static func primaryIPv4Address() -> String? { private static func primaryIPv4Address() -> String? {
@ -611,23 +444,21 @@ struct SettingsTab: View {
SettingsNetworkingHelpers.httpURLString(host: host, port: port, fallback: fallback) 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] = [] var lines: [String] = []
if let lanHost = bridge.lanHost { lines.append("LAN: \(lanHost)") } if let lanHost = gateway.lanHost { lines.append("LAN: \(lanHost)") }
if let tailnet = bridge.tailnetDns { lines.append("Tailnet: \(tailnet)") } if let tailnet = gateway.tailnetDns { lines.append("Tailnet: \(tailnet)") }
let gatewayPort = bridge.gatewayPort let gatewayPort = gateway.gatewayPort
let bridgePort = bridge.bridgePort let canvasPort = gateway.canvasPort
let canvasPort = bridge.canvasPort if gatewayPort != nil || canvasPort != nil {
if gatewayPort != nil || bridgePort != nil || canvasPort != nil {
let gw = gatewayPort.map(String.init) ?? "" let gw = gatewayPort.map(String.init) ?? ""
let br = bridgePort.map(String.init) ?? ""
let canvas = canvasPort.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 { if lines.isEmpty {
lines.append(bridge.debugID) lines.append(gateway.debugID)
} }
return lines return lines

View File

@ -42,7 +42,7 @@ struct VoiceWakeWordsSettingsView: View {
} }
} }
.onChange(of: self.triggerWords) { _, newValue in .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) VoiceWakePreferences.saveTriggerWords(newValue)
let snapshot = VoiceWakePreferences.sanitizeTriggerWords(newValue) let snapshot = VoiceWakePreferences.sanitizeTriggerWords(newValue)

View File

@ -3,7 +3,7 @@ import SwiftUI
struct StatusPill: View { struct StatusPill: View {
@Environment(\.scenePhase) private var scenePhase @Environment(\.scenePhase) private var scenePhase
enum BridgeState: Equatable { enum GatewayState: Equatable {
case connected case connected
case connecting case connecting
case error case error
@ -34,7 +34,7 @@ struct StatusPill: View {
var tint: Color? var tint: Color?
} }
var bridge: BridgeState var gateway: GatewayState
var voiceWakeEnabled: Bool var voiceWakeEnabled: Bool
var activity: Activity? var activity: Activity?
var brighten: Bool = false var brighten: Bool = false
@ -47,12 +47,12 @@ struct StatusPill: View {
HStack(spacing: 10) { HStack(spacing: 10) {
HStack(spacing: 8) { HStack(spacing: 8) {
Circle() Circle()
.fill(self.bridge.color) .fill(self.gateway.color)
.frame(width: 9, height: 9) .frame(width: 9, height: 9)
.scaleEffect(self.bridge == .connecting ? (self.pulse ? 1.15 : 0.85) : 1.0) .scaleEffect(self.gateway == .connecting ? (self.pulse ? 1.15 : 0.85) : 1.0)
.opacity(self.bridge == .connecting ? (self.pulse ? 1.0 : 0.6) : 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)) .font(.system(size: 13, weight: .semibold))
.foregroundStyle(.primary) .foregroundStyle(.primary)
} }
@ -95,26 +95,26 @@ struct StatusPill: View {
.buttonStyle(.plain) .buttonStyle(.plain)
.accessibilityLabel("Status") .accessibilityLabel("Status")
.accessibilityValue(self.accessibilityValue) .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 } .onDisappear { self.pulse = false }
.onChange(of: self.bridge) { _, newValue in .onChange(of: self.gateway) { _, newValue in
self.updatePulse(for: newValue, scenePhase: self.scenePhase) self.updatePulse(for: newValue, scenePhase: self.scenePhase)
} }
.onChange(of: self.scenePhase) { _, newValue in .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) .animation(.easeInOut(duration: 0.18), value: self.activity?.title)
} }
private var accessibilityValue: String { private var accessibilityValue: String {
if let activity { 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) { private func updatePulse(for gateway: GatewayState, scenePhase: ScenePhase) {
guard bridge == .connecting, scenePhase == .active else { guard gateway == .connecting, scenePhase == .active else {
withAnimation(.easeOut(duration: 0.2)) { self.pulse = false } withAnimation(.easeOut(duration: 0.2)) { self.pulse = false }
return return
} }

View File

@ -1,5 +1,6 @@
import AVFAudio import AVFAudio
import ClawdbotKit import ClawdbotKit
import ClawdbotProtocol
import Foundation import Foundation
import Observation import Observation
import OSLog import OSLog
@ -42,15 +43,15 @@ final class TalkModeManager: NSObject {
var pcmPlayer: PCMStreamingAudioPlaying = PCMStreamingAudioPlayer.shared var pcmPlayer: PCMStreamingAudioPlaying = PCMStreamingAudioPlayer.shared
var mp3Player: StreamingAudioPlaying = StreamingAudioPlayer.shared var mp3Player: StreamingAudioPlaying = StreamingAudioPlayer.shared
private var bridge: BridgeSession? private var gateway: GatewayNodeSession?
private let silenceWindow: TimeInterval = 0.7 private let silenceWindow: TimeInterval = 0.7
private var chatSubscribedSessionKeys = Set<String>() private var chatSubscribedSessionKeys = Set<String>()
private let logger = Logger(subsystem: "com.clawdbot", category: "TalkMode") private let logger = Logger(subsystem: "com.clawdbot", category: "TalkMode")
func attachBridge(_ bridge: BridgeSession) { func attachGateway(_ gateway: GatewayNodeSession) {
self.bridge = bridge self.gateway = gateway
} }
func updateMainSessionKey(_ sessionKey: String?) { func updateMainSessionKey(_ sessionKey: String?) {
@ -232,9 +233,9 @@ final class TalkModeManager: NSObject {
await self.reloadConfig() await self.reloadConfig()
let prompt = self.buildPrompt(transcript: transcript) let prompt = self.buildPrompt(transcript: transcript)
guard let bridge else { guard let gateway else {
self.statusText = "Bridge not connected" self.statusText = "Gateway not connected"
self.logger.warning("finalize: bridge not connected") self.logger.warning("finalize: gateway not connected")
await self.start() await self.start()
return return
} }
@ -245,9 +246,9 @@ final class TalkModeManager: NSObject {
await self.subscribeChatIfNeeded(sessionKey: sessionKey) await self.subscribeChatIfNeeded(sessionKey: sessionKey)
self.logger.info( self.logger.info(
"chat.send start sessionKey=\(sessionKey, privacy: .public) chars=\(prompt.count, privacy: .public)") "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)") 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 { if completion == .timeout {
self.logger.warning( self.logger.warning(
"chat completion timeout runId=\(runId, privacy: .public); attempting history fallback") "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( guard let assistantText = try await self.waitForAssistantText(
bridge: bridge, gateway: gateway,
since: startedAt, since: startedAt,
timeoutSeconds: completion == .final ? 12 : 25) timeoutSeconds: completion == .final ? 12 : 25)
else { else {
@ -286,31 +287,22 @@ final class TalkModeManager: NSObject {
private func subscribeChatIfNeeded(sessionKey: String) async { private func subscribeChatIfNeeded(sessionKey: String) async {
let key = sessionKey.trimmingCharacters(in: .whitespacesAndNewlines) let key = sessionKey.trimmingCharacters(in: .whitespacesAndNewlines)
guard !key.isEmpty else { return } guard !key.isEmpty else { return }
guard let bridge else { return } guard let gateway else { return }
guard !self.chatSubscribedSessionKeys.contains(key) else { return } guard !self.chatSubscribedSessionKeys.contains(key) else { return }
do { let payload = "{\"sessionKey\":\"\(key)\"}"
let payload = "{\"sessionKey\":\"\(key)\"}" await gateway.sendEvent(event: "chat.subscribe", payloadJSON: payload)
try await bridge.sendEvent(event: "chat.subscribe", payloadJSON: payload) self.chatSubscribedSessionKeys.insert(key)
self.chatSubscribedSessionKeys.insert(key) self.logger.info("chat.subscribe ok sessionKey=\(key, privacy: .public)")
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)")
}
} }
private func unsubscribeAllChats() async { private func unsubscribeAllChats() async {
guard let bridge else { return } guard let gateway else { return }
let keys = self.chatSubscribedSessionKeys let keys = self.chatSubscribedSessionKeys
self.chatSubscribedSessionKeys.removeAll() self.chatSubscribedSessionKeys.removeAll()
for key in keys { for key in keys {
do { let payload = "{\"sessionKey\":\"\(key)\"}"
let payload = "{\"sessionKey\":\"\(key)\"}" await gateway.sendEvent(event: "chat.unsubscribe", payloadJSON: payload)
try await bridge.sendEvent(event: "chat.unsubscribe", payloadJSON: payload)
} catch {
// ignore
}
} }
} }
@ -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 } struct SendResponse: Decodable { let runId: String }
let payload: [String: Any] = [ let payload: [String: Any] = [
"sessionKey": self.mainSessionKey, "sessionKey": self.mainSessionKey,
@ -352,26 +344,27 @@ final class TalkModeManager: NSObject {
code: 1, code: 1,
userInfo: [NSLocalizedDescriptionKey: "Failed to encode chat payload"]) 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) let decoded = try JSONDecoder().decode(SendResponse.self, from: res)
return decoded.runId return decoded.runId
} }
private func waitForChatCompletion( private func waitForChatCompletion(
runId: String, runId: String,
bridge: BridgeSession, gateway: GatewayNodeSession,
timeoutSeconds: Int = 120) async -> ChatCompletionState 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 return await withTaskGroup(of: ChatCompletionState.self) { group in
group.addTask { [runId] in group.addTask { [runId] in
for await evt in stream { for await evt in stream {
if Task.isCancelled { return .timeout } if Task.isCancelled { return .timeout }
guard evt.event == "chat", let payload = evt.payloadJSON else { continue } guard evt.event == "chat", let payload = evt.payload else { continue }
guard let data = payload.data(using: .utf8) else { continue } guard let chatEvent = try? GatewayPayloadDecoding.decode(payload, as: ChatEvent.self) else {
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { continue } continue
if (json["runId"] as? String) != runId { continue } }
if let state = json["state"] as? String { guard chatEvent.runid == runId else { continue }
if let state = chatEvent.state.value as? String {
switch state { switch state {
case "final": return .final case "final": return .final
case "aborted": return .aborted case "aborted": return .aborted
@ -393,13 +386,13 @@ final class TalkModeManager: NSObject {
} }
private func waitForAssistantText( private func waitForAssistantText(
bridge: BridgeSession, gateway: GatewayNodeSession,
since: Double, since: Double,
timeoutSeconds: Int) async throws -> String? timeoutSeconds: Int) async throws -> String?
{ {
let deadline = Date().addingTimeInterval(TimeInterval(timeoutSeconds)) let deadline = Date().addingTimeInterval(TimeInterval(timeoutSeconds))
while Date() < deadline { 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 return text
} }
try? await Task.sleep(nanoseconds: 300_000_000) try? await Task.sleep(nanoseconds: 300_000_000)
@ -407,8 +400,8 @@ final class TalkModeManager: NSObject {
return nil return nil
} }
private func fetchLatestAssistantText(bridge: BridgeSession, since: Double? = nil) async throws -> String? { private func fetchLatestAssistantText(gateway: GatewayNodeSession, since: Double? = nil) async throws -> String? {
let res = try await bridge.request( let res = try await gateway.request(
method: "chat.history", method: "chat.history",
paramsJSON: "{\"sessionKey\":\"\(self.mainSessionKey)\"}", paramsJSON: "{\"sessionKey\":\"\(self.mainSessionKey)\"}",
timeoutSeconds: 15) timeoutSeconds: 15)
@ -649,9 +642,9 @@ final class TalkModeManager: NSObject {
} }
private func reloadConfig() async { private func reloadConfig() async {
guard let bridge else { return } guard let gateway else { return }
do { 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 json = try JSONSerialization.jsonObject(with: res) as? [String: Any] else { return }
guard let config = json["config"] as? [String: Any] else { return } guard let config = json["config"] as? [String: Any] else { return }
let talk = config["talk"] as? [String: Any] let talk = config["talk"] as? [String: Any]

View File

@ -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<Void, Error>) 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"))
}
}
}

View File

@ -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<T>(_ 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<T>(
_ 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<T>(_ 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<T>(
_ 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)
}
}
}
}

View File

@ -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)
}
}

View File

@ -0,0 +1,79 @@
import ClawdbotKit
import Foundation
import Testing
import UIKit
@testable import Clawdbot
private func withUserDefaults<T>(_ 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))
}
}
}

View File

@ -1,9 +1,9 @@
import Testing import Testing
@testable import Clawdbot @testable import Clawdbot
@Suite(.serialized) struct BridgeDiscoveryModelTests { @Suite(.serialized) struct GatewayDiscoveryModelTests {
@Test @MainActor func debugLoggingCapturesLifecycleAndResets() { @Test @MainActor func debugLoggingCapturesLifecycleAndResets() {
let model = BridgeDiscoveryModel() let model = GatewayDiscoveryModel()
#expect(model.debugLog.isEmpty) #expect(model.debugLog.isEmpty)
#expect(model.statusText == "Idle") #expect(model.statusText == "Idle")
@ -13,7 +13,7 @@ import Testing
model.stop() model.stop()
#expect(model.statusText == "Stopped") #expect(model.statusText == "Stopped")
#expect(model.bridges.isEmpty) #expect(model.gateways.isEmpty)
#expect(model.debugLog.count >= 3) #expect(model.debugLog.count >= 3)
model.setDebugLoggingEnabled(false) model.setDebugLoggingEnabled(false)

View File

@ -3,30 +3,30 @@ import Network
import Testing import Testing
@testable import Clawdbot @testable import Clawdbot
@Suite struct BridgeEndpointIDTests { @Suite struct GatewayEndpointIDTests {
@Test func stableIDForServiceDecodesAndNormalizesName() { @Test func stableIDForServiceDecodesAndNormalizesName() {
let endpoint = NWEndpoint.service( let endpoint = NWEndpoint.service(
name: "Clawdbot\\032Bridge \\032 Node\n", name: "Clawdbot\\032Gateway \\032 Node\n",
type: "_clawdbot-bridge._tcp", type: "_clawdbot-gateway._tcp",
domain: "local.", domain: "local.",
interface: nil) 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() { @Test func stableIDForNonServiceUsesEndpointDescription() {
let endpoint = NWEndpoint.hostPort(host: NWEndpoint.Host("127.0.0.1"), port: 4242) 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() { @Test func prettyDescriptionDecodesBonjourEscapes() {
let endpoint = NWEndpoint.service( let endpoint = NWEndpoint.service(
name: "Clawdbot\\032Bridge", name: "Clawdbot\\032Gateway",
type: "_clawdbot-bridge._tcp", type: "_clawdbot-gateway._tcp",
domain: "local.", domain: "local.",
interface: nil) interface: nil)
let pretty = BridgeEndpointID.prettyDescription(endpoint) let pretty = GatewayEndpointID.prettyDescription(endpoint)
#expect(pretty == BonjourEscapes.decode(String(describing: endpoint))) #expect(pretty == BonjourEscapes.decode(String(describing: endpoint)))
#expect(!pretty.localizedCaseInsensitiveContains("\\032")) #expect(!pretty.localizedCaseInsensitiveContains("\\032"))
} }

View File

@ -7,11 +7,11 @@ private struct KeychainEntry: Hashable {
let account: String let account: String
} }
private let bridgeService = "com.clawdbot.bridge" private let gatewayService = "com.clawdbot.gateway"
private let nodeService = "com.clawdbot.node" private let nodeService = "com.clawdbot.node"
private let instanceIdEntry = KeychainEntry(service: nodeService, account: "instanceId") private let instanceIdEntry = KeychainEntry(service: nodeService, account: "instanceId")
private let preferredBridgeEntry = KeychainEntry(service: bridgeService, account: "preferredStableID") private let preferredGatewayEntry = KeychainEntry(service: gatewayService, account: "preferredStableID")
private let lastBridgeEntry = KeychainEntry(service: bridgeService, account: "lastDiscoveredStableID") private let lastGatewayEntry = KeychainEntry(service: gatewayService, account: "lastDiscoveredStableID")
private func snapshotDefaults(_ keys: [String]) -> [String: Any?] { private func snapshotDefaults(_ keys: [String]) -> [String: Any?] {
let defaults = UserDefaults.standard let defaults = UserDefaults.standard
@ -59,14 +59,14 @@ private func restoreKeychain(_ snapshot: [KeychainEntry: String?]) {
applyKeychain(snapshot) applyKeychain(snapshot)
} }
@Suite(.serialized) struct BridgeSettingsStoreTests { @Suite(.serialized) struct GatewaySettingsStoreTests {
@Test func bootstrapCopiesDefaultsToKeychainWhenMissing() { @Test func bootstrapCopiesDefaultsToKeychainWhenMissing() {
let defaultsKeys = [ let defaultsKeys = [
"node.instanceId", "node.instanceId",
"bridge.preferredStableID", "gateway.preferredStableID",
"bridge.lastDiscoveredStableID", "gateway.lastDiscoveredStableID",
] ]
let entries = [instanceIdEntry, preferredBridgeEntry, lastBridgeEntry] let entries = [instanceIdEntry, preferredGatewayEntry, lastGatewayEntry]
let defaultsSnapshot = snapshotDefaults(defaultsKeys) let defaultsSnapshot = snapshotDefaults(defaultsKeys)
let keychainSnapshot = snapshotKeychain(entries) let keychainSnapshot = snapshotKeychain(entries)
defer { defer {
@ -76,29 +76,29 @@ private func restoreKeychain(_ snapshot: [KeychainEntry: String?]) {
applyDefaults([ applyDefaults([
"node.instanceId": "node-test", "node.instanceId": "node-test",
"bridge.preferredStableID": "preferred-test", "gateway.preferredStableID": "preferred-test",
"bridge.lastDiscoveredStableID": "last-test", "gateway.lastDiscoveredStableID": "last-test",
]) ])
applyKeychain([ applyKeychain([
instanceIdEntry: nil, instanceIdEntry: nil,
preferredBridgeEntry: nil, preferredGatewayEntry: nil,
lastBridgeEntry: nil, lastGatewayEntry: nil,
]) ])
BridgeSettingsStore.bootstrapPersistence() GatewaySettingsStore.bootstrapPersistence()
#expect(KeychainStore.loadString(service: nodeService, account: "instanceId") == "node-test") #expect(KeychainStore.loadString(service: nodeService, account: "instanceId") == "node-test")
#expect(KeychainStore.loadString(service: bridgeService, account: "preferredStableID") == "preferred-test") #expect(KeychainStore.loadString(service: gatewayService, account: "preferredStableID") == "preferred-test")
#expect(KeychainStore.loadString(service: bridgeService, account: "lastDiscoveredStableID") == "last-test") #expect(KeychainStore.loadString(service: gatewayService, account: "lastDiscoveredStableID") == "last-test")
} }
@Test func bootstrapCopiesKeychainToDefaultsWhenMissing() { @Test func bootstrapCopiesKeychainToDefaultsWhenMissing() {
let defaultsKeys = [ let defaultsKeys = [
"node.instanceId", "node.instanceId",
"bridge.preferredStableID", "gateway.preferredStableID",
"bridge.lastDiscoveredStableID", "gateway.lastDiscoveredStableID",
] ]
let entries = [instanceIdEntry, preferredBridgeEntry, lastBridgeEntry] let entries = [instanceIdEntry, preferredGatewayEntry, lastGatewayEntry]
let defaultsSnapshot = snapshotDefaults(defaultsKeys) let defaultsSnapshot = snapshotDefaults(defaultsKeys)
let keychainSnapshot = snapshotKeychain(entries) let keychainSnapshot = snapshotKeychain(entries)
defer { defer {
@ -108,20 +108,20 @@ private func restoreKeychain(_ snapshot: [KeychainEntry: String?]) {
applyDefaults([ applyDefaults([
"node.instanceId": nil, "node.instanceId": nil,
"bridge.preferredStableID": nil, "gateway.preferredStableID": nil,
"bridge.lastDiscoveredStableID": nil, "gateway.lastDiscoveredStableID": nil,
]) ])
applyKeychain([ applyKeychain([
instanceIdEntry: "node-from-keychain", instanceIdEntry: "node-from-keychain",
preferredBridgeEntry: "preferred-from-keychain", preferredGatewayEntry: "preferred-from-keychain",
lastBridgeEntry: "last-from-keychain", lastGatewayEntry: "last-from-keychain",
]) ])
BridgeSettingsStore.bootstrapPersistence() GatewaySettingsStore.bootstrapPersistence()
let defaults = UserDefaults.standard let defaults = UserDefaults.standard
#expect(defaults.string(forKey: "node.instanceId") == "node-from-keychain") #expect(defaults.string(forKey: "node.instanceId") == "node-from-keychain")
#expect(defaults.string(forKey: "bridge.preferredStableID") == "preferred-from-keychain") #expect(defaults.string(forKey: "gateway.preferredStableID") == "preferred-from-keychain")
#expect(defaults.string(forKey: "bridge.lastDiscoveredStableID") == "last-from-keychain") #expect(defaults.string(forKey: "gateway.lastDiscoveredStableID") == "last-from-keychain")
} }
} }

View File

@ -1,19 +1,15 @@
import ClawdbotKit
import Testing import Testing
@testable import Clawdbot @testable import Clawdbot
@Suite struct IOSBridgeChatTransportTests { @Suite struct IOSGatewayChatTransportTests {
@Test func requestsFailFastWhenBridgeNotConnected() async { @Test func requestsFailFastWhenGatewayNotConnected() async {
let bridge = BridgeSession() let gateway = GatewayNodeSession()
let transport = IOSBridgeChatTransport(bridge: bridge) let transport = IOSGatewayChatTransport(gateway: gateway)
do {
try await transport.setActiveSessionKey("node-test")
Issue.record("Expected setActiveSessionKey to throw when bridge not connected")
} catch {}
do { do {
_ = try await transport.requestHistory(sessionKey: "node-test") _ = 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 {} } catch {}
do { do {
@ -23,11 +19,12 @@ import Testing
thinking: "low", thinking: "low",
idempotencyKey: "idempotency", idempotencyKey: "idempotency",
attachments: []) attachments: [])
Issue.record("Expected sendMessage to throw when bridge not connected") Issue.record("Expected sendMessage to throw when gateway not connected")
} catch {} } catch {}
do { do {
_ = try await transport.requestHealth(timeoutMs: 250) _ = try await transport.requestHealth(timeoutMs: 250)
Issue.record("Expected requestHealth to throw when gateway not connected")
} catch {} } catch {}
} }
} }

View File

@ -159,7 +159,7 @@ private func withUserDefaults<T>(_ updates: [String: Any?], _ body: () throws ->
let appModel = NodeAppModel() let appModel = NodeAppModel()
let url = URL(string: "clawdbot://agent?message=hello")! let url = URL(string: "clawdbot://agent?message=hello")!
await appModel.handleDeepLink(url: url) 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 { @Test @MainActor func handleDeepLinkRejectsOversizedMessage() async {
@ -170,7 +170,7 @@ private func withUserDefaults<T>(_ updates: [String: Any?], _ body: () throws ->
#expect(appModel.screen.errorText?.contains("Deep link too large") == true) #expect(appModel.screen.errorText?.contains("Deep link too large") == true)
} }
@Test @MainActor func sendVoiceTranscriptThrowsWhenBridgeOffline() async { @Test @MainActor func sendVoiceTranscriptThrowsWhenGatewayOffline() async {
let appModel = NodeAppModel() let appModel = NodeAppModel()
await #expect(throws: Error.self) { await #expect(throws: Error.self) {
try await appModel.sendVoiceTranscript(text: "hello", sessionKey: "main") try await appModel.sendVoiceTranscript(text: "hello", sessionKey: "main")

View File

@ -1,3 +1,4 @@
import ClawdbotKit
import SwiftUI import SwiftUI
import Testing import Testing
import UIKit import UIKit
@ -14,35 +15,35 @@ import UIKit
} }
@Test @MainActor func statusPillConnectingBuildsAViewHierarchy() { @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) _ = Self.host(root)
} }
@Test @MainActor func statusPillDisconnectedBuildsAViewHierarchy() { @Test @MainActor func statusPillDisconnectedBuildsAViewHierarchy() {
let root = StatusPill(bridge: .disconnected, voiceWakeEnabled: false) {} let root = StatusPill(gateway: .disconnected, voiceWakeEnabled: false) {}
_ = Self.host(root) _ = Self.host(root)
} }
@Test @MainActor func settingsTabBuildsAViewHierarchy() { @Test @MainActor func settingsTabBuildsAViewHierarchy() {
let appModel = NodeAppModel() let appModel = NodeAppModel()
let bridgeController = BridgeConnectionController(appModel: appModel, startDiscovery: false) let gatewayController = GatewayConnectionController(appModel: appModel, startDiscovery: false)
let root = SettingsTab() let root = SettingsTab()
.environment(appModel) .environment(appModel)
.environment(appModel.voiceWake) .environment(appModel.voiceWake)
.environment(bridgeController) .environment(gatewayController)
_ = Self.host(root) _ = Self.host(root)
} }
@Test @MainActor func rootTabsBuildAViewHierarchy() { @Test @MainActor func rootTabsBuildAViewHierarchy() {
let appModel = NodeAppModel() let appModel = NodeAppModel()
let bridgeController = BridgeConnectionController(appModel: appModel, startDiscovery: false) let gatewayController = GatewayConnectionController(appModel: appModel, startDiscovery: false)
let root = RootTabs() let root = RootTabs()
.environment(appModel) .environment(appModel)
.environment(appModel.voiceWake) .environment(appModel.voiceWake)
.environment(bridgeController) .environment(gatewayController)
_ = Self.host(root) _ = Self.host(root)
} }
@ -66,8 +67,8 @@ import UIKit
@Test @MainActor func chatSheetBuildsAViewHierarchy() { @Test @MainActor func chatSheetBuildsAViewHierarchy() {
let appModel = NodeAppModel() let appModel = NodeAppModel()
let bridge = BridgeSession() let gateway = GatewayNodeSession()
let root = ChatSheet(bridge: bridge, sessionKey: "test") let root = ChatSheet(gateway: gateway, sessionKey: "test")
.environment(appModel) .environment(appModel)
.environment(appModel.voiceWake) .environment(appModel.voiceWake)
_ = Self.host(root) _ = Self.host(root)

View File

@ -35,6 +35,8 @@ targets:
- package: ClawdbotKit - package: ClawdbotKit
- package: ClawdbotKit - package: ClawdbotKit
product: ClawdbotChatUI product: ClawdbotChatUI
- package: ClawdbotKit
product: ClawdbotProtocol
- package: Swabble - package: Swabble
product: SwabbleKit product: SwabbleKit
- sdk: AppIntents.framework - sdk: AppIntents.framework
@ -86,12 +88,12 @@ targets:
UIApplicationSupportsMultipleScenes: false UIApplicationSupportsMultipleScenes: false
UIBackgroundModes: UIBackgroundModes:
- audio - 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: NSAppTransportSecurity:
NSAllowsArbitraryLoadsInWebContent: true NSAllowsArbitraryLoadsInWebContent: true
NSBonjourServices: NSBonjourServices:
- _clawdbot-bridge._tcp - _clawdbot-gateway._tcp
NSCameraUsageDescription: Clawdbot can capture photos or short video clips when requested via the bridge. NSCameraUsageDescription: Clawdbot can capture photos or short video clips when requested via the gateway.
NSLocationWhenInUseUsageDescription: Clawdbot uses your location when you allow location sharing. NSLocationWhenInUseUsageDescription: Clawdbot uses your location when you allow location sharing.
NSLocationAlwaysAndWhenInUseUsageDescription: Clawdbot can share your location in the background when you enable Always. NSLocationAlwaysAndWhenInUseUsageDescription: Clawdbot can share your location in the background when you enable Always.
NSMicrophoneUsageDescription: Clawdbot needs microphone access for voice wake. NSMicrophoneUsageDescription: Clawdbot needs microphone access for voice wake.

View File

@ -25,13 +25,6 @@ let package = Package(
.package(path: "../../Swabble"), .package(path: "../../Swabble"),
], ],
targets: [ targets: [
.target(
name: "ClawdbotProtocol",
dependencies: [],
path: "Sources/ClawdbotProtocol",
swiftSettings: [
.enableUpcomingFeature("StrictConcurrency"),
]),
.target( .target(
name: "ClawdbotIPC", name: "ClawdbotIPC",
dependencies: [], dependencies: [],
@ -52,9 +45,9 @@ let package = Package(
dependencies: [ dependencies: [
"ClawdbotIPC", "ClawdbotIPC",
"ClawdbotDiscovery", "ClawdbotDiscovery",
"ClawdbotProtocol",
.product(name: "ClawdbotKit", package: "ClawdbotKit"), .product(name: "ClawdbotKit", package: "ClawdbotKit"),
.product(name: "ClawdbotChatUI", package: "ClawdbotKit"), .product(name: "ClawdbotChatUI", package: "ClawdbotKit"),
.product(name: "ClawdbotProtocol", package: "ClawdbotKit"),
.product(name: "SwabbleKit", package: "swabble"), .product(name: "SwabbleKit", package: "swabble"),
.product(name: "MenuBarExtraAccess", package: "MenuBarExtraAccess"), .product(name: "MenuBarExtraAccess", package: "MenuBarExtraAccess"),
.product(name: "Subprocess", package: "swift-subprocess"), .product(name: "Subprocess", package: "swift-subprocess"),
@ -85,7 +78,7 @@ let package = Package(
.executableTarget( .executableTarget(
name: "ClawdbotWizardCLI", name: "ClawdbotWizardCLI",
dependencies: [ dependencies: [
"ClawdbotProtocol", .product(name: "ClawdbotProtocol", package: "ClawdbotKit"),
], ],
path: "Sources/ClawdbotWizardCLI", path: "Sources/ClawdbotWizardCLI",
swiftSettings: [ swiftSettings: [
@ -97,7 +90,7 @@ let package = Package(
"ClawdbotIPC", "ClawdbotIPC",
"Clawdbot", "Clawdbot",
"ClawdbotDiscovery", "ClawdbotDiscovery",
"ClawdbotProtocol", .product(name: "ClawdbotProtocol", package: "ClawdbotKit"),
.product(name: "SwabbleKit", package: "swabble"), .product(name: "SwabbleKit", package: "swabble"),
], ],
swiftSettings: [ swiftSettings: [

View File

@ -1,5 +1,6 @@
import AppKit import AppKit
import ClawdbotIPC import ClawdbotIPC
import ClawdbotKit
import Foundation import Foundation
import OSLog import OSLog

View File

@ -1,3 +1,4 @@
import ClawdbotKit
import ClawdbotProtocol import ClawdbotProtocol
import Foundation import Foundation
import Observation import Observation

View File

@ -1,3 +1,4 @@
import ClawdbotKit
import ClawdbotProtocol import ClawdbotProtocol
import Foundation import Foundation
import Observation import Observation

View File

@ -1,4 +1,5 @@
import AppKit import AppKit
import ClawdbotKit
import ClawdbotProtocol import ClawdbotProtocol
import Foundation import Foundation
import Observation import Observation

View File

@ -1,3 +1,4 @@
import ClawdbotKit
import ClawdbotProtocol import ClawdbotProtocol
import Foundation import Foundation
import OSLog import OSLog

View File

@ -1,4 +1,5 @@
import ClawdbotChatUI import ClawdbotChatUI
import ClawdbotKit
import ClawdbotProtocol import ClawdbotProtocol
import Foundation import Foundation
import OSLog import OSLog

View File

@ -1,16 +0,0 @@
import ClawdbotProtocol
import Foundation
enum GatewayPayloadDecoding {
static func decode<T: Decodable>(_ 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<T: Decodable>(_ payload: ClawdbotProtocol.AnyCodable?, as _: T.Type = T.self) throws
-> T?
{
guard let payload else { return nil }
return try self.decode(payload, as: T.self)
}
}

View File

@ -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
}()
}

View File

@ -1,3 +1,4 @@
import ClawdbotKit
import ClawdbotProtocol import ClawdbotProtocol
import Cocoa import Cocoa
import Foundation import Foundation

View File

@ -9,7 +9,7 @@ final class MacNodeModeCoordinator {
private let logger = Logger(subsystem: "com.clawdbot", category: "mac-node") private let logger = Logger(subsystem: "com.clawdbot", category: "mac-node")
private var task: Task<Void, Never>? private var task: Task<Void, Never>?
private let runtime = MacNodeRuntime() private let runtime = MacNodeRuntime()
private let session = MacNodeGatewaySession() private let session = GatewayNodeSession()
func start() { func start() {
guard self.task == nil else { return } guard self.task == nil else { return }

View File

@ -1,6 +1,7 @@
import AppKit import AppKit
import ClawdbotDiscovery import ClawdbotDiscovery
import ClawdbotIPC import ClawdbotIPC
import ClawdbotKit
import ClawdbotProtocol import ClawdbotProtocol
import Foundation import Foundation
import Observation import Observation

View File

@ -1,3 +1,4 @@
import ClawdbotKit
import ClawdbotProtocol import ClawdbotProtocol
import Foundation import Foundation
import Observation import Observation

View File

@ -1,3 +1,4 @@
import ClawdbotKit
import Foundation import Foundation
import OSLog import OSLog

View File

@ -1,5 +1,6 @@
import AppKit import AppKit
import ClawdbotChatUI import ClawdbotChatUI
import ClawdbotKit
import ClawdbotProtocol import ClawdbotProtocol
import Foundation import Foundation
import OSLog import OSLog

View File

@ -9,6 +9,7 @@ let package = Package(
.macOS(.v15), .macOS(.v15),
], ],
products: [ products: [
.library(name: "ClawdbotProtocol", targets: ["ClawdbotProtocol"]),
.library(name: "ClawdbotKit", targets: ["ClawdbotKit"]), .library(name: "ClawdbotKit", targets: ["ClawdbotKit"]),
.library(name: "ClawdbotChatUI", targets: ["ClawdbotChatUI"]), .library(name: "ClawdbotChatUI", targets: ["ClawdbotChatUI"]),
], ],
@ -17,9 +18,15 @@ let package = Package(
.package(url: "https://github.com/gonzalezreal/textual", exact: "0.2.0"), .package(url: "https://github.com/gonzalezreal/textual", exact: "0.2.0"),
], ],
targets: [ targets: [
.target(
name: "ClawdbotProtocol",
swiftSettings: [
.enableUpcomingFeature("StrictConcurrency"),
]),
.target( .target(
name: "ClawdbotKit", name: "ClawdbotKit",
dependencies: [ dependencies: [
"ClawdbotProtocol",
.product(name: "ElevenLabsKit", package: "ElevenLabsKit"), .product(name: "ElevenLabsKit", package: "ElevenLabsKit"),
], ],
resources: [ resources: [

View File

@ -8,6 +8,25 @@ struct DeviceIdentity: Codable, Sendable {
var createdAtMs: Int 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 { enum DeviceIdentityStore {
private static let fileName = "device.json" private static let fileName = "device.json"
@ -76,7 +95,7 @@ enum DeviceIdentityStore {
} }
private static func fileURL() -> URL { private static func fileURL() -> URL {
let base = ClawdbotPaths.stateDirURL let base = DeviceIdentityPaths.stateDirURL()
return base return base
.appendingPathComponent("identity", isDirectory: true) .appendingPathComponent("identity", isDirectory: true)
.appendingPathComponent(fileName, isDirectory: false) .appendingPathComponent(fileName, isDirectory: false)

View File

@ -1,9 +1,8 @@
import ClawdbotKit
import ClawdbotProtocol import ClawdbotProtocol
import Foundation import Foundation
import OSLog import OSLog
protocol WebSocketTasking: AnyObject { public protocol WebSocketTasking: AnyObject {
var state: URLSessionTask.State { get } var state: URLSessionTask.State { get }
func resume() func resume()
func cancel(with closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) func cancel(with closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?)
@ -14,31 +13,33 @@ protocol WebSocketTasking: AnyObject {
extension URLSessionWebSocketTask: WebSocketTasking {} extension URLSessionWebSocketTask: WebSocketTasking {}
struct WebSocketTaskBox: @unchecked Sendable { public struct WebSocketTaskBox: @unchecked Sendable {
let task: any WebSocketTasking 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) 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) try await self.task.send(message)
} }
func receive() async throws -> URLSessionWebSocketTask.Message { public func receive() async throws -> URLSessionWebSocketTask.Message {
try await self.task.receive() try await self.task.receive()
} }
func receive(completionHandler: @escaping @Sendable (Result<URLSessionWebSocketTask.Message, Error>) -> Void) { public func receive(
completionHandler: @escaping @Sendable (Result<URLSessionWebSocketTask.Message, Error>) -> Void)
{
self.task.receive(completionHandler: completionHandler) self.task.receive(completionHandler: completionHandler)
} }
} }
protocol WebSocketSessioning: AnyObject { public protocol WebSocketSessioning: AnyObject {
func makeWebSocketTask(url: URL) -> WebSocketTaskBox func makeWebSocketTask(url: URL) -> WebSocketTaskBox
} }
@ -51,25 +52,45 @@ extension URLSession: WebSocketSessioning {
} }
} }
struct WebSocketSessionBox: @unchecked Sendable { public struct WebSocketSessionBox: @unchecked Sendable {
let session: any WebSocketSessioning public let session: any WebSocketSessioning
} }
struct GatewayConnectOptions: Sendable { public struct GatewayConnectOptions: Sendable {
var role: String public var role: String
var scopes: [String] public var scopes: [String]
var caps: [String] public var caps: [String]
var commands: [String] public var commands: [String]
var permissions: [String: Bool] public var permissions: [String: Bool]
var clientId: String public var clientId: String
var clientMode: String public var clientMode: String
var clientDisplayName: 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. // Avoid ambiguity with the app's own AnyCodable type.
private typealias ProtoAnyCodable = ClawdbotProtocol.AnyCodable private typealias ProtoAnyCodable = ClawdbotProtocol.AnyCodable
actor GatewayChannelActor { public actor GatewayChannelActor {
private let logger = Logger(subsystem: "com.clawdbot", category: "gateway") private let logger = Logger(subsystem: "com.clawdbot", category: "gateway")
private var task: WebSocketTaskBox? private var task: WebSocketTaskBox?
private var pending: [String: CheckedContinuation<GatewayFrame, Error>] = [:] private var pending: [String: CheckedContinuation<GatewayFrame, Error>] = [:]
@ -95,7 +116,7 @@ actor GatewayChannelActor {
private let connectOptions: GatewayConnectOptions? private let connectOptions: GatewayConnectOptions?
private let disconnectHandler: (@Sendable (String) async -> Void)? private let disconnectHandler: (@Sendable (String) async -> Void)?
init( public init(
url: URL, url: URL,
token: String?, token: String?,
password: String? = nil, password: String? = nil,
@ -116,7 +137,7 @@ actor GatewayChannelActor {
} }
} }
func shutdown() async { public func shutdown() async {
self.shouldReconnect = false self.shouldReconnect = false
self.connected = 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.connected, self.task?.state == .running { return }
if self.isConnecting { if self.isConnecting {
try await withCheckedThrowingContinuation { cont in try await withCheckedThrowingContinuation { cont in
@ -217,8 +238,7 @@ actor GatewayChannelActor {
} }
private func sendConnect() async throws { private func sendConnect() async throws {
let osVersion = ProcessInfo.processInfo.operatingSystemVersion let platform = InstanceIdentity.platformString
let platform = "macos \(osVersion.majorVersion).\(osVersion.minorVersion).\(osVersion.patchVersion)"
let primaryLocale = Locale.preferredLanguages.first ?? Locale.current.identifier let primaryLocale = Locale.preferredLanguages.first ?? Locale.current.identifier
let options = self.connectOptions ?? GatewayConnectOptions( let options = self.connectOptions ?? GatewayConnectOptions(
role: "operator", role: "operator",
@ -243,7 +263,7 @@ actor GatewayChannelActor {
"mode": ProtoAnyCodable(clientMode), "mode": ProtoAnyCodable(clientMode),
"instanceId": ProtoAnyCodable(InstanceIdentity.instanceId), "instanceId": ProtoAnyCodable(InstanceIdentity.instanceId),
] ]
client["deviceFamily"] = ProtoAnyCodable("Mac") client["deviceFamily"] = ProtoAnyCodable(InstanceIdentity.deviceFamily)
if let model = InstanceIdentity.modelIdentifier { if let model = InstanceIdentity.modelIdentifier {
client["modelIdentifier"] = ProtoAnyCodable(model) client["modelIdentifier"] = ProtoAnyCodable(model)
} }
@ -450,7 +470,7 @@ actor GatewayChannelActor {
} }
} }
func request( public func request(
method: String, method: String,
params: [String: ClawdbotProtocol.AnyCodable]?, params: [String: ClawdbotProtocol.AnyCodable]?,
timeoutMs: Double? = nil) async throws -> Data timeoutMs: Double? = nil) async throws -> Data

View File

@ -1,4 +1,3 @@
import ClawdbotKit
import Foundation import Foundation
import Network import Network

View File

@ -2,13 +2,13 @@ import ClawdbotProtocol
import Foundation import Foundation
/// Structured error surfaced when the gateway responds with `{ ok: false }`. /// Structured error surfaced when the gateway responds with `{ ok: false }`.
struct GatewayResponseError: LocalizedError, @unchecked Sendable { public struct GatewayResponseError: LocalizedError, @unchecked Sendable {
let method: String public let method: String
let code: String public let code: String
let message: String public let message: String
let details: [String: AnyCodable] 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.method = method
self.code = (code?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false) self.code = (code?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false)
? code!.trimmingCharacters(in: .whitespacesAndNewlines) ? code!.trimmingCharacters(in: .whitespacesAndNewlines)
@ -19,15 +19,15 @@ struct GatewayResponseError: LocalizedError, @unchecked Sendable {
self.details = details ?? [:] self.details = details ?? [:]
} }
var errorDescription: String? { public var errorDescription: String? {
if self.code == "GATEWAY_ERROR" { return "\(self.method): \(self.message)" } if self.code == "GATEWAY_ERROR" { return "\(self.method): \(self.message)" }
return "\(self.method): [\(self.code)] \(self.message)" return "\(self.method): [\(self.code)] \(self.message)"
} }
} }
struct GatewayDecodingError: LocalizedError, Sendable { public struct GatewayDecodingError: LocalizedError, Sendable {
let method: String public let method: String
let message: String public let message: String
var errorDescription: String? { "\(self.method): \(self.message)" } public var errorDescription: String? { "\(self.method): \(self.message)" }
} }

View File

@ -1,4 +1,3 @@
import ClawdbotKit
import ClawdbotProtocol import ClawdbotProtocol
import Foundation import Foundation
import OSLog import OSLog
@ -12,7 +11,7 @@ private struct NodeInvokeRequestPayload: Codable, Sendable {
var idempotencyKey: String? var idempotencyKey: String?
} }
actor MacNodeGatewaySession { public actor GatewayNodeSession {
private let logger = Logger(subsystem: "com.clawdbot", category: "node.gateway") private let logger = Logger(subsystem: "com.clawdbot", category: "node.gateway")
private let decoder = JSONDecoder() private let decoder = JSONDecoder()
private let encoder = JSONEncoder() private let encoder = JSONEncoder()
@ -24,8 +23,10 @@ actor MacNodeGatewaySession {
private var onConnected: (@Sendable () async -> Void)? private var onConnected: (@Sendable () async -> Void)?
private var onDisconnected: (@Sendable (String) async -> Void)? private var onDisconnected: (@Sendable (String) async -> Void)?
private var onInvoke: (@Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse)? private var onInvoke: (@Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse)?
private var serverEventSubscribers: [UUID: AsyncStream<EventFrame>.Continuation] = [:]
private var canvasHostUrl: String?
func connect( public func connect(
url: URL, url: URL,
token: String?, token: String?,
password: String?, password: String?,
@ -82,7 +83,7 @@ actor MacNodeGatewaySession {
} }
} }
func disconnect() async { public func disconnect() async {
await self.channel?.shutdown() await self.channel?.shutdown()
self.channel = nil self.channel = nil
self.activeURL = nil self.activeURL = nil
@ -90,7 +91,21 @@ actor MacNodeGatewaySession {
self.activePassword = nil 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 } guard let channel = self.channel else { return }
let params: [String: ClawdbotProtocol.AnyCodable] = [ let params: [String: ClawdbotProtocol.AnyCodable] = [
"event": ClawdbotProtocol.AnyCodable(event), "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<EventFrame> {
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 { private func handlePush(_ push: GatewayPush) async {
switch push { 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): case let .event(evt):
await self.handleEvent(evt) await self.handleEvent(evt)
default: default:
@ -113,6 +157,7 @@ actor MacNodeGatewaySession {
} }
private func handleEvent(_ evt: EventFrame) async { private func handleEvent(_ evt: EventFrame) async {
self.broadcastServerEvent(evt)
guard evt.event == "node.invoke.request" else { return } guard evt.event == "node.invoke.request" else { return }
guard let payload = evt.payload else { return } guard let payload = evt.payload else { return }
do { do {
@ -147,4 +192,34 @@ actor MacNodeGatewaySession {
self.logger.error("node invoke result failed: \(error.localizedDescription, privacy: .public)") 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)
}
} }

View File

@ -0,0 +1,20 @@
import ClawdbotProtocol
import Foundation
public enum GatewayPayloadDecoding {
public static func decode<T: Decodable>(
_ 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<T: Decodable>(
_ payload: ClawdbotProtocol.AnyCodable?,
as _: T.Type = T.self) throws -> T?
{
guard let payload else { return nil }
return try self.decode(payload, as: T.self)
}
}

View File

@ -3,7 +3,7 @@ import ClawdbotProtocol
/// Server-push messages from the gateway websocket. /// Server-push messages from the gateway websocket.
/// ///
/// This is the in-process replacement for the legacy `NotificationCenter` fan-out. /// 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). /// A full snapshot that arrives on connect (or reconnect).
case snapshot(HelloOk) case snapshot(HelloOk)
/// A server push event frame. /// A server push event frame.

View File

@ -2,14 +2,21 @@ import CryptoKit
import Foundation import Foundation
import Security import Security
struct GatewayTLSParams: Sendable { public struct GatewayTLSParams: Sendable {
let required: Bool public let required: Bool
let expectedFingerprint: String? public let expectedFingerprint: String?
let allowTOFU: Bool public let allowTOFU: Bool
let storeKey: String? 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 suiteName = "com.clawdbot.shared"
private static let keyPrefix = "gateway.tls." private static let keyPrefix = "gateway.tls."
@ -17,19 +24,19 @@ enum GatewayTLSStore {
UserDefaults(suiteName: suiteName) ?? .standard UserDefaults(suiteName: suiteName) ?? .standard
} }
static func loadFingerprint(stableID: String) -> String? { public static func loadFingerprint(stableID: String) -> String? {
let key = self.keyPrefix + stableID let key = self.keyPrefix + stableID
let raw = self.defaults.string(forKey: key)?.trimmingCharacters(in: .whitespacesAndNewlines) let raw = self.defaults.string(forKey: key)?.trimmingCharacters(in: .whitespacesAndNewlines)
return raw?.isEmpty == false ? raw : nil 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 let key = self.keyPrefix + stableID
self.defaults.set(value, forKey: key) self.defaults.set(value, forKey: key)
} }
} }
final class GatewayTLSPinningSession: NSObject, WebSocketSessioning, URLSessionDelegate { public final class GatewayTLSPinningSession: NSObject, WebSocketSessioning, URLSessionDelegate {
private let params: GatewayTLSParams private let params: GatewayTLSParams
private lazy var session: URLSession = { private lazy var session: URLSession = {
let config = URLSessionConfiguration.default let config = URLSessionConfiguration.default
@ -37,18 +44,18 @@ final class GatewayTLSPinningSession: NSObject, WebSocketSessioning, URLSessionD
return URLSession(configuration: config, delegate: self, delegateQueue: nil) return URLSession(configuration: config, delegate: self, delegateQueue: nil)
}() }()
init(params: GatewayTLSParams) { public init(params: GatewayTLSParams) {
self.params = params self.params = params
super.init() super.init()
} }
func makeWebSocketTask(url: URL) -> WebSocketTaskBox { public func makeWebSocketTask(url: URL) -> WebSocketTaskBox {
let task = self.session.webSocketTask(with: url) let task = self.session.webSocketTask(with: url)
task.maximumMessageSize = 16 * 1024 * 1024 task.maximumMessageSize = 16 * 1024 * 1024
return WebSocketTaskBox(task: task) return WebSocketTaskBox(task: task)
} }
func urlSession( public func urlSession(
_ session: URLSession, _ session: URLSession,
didReceive challenge: URLAuthenticationChallenge, didReceive challenge: URLAuthenticationChallenge,
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void

View File

@ -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
}()
}