mirror of
https://github.com/moltbot/moltbot.git
synced 2026-03-08 06:54:24 +00:00
refactor: migrate iOS gateway to unified ws
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -44,7 +44,7 @@ actor CameraController {
|
||||
{
|
||||
let facing = params.facing ?? .front
|
||||
let format = params.format ?? .jpg
|
||||
// Default to a reasonable max width to keep bridge payload sizes manageable.
|
||||
// Default to a reasonable max width to keep gateway payload sizes manageable.
|
||||
// If you need the full-res photo, explicitly request a larger maxWidth.
|
||||
let maxWidth = params.maxWidth.flatMap { $0 > 0 ? $0 : nil } ?? 1600
|
||||
let quality = Self.clampQuality(params.quality)
|
||||
@@ -270,7 +270,7 @@ actor CameraController {
|
||||
|
||||
nonisolated static func clampDurationMs(_ ms: Int?) -> Int {
|
||||
let v = ms ?? 3000
|
||||
// Keep clips short by default; avoid huge base64 payloads on the bridge.
|
||||
// Keep clips short by default; avoid huge base64 payloads on the gateway.
|
||||
return min(60000, max(250, v))
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import ClawdbotChatUI
|
||||
import ClawdbotKit
|
||||
import SwiftUI
|
||||
|
||||
struct ChatSheet: View {
|
||||
@@ -6,8 +7,8 @@ struct ChatSheet: View {
|
||||
@State private var viewModel: ClawdbotChatViewModel
|
||||
private let userAccent: Color?
|
||||
|
||||
init(bridge: BridgeSession, sessionKey: String, userAccent: Color? = nil) {
|
||||
let transport = IOSBridgeChatTransport(bridge: bridge)
|
||||
init(gateway: GatewayNodeSession, sessionKey: String, userAccent: Color? = nil) {
|
||||
let transport = IOSGatewayChatTransport(gateway: gateway)
|
||||
self._viewModel = State(
|
||||
initialValue: ClawdbotChatViewModel(
|
||||
sessionKey: sessionKey,
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import ClawdbotChatUI
|
||||
import ClawdbotKit
|
||||
import ClawdbotProtocol
|
||||
import Foundation
|
||||
|
||||
struct IOSBridgeChatTransport: ClawdbotChatTransport, Sendable {
|
||||
private let bridge: BridgeSession
|
||||
struct IOSGatewayChatTransport: ClawdbotChatTransport, Sendable {
|
||||
private let gateway: GatewayNodeSession
|
||||
|
||||
init(bridge: BridgeSession) {
|
||||
self.bridge = bridge
|
||||
init(gateway: GatewayNodeSession) {
|
||||
self.gateway = gateway
|
||||
}
|
||||
|
||||
func abortRun(sessionKey: String, runId: String) async throws {
|
||||
@@ -16,7 +17,7 @@ struct IOSBridgeChatTransport: ClawdbotChatTransport, Sendable {
|
||||
}
|
||||
let data = try JSONEncoder().encode(Params(sessionKey: sessionKey, runId: runId))
|
||||
let json = String(data: data, encoding: .utf8)
|
||||
_ = try await self.bridge.request(method: "chat.abort", paramsJSON: json, timeoutSeconds: 10)
|
||||
_ = try await self.gateway.request(method: "chat.abort", paramsJSON: json, timeoutSeconds: 10)
|
||||
}
|
||||
|
||||
func listSessions(limit: Int?) async throws -> ClawdbotChatSessionsListResponse {
|
||||
@@ -27,7 +28,7 @@ struct IOSBridgeChatTransport: ClawdbotChatTransport, Sendable {
|
||||
}
|
||||
let data = try JSONEncoder().encode(Params(includeGlobal: true, includeUnknown: false, limit: limit))
|
||||
let json = String(data: data, encoding: .utf8)
|
||||
let res = try await self.bridge.request(method: "sessions.list", paramsJSON: json, timeoutSeconds: 15)
|
||||
let res = try await self.gateway.request(method: "sessions.list", paramsJSON: json, timeoutSeconds: 15)
|
||||
return try JSONDecoder().decode(ClawdbotChatSessionsListResponse.self, from: res)
|
||||
}
|
||||
|
||||
@@ -35,14 +36,14 @@ struct IOSBridgeChatTransport: ClawdbotChatTransport, Sendable {
|
||||
struct Subscribe: Codable { var sessionKey: String }
|
||||
let data = try JSONEncoder().encode(Subscribe(sessionKey: sessionKey))
|
||||
let json = String(data: data, encoding: .utf8)
|
||||
try await self.bridge.sendEvent(event: "chat.subscribe", payloadJSON: json)
|
||||
await self.gateway.sendEvent(event: "chat.subscribe", payloadJSON: json)
|
||||
}
|
||||
|
||||
func requestHistory(sessionKey: String) async throws -> ClawdbotChatHistoryPayload {
|
||||
struct Params: Codable { var sessionKey: String }
|
||||
let data = try JSONEncoder().encode(Params(sessionKey: sessionKey))
|
||||
let json = String(data: data, encoding: .utf8)
|
||||
let res = try await self.bridge.request(method: "chat.history", paramsJSON: json, timeoutSeconds: 15)
|
||||
let res = try await self.gateway.request(method: "chat.history", paramsJSON: json, timeoutSeconds: 15)
|
||||
return try JSONDecoder().decode(ClawdbotChatHistoryPayload.self, from: res)
|
||||
}
|
||||
|
||||
@@ -71,20 +72,20 @@ struct IOSBridgeChatTransport: ClawdbotChatTransport, Sendable {
|
||||
idempotencyKey: idempotencyKey)
|
||||
let data = try JSONEncoder().encode(params)
|
||||
let json = String(data: data, encoding: .utf8)
|
||||
let res = try await self.bridge.request(method: "chat.send", paramsJSON: json, timeoutSeconds: 35)
|
||||
let res = try await self.gateway.request(method: "chat.send", paramsJSON: json, timeoutSeconds: 35)
|
||||
return try JSONDecoder().decode(ClawdbotChatSendResponse.self, from: res)
|
||||
}
|
||||
|
||||
func requestHealth(timeoutMs: Int) async throws -> Bool {
|
||||
let seconds = max(1, Int(ceil(Double(timeoutMs) / 1000.0)))
|
||||
let res = try await self.bridge.request(method: "health", paramsJSON: nil, timeoutSeconds: seconds)
|
||||
let res = try await self.gateway.request(method: "health", paramsJSON: nil, timeoutSeconds: seconds)
|
||||
return (try? JSONDecoder().decode(ClawdbotGatewayHealthOK.self, from: res))?.ok ?? true
|
||||
}
|
||||
|
||||
func events() -> AsyncStream<ClawdbotChatTransportEvent> {
|
||||
AsyncStream { continuation in
|
||||
let task = Task {
|
||||
let stream = await self.bridge.subscribeServerEvents()
|
||||
let stream = await self.gateway.subscribeServerEvents()
|
||||
for await evt in stream {
|
||||
if Task.isCancelled { return }
|
||||
switch evt.event {
|
||||
@@ -93,18 +94,18 @@ struct IOSBridgeChatTransport: ClawdbotChatTransport, Sendable {
|
||||
case "seqGap":
|
||||
continuation.yield(.seqGap)
|
||||
case "health":
|
||||
guard let json = evt.payloadJSON, let data = json.data(using: .utf8) else { break }
|
||||
let ok = (try? JSONDecoder().decode(ClawdbotGatewayHealthOK.self, from: data))?.ok ?? true
|
||||
guard let payload = evt.payload else { break }
|
||||
let ok = (try? GatewayPayloadDecoding.decode(payload, as: ClawdbotGatewayHealthOK.self))?.ok ?? true
|
||||
continuation.yield(.health(ok: ok))
|
||||
case "chat":
|
||||
guard let json = evt.payloadJSON, let data = json.data(using: .utf8) else { break }
|
||||
if let payload = try? JSONDecoder().decode(ClawdbotChatEventPayload.self, from: data) {
|
||||
continuation.yield(.chat(payload))
|
||||
guard let payload = evt.payload else { break }
|
||||
if let chatPayload = try? GatewayPayloadDecoding.decode(payload, as: ClawdbotChatEventPayload.self) {
|
||||
continuation.yield(.chat(chatPayload))
|
||||
}
|
||||
case "agent":
|
||||
guard let json = evt.payloadJSON, let data = json.data(using: .utf8) else { break }
|
||||
if let payload = try? JSONDecoder().decode(ClawdbotAgentEventPayload.self, from: data) {
|
||||
continuation.yield(.agent(payload))
|
||||
guard let payload = evt.payload else { break }
|
||||
if let agentPayload = try? GatewayPayloadDecoding.decode(payload, as: ClawdbotAgentEventPayload.self) {
|
||||
continuation.yield(.agent(agentPayload))
|
||||
}
|
||||
default:
|
||||
break
|
||||
@@ -3,14 +3,14 @@ import SwiftUI
|
||||
@main
|
||||
struct ClawdbotApp: App {
|
||||
@State private var appModel: NodeAppModel
|
||||
@State private var bridgeController: BridgeConnectionController
|
||||
@State private var gatewayController: GatewayConnectionController
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
|
||||
init() {
|
||||
BridgeSettingsStore.bootstrapPersistence()
|
||||
GatewaySettingsStore.bootstrapPersistence()
|
||||
let appModel = NodeAppModel()
|
||||
_appModel = State(initialValue: appModel)
|
||||
_bridgeController = State(initialValue: BridgeConnectionController(appModel: appModel))
|
||||
_gatewayController = State(initialValue: GatewayConnectionController(appModel: appModel))
|
||||
}
|
||||
|
||||
var body: some Scene {
|
||||
@@ -18,13 +18,13 @@ struct ClawdbotApp: App {
|
||||
RootCanvas()
|
||||
.environment(self.appModel)
|
||||
.environment(self.appModel.voiceWake)
|
||||
.environment(self.bridgeController)
|
||||
.environment(self.gatewayController)
|
||||
.onOpenURL { url in
|
||||
Task { await self.appModel.handleDeepLink(url: url) }
|
||||
}
|
||||
.onChange(of: self.scenePhase) { _, newValue in
|
||||
self.appModel.setScenePhase(newValue)
|
||||
self.bridgeController.setScenePhase(newValue)
|
||||
self.gatewayController.setScenePhase(newValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,40 +6,23 @@ import Observation
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
protocol BridgePairingClient: Sendable {
|
||||
func pairAndHello(
|
||||
endpoint: NWEndpoint,
|
||||
hello: BridgeHello,
|
||||
tls: BridgeTLSParams?,
|
||||
onStatus: (@Sendable (String) -> Void)?) async throws -> String
|
||||
}
|
||||
|
||||
extension BridgeClient: BridgePairingClient {}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
final class BridgeConnectionController {
|
||||
private(set) var bridges: [BridgeDiscoveryModel.DiscoveredBridge] = []
|
||||
final class GatewayConnectionController {
|
||||
private(set) var gateways: [GatewayDiscoveryModel.DiscoveredGateway] = []
|
||||
private(set) var discoveryStatusText: String = "Idle"
|
||||
private(set) var discoveryDebugLog: [BridgeDiscoveryModel.DebugLogEntry] = []
|
||||
private(set) var discoveryDebugLog: [GatewayDiscoveryModel.DebugLogEntry] = []
|
||||
|
||||
private let discovery = BridgeDiscoveryModel()
|
||||
private let discovery = GatewayDiscoveryModel()
|
||||
private weak var appModel: NodeAppModel?
|
||||
private var didAutoConnect = false
|
||||
|
||||
private let bridgeClientFactory: @Sendable () -> any BridgePairingClient
|
||||
|
||||
init(
|
||||
appModel: NodeAppModel,
|
||||
startDiscovery: Bool = true,
|
||||
bridgeClientFactory: @escaping @Sendable () -> any BridgePairingClient = { BridgeClient() })
|
||||
{
|
||||
init(appModel: NodeAppModel, startDiscovery: Bool = true) {
|
||||
self.appModel = appModel
|
||||
self.bridgeClientFactory = bridgeClientFactory
|
||||
|
||||
BridgeSettingsStore.bootstrapPersistence()
|
||||
GatewaySettingsStore.bootstrapPersistence()
|
||||
let defaults = UserDefaults.standard
|
||||
self.discovery.setDebugLoggingEnabled(defaults.bool(forKey: "bridge.discovery.debugLogs"))
|
||||
self.discovery.setDebugLoggingEnabled(defaults.bool(forKey: "gateway.discovery.debugLogs"))
|
||||
|
||||
self.updateFromDiscovery()
|
||||
self.observeDiscovery()
|
||||
@@ -64,18 +47,53 @@ final class BridgeConnectionController {
|
||||
}
|
||||
}
|
||||
|
||||
func connect(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) async {
|
||||
let instanceId = UserDefaults.standard.string(forKey: "node.instanceId")?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
let token = GatewaySettingsStore.loadGatewayToken(instanceId: instanceId)
|
||||
let password = GatewaySettingsStore.loadGatewayPassword(instanceId: instanceId)
|
||||
guard let host = self.resolveGatewayHost(gateway) else { return }
|
||||
let port = gateway.gatewayPort ?? 18789
|
||||
let tlsParams = self.resolveDiscoveredTLSParams(gateway: gateway)
|
||||
guard let url = self.buildGatewayURL(host: host, port: port, useTLS: tlsParams?.required == true) else { return }
|
||||
self.didAutoConnect = true
|
||||
self.startAutoConnect(
|
||||
url: url,
|
||||
gatewayStableID: gateway.stableID,
|
||||
tls: tlsParams,
|
||||
token: token,
|
||||
password: password)
|
||||
}
|
||||
|
||||
func connectManual(host: String, port: Int, useTLS: Bool) async {
|
||||
let instanceId = UserDefaults.standard.string(forKey: "node.instanceId")?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
let token = GatewaySettingsStore.loadGatewayToken(instanceId: instanceId)
|
||||
let password = GatewaySettingsStore.loadGatewayPassword(instanceId: instanceId)
|
||||
let stableID = self.manualStableID(host: host, port: port)
|
||||
let tlsParams = self.resolveManualTLSParams(stableID: stableID, tlsEnabled: useTLS)
|
||||
guard let url = self.buildGatewayURL(host: host, port: port, useTLS: tlsParams?.required == true) else { return }
|
||||
self.didAutoConnect = true
|
||||
self.startAutoConnect(
|
||||
url: url,
|
||||
gatewayStableID: stableID,
|
||||
tls: tlsParams,
|
||||
token: token,
|
||||
password: password)
|
||||
}
|
||||
|
||||
private func updateFromDiscovery() {
|
||||
let newBridges = self.discovery.bridges
|
||||
self.bridges = newBridges
|
||||
let newGateways = self.discovery.gateways
|
||||
self.gateways = newGateways
|
||||
self.discoveryStatusText = self.discovery.statusText
|
||||
self.discoveryDebugLog = self.discovery.debugLog
|
||||
self.updateLastDiscoveredBridge(from: newBridges)
|
||||
self.updateLastDiscoveredGateway(from: newGateways)
|
||||
self.maybeAutoConnect()
|
||||
}
|
||||
|
||||
private func observeDiscovery() {
|
||||
withObservationTracking {
|
||||
_ = self.discovery.bridges
|
||||
_ = self.discovery.gateways
|
||||
_ = self.discovery.statusText
|
||||
_ = self.discovery.debugLog
|
||||
} onChange: { [weak self] in
|
||||
@@ -90,181 +108,176 @@ final class BridgeConnectionController {
|
||||
private func maybeAutoConnect() {
|
||||
guard !self.didAutoConnect else { return }
|
||||
guard let appModel = self.appModel else { return }
|
||||
guard appModel.bridgeServerName == nil else { return }
|
||||
guard appModel.gatewayServerName == nil else { return }
|
||||
|
||||
let defaults = UserDefaults.standard
|
||||
let manualEnabled = defaults.bool(forKey: "bridge.manual.enabled")
|
||||
let manualEnabled = defaults.bool(forKey: "gateway.manual.enabled")
|
||||
|
||||
let instanceId = defaults.string(forKey: "node.instanceId")?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
guard !instanceId.isEmpty else { return }
|
||||
|
||||
let token = KeychainStore.loadString(
|
||||
service: "com.clawdbot.bridge",
|
||||
account: self.keychainAccount(instanceId: instanceId))?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
guard !token.isEmpty else { return }
|
||||
let token = GatewaySettingsStore.loadGatewayToken(instanceId: instanceId)
|
||||
let password = GatewaySettingsStore.loadGatewayPassword(instanceId: instanceId)
|
||||
|
||||
if manualEnabled {
|
||||
let manualHost = defaults.string(forKey: "bridge.manual.host")?
|
||||
let manualHost = defaults.string(forKey: "gateway.manual.host")?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
guard !manualHost.isEmpty else { return }
|
||||
|
||||
let manualPort = defaults.integer(forKey: "bridge.manual.port")
|
||||
let resolvedPort = manualPort > 0 ? manualPort : 18790
|
||||
guard let port = NWEndpoint.Port(rawValue: UInt16(resolvedPort)) else { return }
|
||||
let manualPort = defaults.integer(forKey: "gateway.manual.port")
|
||||
let resolvedPort = manualPort > 0 ? manualPort : 18789
|
||||
let manualTLS = defaults.bool(forKey: "gateway.manual.tls")
|
||||
|
||||
let stableID = self.manualStableID(host: manualHost, port: resolvedPort)
|
||||
let tlsParams = self.resolveManualTLSParams(stableID: stableID, tlsEnabled: manualTLS)
|
||||
|
||||
guard let url = self.buildGatewayURL(
|
||||
host: manualHost,
|
||||
port: resolvedPort,
|
||||
useTLS: tlsParams?.required == true)
|
||||
else { return }
|
||||
|
||||
self.didAutoConnect = true
|
||||
let endpoint = NWEndpoint.hostPort(host: NWEndpoint.Host(manualHost), port: port)
|
||||
let stableID = BridgeEndpointID.stableID(endpoint)
|
||||
let tlsParams = self.resolveManualTLSParams(stableID: stableID)
|
||||
self.startAutoConnect(
|
||||
endpoint: endpoint,
|
||||
bridgeStableID: stableID,
|
||||
url: url,
|
||||
gatewayStableID: stableID,
|
||||
tls: tlsParams,
|
||||
token: token,
|
||||
instanceId: instanceId)
|
||||
password: password)
|
||||
return
|
||||
}
|
||||
|
||||
let preferredStableID = defaults.string(forKey: "bridge.preferredStableID")?
|
||||
let preferredStableID = defaults.string(forKey: "gateway.preferredStableID")?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
let lastDiscoveredStableID = defaults.string(forKey: "bridge.lastDiscoveredStableID")?
|
||||
let lastDiscoveredStableID = defaults.string(forKey: "gateway.lastDiscoveredStableID")?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
|
||||
let candidates = [preferredStableID, lastDiscoveredStableID].filter { !$0.isEmpty }
|
||||
guard let targetStableID = candidates.first(where: { id in
|
||||
self.bridges.contains(where: { $0.stableID == id })
|
||||
self.gateways.contains(where: { $0.stableID == id })
|
||||
}) else { return }
|
||||
|
||||
guard let target = self.bridges.first(where: { $0.stableID == targetStableID }) else { return }
|
||||
guard let target = self.gateways.first(where: { $0.stableID == targetStableID }) else { return }
|
||||
guard let host = self.resolveGatewayHost(target) else { return }
|
||||
let port = target.gatewayPort ?? 18789
|
||||
let tlsParams = self.resolveDiscoveredTLSParams(gateway: target)
|
||||
guard let url = self.buildGatewayURL(host: host, port: port, useTLS: tlsParams?.required == true)
|
||||
else { return }
|
||||
|
||||
let tlsParams = self.resolveDiscoveredTLSParams(bridge: target)
|
||||
self.didAutoConnect = true
|
||||
self.startAutoConnect(
|
||||
endpoint: target.endpoint,
|
||||
bridgeStableID: target.stableID,
|
||||
url: url,
|
||||
gatewayStableID: target.stableID,
|
||||
tls: tlsParams,
|
||||
token: token,
|
||||
instanceId: instanceId)
|
||||
password: password)
|
||||
}
|
||||
|
||||
private func updateLastDiscoveredBridge(from bridges: [BridgeDiscoveryModel.DiscoveredBridge]) {
|
||||
private func updateLastDiscoveredGateway(from gateways: [GatewayDiscoveryModel.DiscoveredGateway]) {
|
||||
let defaults = UserDefaults.standard
|
||||
let preferred = defaults.string(forKey: "bridge.preferredStableID")?
|
||||
let preferred = defaults.string(forKey: "gateway.preferredStableID")?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
let existingLast = defaults.string(forKey: "bridge.lastDiscoveredStableID")?
|
||||
let existingLast = defaults.string(forKey: "gateway.lastDiscoveredStableID")?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
|
||||
// Avoid overriding user intent (preferred/lastDiscovered are also set on manual Connect).
|
||||
guard preferred.isEmpty, existingLast.isEmpty else { return }
|
||||
guard let first = bridges.first else { return }
|
||||
guard let first = gateways.first else { return }
|
||||
|
||||
defaults.set(first.stableID, forKey: "bridge.lastDiscoveredStableID")
|
||||
BridgeSettingsStore.saveLastDiscoveredBridgeStableID(first.stableID)
|
||||
}
|
||||
|
||||
private func makeHello(token: String) -> BridgeHello {
|
||||
let defaults = UserDefaults.standard
|
||||
let nodeId = defaults.string(forKey: "node.instanceId") ?? "ios-node"
|
||||
let displayName = self.resolvedDisplayName(defaults: defaults)
|
||||
|
||||
return BridgeHello(
|
||||
nodeId: nodeId,
|
||||
displayName: displayName,
|
||||
token: token,
|
||||
platform: self.platformString(),
|
||||
version: self.appVersion(),
|
||||
deviceFamily: self.deviceFamily(),
|
||||
modelIdentifier: self.modelIdentifier(),
|
||||
caps: self.currentCaps(),
|
||||
commands: self.currentCommands())
|
||||
}
|
||||
|
||||
private func keychainAccount(instanceId: String) -> String {
|
||||
"bridge-token.\(instanceId)"
|
||||
defaults.set(first.stableID, forKey: "gateway.lastDiscoveredStableID")
|
||||
GatewaySettingsStore.saveLastDiscoveredGatewayStableID(first.stableID)
|
||||
}
|
||||
|
||||
private func startAutoConnect(
|
||||
endpoint: NWEndpoint,
|
||||
bridgeStableID: String,
|
||||
tls: BridgeTLSParams?,
|
||||
token: String,
|
||||
instanceId: String)
|
||||
url: URL,
|
||||
gatewayStableID: String,
|
||||
tls: GatewayTLSParams?,
|
||||
token: String?,
|
||||
password: String?)
|
||||
{
|
||||
guard let appModel else { return }
|
||||
let connectOptions = self.makeConnectOptions()
|
||||
|
||||
Task { [weak self] in
|
||||
guard let self else { return }
|
||||
do {
|
||||
let hello = self.makeHello(token: token)
|
||||
let refreshed = try await self.bridgeClientFactory().pairAndHello(
|
||||
endpoint: endpoint,
|
||||
hello: hello,
|
||||
tls: tls,
|
||||
onStatus: { status in
|
||||
Task { @MainActor in
|
||||
appModel.bridgeStatusText = status
|
||||
}
|
||||
})
|
||||
let resolvedToken = refreshed.isEmpty ? token : refreshed
|
||||
if !refreshed.isEmpty, refreshed != token {
|
||||
_ = KeychainStore.saveString(
|
||||
refreshed,
|
||||
service: "com.clawdbot.bridge",
|
||||
account: self.keychainAccount(instanceId: instanceId))
|
||||
}
|
||||
appModel.connectToBridge(
|
||||
endpoint: endpoint,
|
||||
bridgeStableID: bridgeStableID,
|
||||
tls: tls,
|
||||
hello: self.makeHello(token: resolvedToken))
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
appModel.bridgeStatusText = "Bridge error: \(error.localizedDescription)"
|
||||
}
|
||||
await MainActor.run {
|
||||
appModel.gatewayStatusText = "Connecting…"
|
||||
}
|
||||
appModel.connectToGateway(
|
||||
url: url,
|
||||
gatewayStableID: gatewayStableID,
|
||||
tls: tls,
|
||||
token: token,
|
||||
password: password,
|
||||
connectOptions: connectOptions)
|
||||
}
|
||||
}
|
||||
|
||||
private func resolveDiscoveredTLSParams(
|
||||
bridge: BridgeDiscoveryModel.DiscoveredBridge) -> BridgeTLSParams?
|
||||
{
|
||||
let stableID = bridge.stableID
|
||||
let stored = BridgeTLSStore.loadFingerprint(stableID: stableID)
|
||||
private func resolveDiscoveredTLSParams(gateway: GatewayDiscoveryModel.DiscoveredGateway) -> GatewayTLSParams? {
|
||||
let stableID = gateway.stableID
|
||||
let stored = GatewayTLSStore.loadFingerprint(stableID: stableID)
|
||||
|
||||
if bridge.tlsEnabled || bridge.tlsFingerprintSha256 != nil {
|
||||
return BridgeTLSParams(
|
||||
if gateway.tlsEnabled || gateway.tlsFingerprintSha256 != nil || stored != nil {
|
||||
return GatewayTLSParams(
|
||||
required: true,
|
||||
expectedFingerprint: bridge.tlsFingerprintSha256 ?? stored,
|
||||
expectedFingerprint: gateway.tlsFingerprintSha256 ?? stored,
|
||||
allowTOFU: stored == nil,
|
||||
storeKey: stableID)
|
||||
}
|
||||
|
||||
if let stored {
|
||||
return BridgeTLSParams(
|
||||
required: true,
|
||||
expectedFingerprint: stored,
|
||||
allowTOFU: false,
|
||||
storeKey: stableID)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private func resolveManualTLSParams(stableID: String) -> BridgeTLSParams? {
|
||||
if let stored = BridgeTLSStore.loadFingerprint(stableID: stableID) {
|
||||
return BridgeTLSParams(
|
||||
private func resolveManualTLSParams(stableID: String, tlsEnabled: Bool) -> GatewayTLSParams? {
|
||||
let stored = GatewayTLSStore.loadFingerprint(stableID: stableID)
|
||||
if tlsEnabled || stored != nil {
|
||||
return GatewayTLSParams(
|
||||
required: true,
|
||||
expectedFingerprint: stored,
|
||||
allowTOFU: false,
|
||||
allowTOFU: stored == nil,
|
||||
storeKey: stableID)
|
||||
}
|
||||
|
||||
return BridgeTLSParams(
|
||||
required: false,
|
||||
expectedFingerprint: nil,
|
||||
allowTOFU: true,
|
||||
storeKey: stableID)
|
||||
return nil
|
||||
}
|
||||
|
||||
private func resolveGatewayHost(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) -> String? {
|
||||
if let lanHost = gateway.lanHost?.trimmingCharacters(in: .whitespacesAndNewlines), !lanHost.isEmpty {
|
||||
return lanHost
|
||||
}
|
||||
if let tailnet = gateway.tailnetDns?.trimmingCharacters(in: .whitespacesAndNewlines), !tailnet.isEmpty {
|
||||
return tailnet
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func buildGatewayURL(host: String, port: Int, useTLS: Bool) -> URL? {
|
||||
let scheme = useTLS ? "wss" : "ws"
|
||||
var components = URLComponents()
|
||||
components.scheme = scheme
|
||||
components.host = host
|
||||
components.port = port
|
||||
return components.url
|
||||
}
|
||||
|
||||
private func manualStableID(host: String, port: Int) -> String {
|
||||
"manual|\(host.lowercased())|\(port)"
|
||||
}
|
||||
|
||||
private func makeConnectOptions() -> GatewayConnectOptions {
|
||||
let defaults = UserDefaults.standard
|
||||
let displayName = self.resolvedDisplayName(defaults: defaults)
|
||||
|
||||
return GatewayConnectOptions(
|
||||
role: "node",
|
||||
scopes: [],
|
||||
caps: self.currentCaps(),
|
||||
commands: self.currentCommands(),
|
||||
permissions: [:],
|
||||
clientId: "clawdbot-ios",
|
||||
clientMode: "node",
|
||||
clientDisplayName: displayName)
|
||||
}
|
||||
|
||||
private func resolvedDisplayName(defaults: UserDefaults) -> String {
|
||||
@@ -313,6 +326,11 @@ final class BridgeConnectionController {
|
||||
ClawdbotCanvasA2UICommand.pushJSONL.rawValue,
|
||||
ClawdbotCanvasA2UICommand.reset.rawValue,
|
||||
ClawdbotScreenCommand.record.rawValue,
|
||||
ClawdbotSystemCommand.notify.rawValue,
|
||||
ClawdbotSystemCommand.which.rawValue,
|
||||
ClawdbotSystemCommand.run.rawValue,
|
||||
ClawdbotSystemCommand.execApprovalsGet.rawValue,
|
||||
ClawdbotSystemCommand.execApprovalsSet.rawValue,
|
||||
]
|
||||
|
||||
let caps = Set(self.currentCaps())
|
||||
@@ -368,11 +386,7 @@ final class BridgeConnectionController {
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
extension BridgeConnectionController {
|
||||
func _test_makeHello(token: String) -> BridgeHello {
|
||||
self.makeHello(token: token)
|
||||
}
|
||||
|
||||
extension GatewayConnectionController {
|
||||
func _test_resolvedDisplayName(defaults: UserDefaults) -> String {
|
||||
self.resolvedDisplayName(defaults: defaults)
|
||||
}
|
||||
@@ -401,8 +415,8 @@ extension BridgeConnectionController {
|
||||
self.appVersion()
|
||||
}
|
||||
|
||||
func _test_setBridges(_ bridges: [BridgeDiscoveryModel.DiscoveredBridge]) {
|
||||
self.bridges = bridges
|
||||
func _test_setGateways(_ gateways: [GatewayDiscoveryModel.DiscoveredGateway]) {
|
||||
self.gateways = gateways
|
||||
}
|
||||
|
||||
func _test_triggerAutoConnect() {
|
||||
@@ -1,9 +1,9 @@
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
struct BridgeDiscoveryDebugLogView: View {
|
||||
@Environment(BridgeConnectionController.self) private var bridgeController
|
||||
@AppStorage("bridge.discovery.debugLogs") private var debugLogsEnabled: Bool = false
|
||||
struct GatewayDiscoveryDebugLogView: View {
|
||||
@Environment(GatewayConnectionController.self) private var gatewayController
|
||||
@AppStorage("gateway.discovery.debugLogs") private var debugLogsEnabled: Bool = false
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
@@ -12,11 +12,11 @@ struct BridgeDiscoveryDebugLogView: View {
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
if self.bridgeController.discoveryDebugLog.isEmpty {
|
||||
if self.gatewayController.discoveryDebugLog.isEmpty {
|
||||
Text("No log entries yet.")
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
ForEach(self.bridgeController.discoveryDebugLog) { entry in
|
||||
ForEach(self.gatewayController.discoveryDebugLog) { entry in
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(Self.formatTime(entry.ts))
|
||||
.font(.caption)
|
||||
@@ -35,13 +35,13 @@ struct BridgeDiscoveryDebugLogView: View {
|
||||
Button("Copy") {
|
||||
UIPasteboard.general.string = self.formattedLog()
|
||||
}
|
||||
.disabled(self.bridgeController.discoveryDebugLog.isEmpty)
|
||||
.disabled(self.gatewayController.discoveryDebugLog.isEmpty)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func formattedLog() -> String {
|
||||
self.bridgeController.discoveryDebugLog
|
||||
self.gatewayController.discoveryDebugLog
|
||||
.map { "\(Self.formatISO($0.ts)) \($0.message)" }
|
||||
.joined(separator: "\n")
|
||||
}
|
||||
@@ -5,14 +5,14 @@ import Observation
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
final class BridgeDiscoveryModel {
|
||||
final class GatewayDiscoveryModel {
|
||||
struct DebugLogEntry: Identifiable, Equatable {
|
||||
var id = UUID()
|
||||
var ts: Date
|
||||
var message: String
|
||||
}
|
||||
|
||||
struct DiscoveredBridge: Identifiable, Equatable {
|
||||
struct DiscoveredGateway: Identifiable, Equatable {
|
||||
var id: String { self.stableID }
|
||||
var name: String
|
||||
var endpoint: NWEndpoint
|
||||
@@ -21,19 +21,18 @@ final class BridgeDiscoveryModel {
|
||||
var lanHost: String?
|
||||
var tailnetDns: String?
|
||||
var gatewayPort: Int?
|
||||
var bridgePort: Int?
|
||||
var canvasPort: Int?
|
||||
var tlsEnabled: Bool
|
||||
var tlsFingerprintSha256: String?
|
||||
var cliPath: String?
|
||||
}
|
||||
|
||||
var bridges: [DiscoveredBridge] = []
|
||||
var gateways: [DiscoveredGateway] = []
|
||||
var statusText: String = "Idle"
|
||||
private(set) var debugLog: [DebugLogEntry] = []
|
||||
|
||||
private var browsers: [String: NWBrowser] = [:]
|
||||
private var bridgesByDomain: [String: [DiscoveredBridge]] = [:]
|
||||
private var gatewaysByDomain: [String: [DiscoveredGateway]] = [:]
|
||||
private var statesByDomain: [String: NWBrowser.State] = [:]
|
||||
private var debugLoggingEnabled = false
|
||||
private var lastStableIDs = Set<String>()
|
||||
@@ -45,7 +44,7 @@ final class BridgeDiscoveryModel {
|
||||
self.debugLog = []
|
||||
} else if !wasEnabled {
|
||||
self.appendDebugLog("debug logging enabled")
|
||||
self.appendDebugLog("snapshot: status=\(self.statusText) bridges=\(self.bridges.count)")
|
||||
self.appendDebugLog("snapshot: status=\(self.statusText) gateways=\(self.gateways.count)")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,7 +71,7 @@ final class BridgeDiscoveryModel {
|
||||
browser.browseResultsChangedHandler = { [weak self] results, _ in
|
||||
Task { @MainActor in
|
||||
guard let self else { return }
|
||||
self.bridgesByDomain[domain] = results.compactMap { result -> DiscoveredBridge? in
|
||||
self.gatewaysByDomain[domain] = results.compactMap { result -> DiscoveredGateway? in
|
||||
switch result.endpoint {
|
||||
case let .service(name, _, _, _):
|
||||
let decodedName = BonjourEscapes.decode(name)
|
||||
@@ -82,18 +81,17 @@ final class BridgeDiscoveryModel {
|
||||
.map(Self.prettifyInstanceName)
|
||||
.flatMap { $0.isEmpty ? nil : $0 }
|
||||
let prettyName = prettyAdvertised ?? Self.prettifyInstanceName(decodedName)
|
||||
return DiscoveredBridge(
|
||||
return DiscoveredGateway(
|
||||
name: prettyName,
|
||||
endpoint: result.endpoint,
|
||||
stableID: BridgeEndpointID.stableID(result.endpoint),
|
||||
debugID: BridgeEndpointID.prettyDescription(result.endpoint),
|
||||
stableID: GatewayEndpointID.stableID(result.endpoint),
|
||||
debugID: GatewayEndpointID.prettyDescription(result.endpoint),
|
||||
lanHost: Self.txtValue(txt, key: "lanHost"),
|
||||
tailnetDns: Self.txtValue(txt, key: "tailnetDns"),
|
||||
gatewayPort: Self.txtIntValue(txt, key: "gatewayPort"),
|
||||
bridgePort: Self.txtIntValue(txt, key: "bridgePort"),
|
||||
canvasPort: Self.txtIntValue(txt, key: "canvasPort"),
|
||||
tlsEnabled: Self.txtBoolValue(txt, key: "bridgeTls"),
|
||||
tlsFingerprintSha256: Self.txtValue(txt, key: "bridgeTlsSha256"),
|
||||
tlsEnabled: Self.txtBoolValue(txt, key: "gatewayTls"),
|
||||
tlsFingerprintSha256: Self.txtValue(txt, key: "gatewayTlsSha256"),
|
||||
cliPath: Self.txtValue(txt, key: "cliPath"))
|
||||
default:
|
||||
return nil
|
||||
@@ -101,12 +99,12 @@ final class BridgeDiscoveryModel {
|
||||
}
|
||||
.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
|
||||
|
||||
self.recomputeBridges()
|
||||
self.recomputeGateways()
|
||||
}
|
||||
}
|
||||
|
||||
self.browsers[domain] = browser
|
||||
browser.start(queue: DispatchQueue(label: "com.clawdbot.ios.bridge-discovery.\(domain)"))
|
||||
browser.start(queue: DispatchQueue(label: "com.clawdbot.ios.gateway-discovery.\(domain)"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,14 +114,14 @@ final class BridgeDiscoveryModel {
|
||||
browser.cancel()
|
||||
}
|
||||
self.browsers = [:]
|
||||
self.bridgesByDomain = [:]
|
||||
self.gatewaysByDomain = [:]
|
||||
self.statesByDomain = [:]
|
||||
self.bridges = []
|
||||
self.gateways = []
|
||||
self.statusText = "Stopped"
|
||||
}
|
||||
|
||||
private func recomputeBridges() {
|
||||
let next = self.bridgesByDomain.values
|
||||
private func recomputeGateways() {
|
||||
let next = self.gatewaysByDomain.values
|
||||
.flatMap(\.self)
|
||||
.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
|
||||
|
||||
@@ -134,7 +132,7 @@ final class BridgeDiscoveryModel {
|
||||
self.appendDebugLog("results: total=\(next.count) added=\(added.count) removed=\(removed.count)")
|
||||
}
|
||||
self.lastStableIDs = nextIDs
|
||||
self.bridges = next
|
||||
self.gateways = next
|
||||
}
|
||||
|
||||
private func updateStatusText() {
|
||||
220
apps/ios/Sources/Gateway/GatewaySettingsStore.swift
Normal file
220
apps/ios/Sources/Gateway/GatewaySettingsStore.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -29,12 +29,12 @@
|
||||
</dict>
|
||||
<key>NSBonjourServices</key>
|
||||
<array>
|
||||
<string>_clawdbot-bridge._tcp</string>
|
||||
<string>_clawdbot-gateway._tcp</string>
|
||||
</array>
|
||||
<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>
|
||||
<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>
|
||||
<string>Clawdbot can share your location in the background when you enable Always.</string>
|
||||
<key>NSLocationWhenInUseUsageDescription</key>
|
||||
|
||||
@@ -18,15 +18,15 @@ final class NodeAppModel {
|
||||
let screen = ScreenController()
|
||||
let camera = CameraController()
|
||||
private let screenRecorder = ScreenRecordService()
|
||||
var bridgeStatusText: String = "Offline"
|
||||
var bridgeServerName: String?
|
||||
var bridgeRemoteAddress: String?
|
||||
var connectedBridgeID: String?
|
||||
var gatewayStatusText: String = "Offline"
|
||||
var gatewayServerName: String?
|
||||
var gatewayRemoteAddress: String?
|
||||
var connectedGatewayID: String?
|
||||
var seamColorHex: String?
|
||||
var mainSessionKey: String = "main"
|
||||
|
||||
private let bridge = BridgeSession()
|
||||
private var bridgeTask: Task<Void, Never>?
|
||||
private let gateway = GatewayNodeSession()
|
||||
private var gatewayTask: Task<Void, Never>?
|
||||
private var voiceWakeSyncTask: Task<Void, Never>?
|
||||
@ObservationIgnored private var cameraHUDDismissTask: Task<Void, Never>?
|
||||
let voiceWake = VoiceWakeManager()
|
||||
@@ -34,7 +34,8 @@ final class NodeAppModel {
|
||||
private let locationService = LocationService()
|
||||
private var lastAutoA2uiURL: String?
|
||||
|
||||
var bridgeSession: BridgeSession { self.bridge }
|
||||
private var gatewayConnected = false
|
||||
var gatewaySession: GatewayNodeSession { self.gateway }
|
||||
|
||||
var cameraHUDText: String?
|
||||
var cameraHUDKind: CameraHUDKind?
|
||||
@@ -54,7 +55,7 @@ final class NodeAppModel {
|
||||
|
||||
let enabled = UserDefaults.standard.bool(forKey: "voiceWake.enabled")
|
||||
self.voiceWake.setEnabled(enabled)
|
||||
self.talkMode.attachBridge(self.bridge)
|
||||
self.talkMode.attachGateway(self.gateway)
|
||||
let talkEnabled = UserDefaults.standard.bool(forKey: "talk.enabled")
|
||||
self.talkMode.setEnabled(talkEnabled)
|
||||
|
||||
@@ -120,9 +121,9 @@ final class NodeAppModel {
|
||||
|
||||
let ok: Bool
|
||||
var errorText: String?
|
||||
if await !self.isBridgeConnected() {
|
||||
if await !self.isGatewayConnected() {
|
||||
ok = false
|
||||
errorText = "bridge not connected"
|
||||
errorText = "gateway not connected"
|
||||
} else {
|
||||
do {
|
||||
try await self.sendAgentRequest(link: AgentDeepLink(
|
||||
@@ -150,7 +151,7 @@ final class NodeAppModel {
|
||||
}
|
||||
|
||||
private func resolveA2UIHostURL() async -> String? {
|
||||
guard let raw = await self.bridge.currentCanvasHostUrl() else { return nil }
|
||||
guard let raw = await self.gateway.currentCanvasHostUrl() else { return nil }
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty, let base = URL(string: trimmed) else { return nil }
|
||||
return base.appendingPathComponent("__clawdbot__/a2ui/").absoluteString + "?platform=ios"
|
||||
@@ -202,56 +203,70 @@ final class NodeAppModel {
|
||||
}
|
||||
}
|
||||
|
||||
func connectToBridge(
|
||||
endpoint: NWEndpoint,
|
||||
bridgeStableID: String,
|
||||
tls: BridgeTLSParams?,
|
||||
hello: BridgeHello)
|
||||
func connectToGateway(
|
||||
url: URL,
|
||||
gatewayStableID: String,
|
||||
tls: GatewayTLSParams?,
|
||||
token: String?,
|
||||
password: String?,
|
||||
connectOptions: GatewayConnectOptions)
|
||||
{
|
||||
self.bridgeTask?.cancel()
|
||||
self.bridgeServerName = nil
|
||||
self.bridgeRemoteAddress = nil
|
||||
let id = bridgeStableID.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
self.connectedBridgeID = id.isEmpty ? BridgeEndpointID.stableID(endpoint) : id
|
||||
self.gatewayTask?.cancel()
|
||||
self.gatewayServerName = nil
|
||||
self.gatewayRemoteAddress = nil
|
||||
let id = gatewayStableID.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
self.connectedGatewayID = id.isEmpty ? url.absoluteString : id
|
||||
self.gatewayConnected = false
|
||||
self.voiceWakeSyncTask?.cancel()
|
||||
self.voiceWakeSyncTask = nil
|
||||
let sessionBox = tls.map { WebSocketSessionBox(session: GatewayTLSPinningSession(params: $0)) }
|
||||
|
||||
self.bridgeTask = Task {
|
||||
self.gatewayTask = Task {
|
||||
var attempt = 0
|
||||
while !Task.isCancelled {
|
||||
await MainActor.run {
|
||||
if attempt == 0 {
|
||||
self.bridgeStatusText = "Connecting…"
|
||||
self.gatewayStatusText = "Connecting…"
|
||||
} else {
|
||||
self.bridgeStatusText = "Reconnecting…"
|
||||
self.gatewayStatusText = "Reconnecting…"
|
||||
}
|
||||
self.bridgeServerName = nil
|
||||
self.bridgeRemoteAddress = nil
|
||||
self.gatewayServerName = nil
|
||||
self.gatewayRemoteAddress = nil
|
||||
}
|
||||
|
||||
do {
|
||||
try await self.bridge.connect(
|
||||
endpoint: endpoint,
|
||||
hello: hello,
|
||||
tls: tls,
|
||||
onConnected: { [weak self] serverName, mainSessionKey in
|
||||
try await self.gateway.connect(
|
||||
url: url,
|
||||
token: token,
|
||||
password: password,
|
||||
connectOptions: connectOptions,
|
||||
sessionBox: sessionBox,
|
||||
onConnected: { [weak self] in
|
||||
guard let self else { return }
|
||||
await MainActor.run {
|
||||
self.bridgeStatusText = "Connected"
|
||||
self.bridgeServerName = serverName
|
||||
self.gatewayStatusText = "Connected"
|
||||
self.gatewayServerName = url.host ?? "gateway"
|
||||
self.gatewayConnected = true
|
||||
}
|
||||
await MainActor.run {
|
||||
self.applyMainSessionKey(mainSessionKey)
|
||||
}
|
||||
if let addr = await self.bridge.currentRemoteAddress() {
|
||||
if let addr = await self.gateway.currentRemoteAddress() {
|
||||
await MainActor.run {
|
||||
self.bridgeRemoteAddress = addr
|
||||
self.gatewayRemoteAddress = addr
|
||||
}
|
||||
}
|
||||
await self.refreshBrandingFromGateway()
|
||||
await self.startVoiceWakeSync()
|
||||
await self.showA2UIOnConnectIfNeeded()
|
||||
},
|
||||
onDisconnected: { [weak self] reason in
|
||||
guard let self else { return }
|
||||
await MainActor.run {
|
||||
self.gatewayStatusText = "Disconnected"
|
||||
self.gatewayRemoteAddress = nil
|
||||
self.gatewayConnected = false
|
||||
self.showLocalCanvasOnDisconnect()
|
||||
}
|
||||
self.gatewayStatusText = "Disconnected: \(reason)"
|
||||
},
|
||||
onInvoke: { [weak self] req in
|
||||
guard let self else {
|
||||
return BridgeInvokeResponse(
|
||||
@@ -265,19 +280,16 @@ final class NodeAppModel {
|
||||
})
|
||||
|
||||
if Task.isCancelled { break }
|
||||
await MainActor.run {
|
||||
self.showLocalCanvasOnDisconnect()
|
||||
}
|
||||
attempt += 1
|
||||
let sleepSeconds = min(6.0, 0.35 * pow(1.7, Double(attempt)))
|
||||
try? await Task.sleep(nanoseconds: UInt64(sleepSeconds * 1_000_000_000))
|
||||
attempt = 0
|
||||
try? await Task.sleep(nanoseconds: 1_000_000_000)
|
||||
} catch {
|
||||
if Task.isCancelled { break }
|
||||
attempt += 1
|
||||
await MainActor.run {
|
||||
self.bridgeStatusText = "Bridge error: \(error.localizedDescription)"
|
||||
self.bridgeServerName = nil
|
||||
self.bridgeRemoteAddress = nil
|
||||
self.gatewayStatusText = "Gateway error: \(error.localizedDescription)"
|
||||
self.gatewayServerName = nil
|
||||
self.gatewayRemoteAddress = nil
|
||||
self.gatewayConnected = false
|
||||
self.showLocalCanvasOnDisconnect()
|
||||
}
|
||||
let sleepSeconds = min(8.0, 0.5 * pow(1.7, Double(attempt)))
|
||||
@@ -286,10 +298,11 @@ final class NodeAppModel {
|
||||
}
|
||||
|
||||
await MainActor.run {
|
||||
self.bridgeStatusText = "Offline"
|
||||
self.bridgeServerName = nil
|
||||
self.bridgeRemoteAddress = nil
|
||||
self.connectedBridgeID = nil
|
||||
self.gatewayStatusText = "Offline"
|
||||
self.gatewayServerName = nil
|
||||
self.gatewayRemoteAddress = nil
|
||||
self.connectedGatewayID = nil
|
||||
self.gatewayConnected = false
|
||||
self.seamColorHex = nil
|
||||
if !SessionKey.isCanonicalMainSessionKey(self.mainSessionKey) {
|
||||
self.mainSessionKey = "main"
|
||||
@@ -300,16 +313,17 @@ final class NodeAppModel {
|
||||
}
|
||||
}
|
||||
|
||||
func disconnectBridge() {
|
||||
self.bridgeTask?.cancel()
|
||||
self.bridgeTask = nil
|
||||
func disconnectGateway() {
|
||||
self.gatewayTask?.cancel()
|
||||
self.gatewayTask = nil
|
||||
self.voiceWakeSyncTask?.cancel()
|
||||
self.voiceWakeSyncTask = nil
|
||||
Task { await self.bridge.disconnect() }
|
||||
self.bridgeStatusText = "Offline"
|
||||
self.bridgeServerName = nil
|
||||
self.bridgeRemoteAddress = nil
|
||||
self.connectedBridgeID = nil
|
||||
Task { await self.gateway.disconnect() }
|
||||
self.gatewayStatusText = "Offline"
|
||||
self.gatewayServerName = nil
|
||||
self.gatewayRemoteAddress = nil
|
||||
self.connectedGatewayID = nil
|
||||
self.gatewayConnected = false
|
||||
self.seamColorHex = nil
|
||||
if !SessionKey.isCanonicalMainSessionKey(self.mainSessionKey) {
|
||||
self.mainSessionKey = "main"
|
||||
@@ -347,7 +361,7 @@ final class NodeAppModel {
|
||||
|
||||
private func refreshBrandingFromGateway() async {
|
||||
do {
|
||||
let res = try await self.bridge.request(method: "config.get", paramsJSON: "{}", timeoutSeconds: 8)
|
||||
let res = try await self.gateway.request(method: "config.get", paramsJSON: "{}", timeoutSeconds: 8)
|
||||
guard let json = try JSONSerialization.jsonObject(with: res) as? [String: Any] else { return }
|
||||
guard let config = json["config"] as? [String: Any] else { return }
|
||||
let ui = config["ui"] as? [String: Any]
|
||||
@@ -378,7 +392,7 @@ final class NodeAppModel {
|
||||
else { return }
|
||||
|
||||
do {
|
||||
_ = try await self.bridge.request(method: "voicewake.set", paramsJSON: json, timeoutSeconds: 12)
|
||||
_ = try await self.gateway.request(method: "voicewake.set", paramsJSON: json, timeoutSeconds: 12)
|
||||
} catch {
|
||||
// Best-effort only.
|
||||
}
|
||||
@@ -391,7 +405,7 @@ final class NodeAppModel {
|
||||
|
||||
await self.refreshWakeWordsFromGateway()
|
||||
|
||||
let stream = await self.bridge.subscribeServerEvents(bufferingNewest: 200)
|
||||
let stream = await self.gateway.subscribeServerEvents(bufferingNewest: 200)
|
||||
for await evt in stream {
|
||||
if Task.isCancelled { return }
|
||||
guard evt.event == "voicewake.changed" else { continue }
|
||||
@@ -404,7 +418,7 @@ final class NodeAppModel {
|
||||
|
||||
private func refreshWakeWordsFromGateway() async {
|
||||
do {
|
||||
let data = try await self.bridge.request(method: "voicewake.get", paramsJSON: "{}", timeoutSeconds: 8)
|
||||
let data = try await self.gateway.request(method: "voicewake.get", paramsJSON: "{}", timeoutSeconds: 8)
|
||||
guard let triggers = VoiceWakePreferences.decodeGatewayTriggers(from: data) else { return }
|
||||
VoiceWakePreferences.saveTriggerWords(triggers)
|
||||
} catch {
|
||||
@@ -413,6 +427,11 @@ final class NodeAppModel {
|
||||
}
|
||||
|
||||
func sendVoiceTranscript(text: String, sessionKey: String?) async throws {
|
||||
if await !self.isGatewayConnected() {
|
||||
throw NSError(domain: "Gateway", code: 10, userInfo: [
|
||||
NSLocalizedDescriptionKey: "Gateway not connected",
|
||||
])
|
||||
}
|
||||
struct Payload: Codable {
|
||||
var text: String
|
||||
var sessionKey: String?
|
||||
@@ -424,7 +443,7 @@ final class NodeAppModel {
|
||||
NSLocalizedDescriptionKey: "Failed to encode voice transcript payload as UTF-8",
|
||||
])
|
||||
}
|
||||
try await self.bridge.sendEvent(event: "voice.transcript", payloadJSON: json)
|
||||
await self.gateway.sendEvent(event: "voice.transcript", payloadJSON: json)
|
||||
}
|
||||
|
||||
func handleDeepLink(url: URL) async {
|
||||
@@ -445,8 +464,8 @@ final class NodeAppModel {
|
||||
return
|
||||
}
|
||||
|
||||
guard await self.isBridgeConnected() else {
|
||||
self.screen.errorText = "Bridge not connected (cannot forward deep link)."
|
||||
guard await self.isGatewayConnected() else {
|
||||
self.screen.errorText = "Gateway not connected (cannot forward deep link)."
|
||||
return
|
||||
}
|
||||
|
||||
@@ -465,7 +484,7 @@ final class NodeAppModel {
|
||||
])
|
||||
}
|
||||
|
||||
// iOS bridge forwards to the gateway; no local auth prompts here.
|
||||
// iOS gateway forwards to the gateway; no local auth prompts here.
|
||||
// (Key-based unattended auth is handled on macOS for clawdbot:// links.)
|
||||
let data = try JSONEncoder().encode(link)
|
||||
guard let json = String(bytes: data, encoding: .utf8) else {
|
||||
@@ -473,12 +492,11 @@ final class NodeAppModel {
|
||||
NSLocalizedDescriptionKey: "Failed to encode agent request payload as UTF-8",
|
||||
])
|
||||
}
|
||||
try await self.bridge.sendEvent(event: "agent.request", payloadJSON: json)
|
||||
await self.gateway.sendEvent(event: "agent.request", payloadJSON: json)
|
||||
}
|
||||
|
||||
private func isBridgeConnected() async -> Bool {
|
||||
if case .connected = await self.bridge.state { return true }
|
||||
return false
|
||||
private func isGatewayConnected() async -> Bool {
|
||||
self.gatewayConnected
|
||||
}
|
||||
|
||||
private func handleInvoke(_ req: BridgeInvokeRequest) async -> BridgeInvokeResponse {
|
||||
@@ -849,7 +867,7 @@ final class NodeAppModel {
|
||||
|
||||
private static func decodeParams<T: Decodable>(_ type: T.Type, from json: String?) throws -> T {
|
||||
guard let json, let data = json.data(using: .utf8) else {
|
||||
throw NSError(domain: "Bridge", code: 20, userInfo: [
|
||||
throw NSError(domain: "Gateway", code: 20, userInfo: [
|
||||
NSLocalizedDescriptionKey: "INVALID_REQUEST: paramsJSON required",
|
||||
])
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ struct RootCanvas: View {
|
||||
ZStack {
|
||||
CanvasContent(
|
||||
systemColorScheme: self.systemColorScheme,
|
||||
bridgeStatus: self.bridgeStatus,
|
||||
gatewayStatus: self.gatewayStatus,
|
||||
voiceWakeEnabled: self.voiceWakeEnabled,
|
||||
voiceWakeToastText: self.voiceWakeToastText,
|
||||
cameraHUDText: self.appModel.cameraHUDText,
|
||||
@@ -52,7 +52,7 @@ struct RootCanvas: View {
|
||||
SettingsTab()
|
||||
case .chat:
|
||||
ChatSheet(
|
||||
bridge: self.appModel.bridgeSession,
|
||||
gateway: self.appModel.gatewaySession,
|
||||
sessionKey: self.appModel.mainSessionKey,
|
||||
userAccent: self.appModel.seamColor)
|
||||
}
|
||||
@@ -62,9 +62,9 @@ struct RootCanvas: View {
|
||||
.onChange(of: self.scenePhase) { _, _ in self.updateIdleTimer() }
|
||||
.onAppear { self.updateCanvasDebugStatus() }
|
||||
.onChange(of: self.canvasDebugStatusEnabled) { _, _ in self.updateCanvasDebugStatus() }
|
||||
.onChange(of: self.appModel.bridgeStatusText) { _, _ in self.updateCanvasDebugStatus() }
|
||||
.onChange(of: self.appModel.bridgeServerName) { _, _ in self.updateCanvasDebugStatus() }
|
||||
.onChange(of: self.appModel.bridgeRemoteAddress) { _, _ in self.updateCanvasDebugStatus() }
|
||||
.onChange(of: self.appModel.gatewayStatusText) { _, _ in self.updateCanvasDebugStatus() }
|
||||
.onChange(of: self.appModel.gatewayServerName) { _, _ in self.updateCanvasDebugStatus() }
|
||||
.onChange(of: self.appModel.gatewayRemoteAddress) { _, _ in self.updateCanvasDebugStatus() }
|
||||
.onChange(of: self.voiceWake.lastTriggeredCommand) { _, newValue in
|
||||
guard let newValue else { return }
|
||||
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
@@ -91,10 +91,10 @@ struct RootCanvas: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var bridgeStatus: StatusPill.BridgeState {
|
||||
if self.appModel.bridgeServerName != nil { return .connected }
|
||||
private var gatewayStatus: StatusPill.GatewayState {
|
||||
if self.appModel.gatewayServerName != nil { return .connected }
|
||||
|
||||
let text = self.appModel.bridgeStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let text = self.appModel.gatewayStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if text.localizedCaseInsensitiveContains("connecting") ||
|
||||
text.localizedCaseInsensitiveContains("reconnecting")
|
||||
{
|
||||
@@ -115,8 +115,8 @@ struct RootCanvas: View {
|
||||
private func updateCanvasDebugStatus() {
|
||||
self.appModel.screen.setDebugStatusEnabled(self.canvasDebugStatusEnabled)
|
||||
guard self.canvasDebugStatusEnabled else { return }
|
||||
let title = self.appModel.bridgeStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let subtitle = self.appModel.bridgeServerName ?? self.appModel.bridgeRemoteAddress
|
||||
let title = self.appModel.gatewayStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let subtitle = self.appModel.gatewayServerName ?? self.appModel.gatewayRemoteAddress
|
||||
self.appModel.screen.updateDebugStatus(title: title, subtitle: subtitle)
|
||||
}
|
||||
}
|
||||
@@ -126,7 +126,7 @@ private struct CanvasContent: View {
|
||||
@AppStorage("talk.enabled") private var talkEnabled: Bool = false
|
||||
@AppStorage("talk.button.enabled") private var talkButtonEnabled: Bool = true
|
||||
var systemColorScheme: ColorScheme
|
||||
var bridgeStatus: StatusPill.BridgeState
|
||||
var gatewayStatus: StatusPill.GatewayState
|
||||
var voiceWakeEnabled: Bool
|
||||
var voiceWakeToastText: String?
|
||||
var cameraHUDText: String?
|
||||
@@ -177,7 +177,7 @@ private struct CanvasContent: View {
|
||||
}
|
||||
.overlay(alignment: .topLeading) {
|
||||
StatusPill(
|
||||
bridge: self.bridgeStatus,
|
||||
gateway: self.gatewayStatus,
|
||||
voiceWakeEnabled: self.voiceWakeEnabled,
|
||||
activity: self.statusActivity,
|
||||
brighten: self.brightenButtons,
|
||||
@@ -208,15 +208,15 @@ private struct CanvasContent: View {
|
||||
tint: .orange)
|
||||
}
|
||||
|
||||
let bridgeStatus = self.appModel.bridgeStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let bridgeLower = bridgeStatus.lowercased()
|
||||
if bridgeLower.contains("repair") {
|
||||
let gatewayStatus = self.appModel.gatewayStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let gatewayLower = gatewayStatus.lowercased()
|
||||
if gatewayLower.contains("repair") {
|
||||
return StatusPill.Activity(title: "Repairing…", systemImage: "wrench.and.screwdriver", tint: .orange)
|
||||
}
|
||||
if bridgeLower.contains("approval") || bridgeLower.contains("pairing") {
|
||||
if gatewayLower.contains("approval") || gatewayLower.contains("pairing") {
|
||||
return StatusPill.Activity(title: "Approval pending", systemImage: "person.crop.circle.badge.clock")
|
||||
}
|
||||
// Avoid duplicating the primary bridge status ("Connecting…") in the activity slot.
|
||||
// Avoid duplicating the primary gateway status ("Connecting…") in the activity slot.
|
||||
|
||||
if self.appModel.screenRecordActive {
|
||||
return StatusPill.Activity(title: "Recording screen…", systemImage: "record.circle.fill", tint: .red)
|
||||
|
||||
@@ -24,7 +24,7 @@ struct RootTabs: View {
|
||||
}
|
||||
.overlay(alignment: .topLeading) {
|
||||
StatusPill(
|
||||
bridge: self.bridgeStatus,
|
||||
gateway: self.gatewayStatus,
|
||||
voiceWakeEnabled: self.voiceWakeEnabled,
|
||||
activity: self.statusActivity,
|
||||
onTap: { self.selectedTab = 2 })
|
||||
@@ -64,10 +64,10 @@ struct RootTabs: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var bridgeStatus: StatusPill.BridgeState {
|
||||
if self.appModel.bridgeServerName != nil { return .connected }
|
||||
private var gatewayStatus: StatusPill.GatewayState {
|
||||
if self.appModel.gatewayServerName != nil { return .connected }
|
||||
|
||||
let text = self.appModel.bridgeStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let text = self.appModel.gatewayStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if text.localizedCaseInsensitiveContains("connecting") ||
|
||||
text.localizedCaseInsensitiveContains("reconnecting")
|
||||
{
|
||||
@@ -90,15 +90,15 @@ struct RootTabs: View {
|
||||
tint: .orange)
|
||||
}
|
||||
|
||||
let bridgeStatus = self.appModel.bridgeStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let bridgeLower = bridgeStatus.lowercased()
|
||||
if bridgeLower.contains("repair") {
|
||||
let gatewayStatus = self.appModel.gatewayStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let gatewayLower = gatewayStatus.lowercased()
|
||||
if gatewayLower.contains("repair") {
|
||||
return StatusPill.Activity(title: "Repairing…", systemImage: "wrench.and.screwdriver", tint: .orange)
|
||||
}
|
||||
if bridgeLower.contains("approval") || bridgeLower.contains("pairing") {
|
||||
if gatewayLower.contains("approval") || gatewayLower.contains("pairing") {
|
||||
return StatusPill.Activity(title: "Approval pending", systemImage: "person.crop.circle.badge.clock")
|
||||
}
|
||||
// Avoid duplicating the primary bridge status ("Connecting…") in the activity slot.
|
||||
// Avoid duplicating the primary gateway status ("Connecting…") in the activity slot.
|
||||
|
||||
if self.appModel.screenRecordActive {
|
||||
return StatusPill.Activity(title: "Recording screen…", systemImage: "record.circle.fill", tint: .red)
|
||||
|
||||
@@ -15,7 +15,7 @@ extension ConnectStatusStore: @unchecked Sendable {}
|
||||
struct SettingsTab: View {
|
||||
@Environment(NodeAppModel.self) private var appModel: NodeAppModel
|
||||
@Environment(VoiceWakeManager.self) private var voiceWake: VoiceWakeManager
|
||||
@Environment(BridgeConnectionController.self) private var bridgeController: BridgeConnectionController
|
||||
@Environment(GatewayConnectionController.self) private var gatewayController: GatewayConnectionController
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@AppStorage("node.displayName") private var displayName: String = "iOS Node"
|
||||
@AppStorage("node.instanceId") private var instanceId: String = UUID().uuidString
|
||||
@@ -26,17 +26,20 @@ struct SettingsTab: View {
|
||||
@AppStorage("location.enabledMode") private var locationEnabledModeRaw: String = ClawdbotLocationMode.off.rawValue
|
||||
@AppStorage("location.preciseEnabled") private var locationPreciseEnabled: Bool = true
|
||||
@AppStorage("screen.preventSleep") private var preventSleep: Bool = true
|
||||
@AppStorage("bridge.preferredStableID") private var preferredBridgeStableID: String = ""
|
||||
@AppStorage("bridge.lastDiscoveredStableID") private var lastDiscoveredBridgeStableID: String = ""
|
||||
@AppStorage("bridge.manual.enabled") private var manualBridgeEnabled: Bool = false
|
||||
@AppStorage("bridge.manual.host") private var manualBridgeHost: String = ""
|
||||
@AppStorage("bridge.manual.port") private var manualBridgePort: Int = 18790
|
||||
@AppStorage("bridge.discovery.debugLogs") private var discoveryDebugLogsEnabled: Bool = false
|
||||
@AppStorage("gateway.preferredStableID") private var preferredGatewayStableID: String = ""
|
||||
@AppStorage("gateway.lastDiscoveredStableID") private var lastDiscoveredGatewayStableID: String = ""
|
||||
@AppStorage("gateway.manual.enabled") private var manualGatewayEnabled: Bool = false
|
||||
@AppStorage("gateway.manual.host") private var manualGatewayHost: String = ""
|
||||
@AppStorage("gateway.manual.port") private var manualGatewayPort: Int = 18789
|
||||
@AppStorage("gateway.manual.tls") private var manualGatewayTLS: Bool = true
|
||||
@AppStorage("gateway.discovery.debugLogs") private var discoveryDebugLogsEnabled: Bool = false
|
||||
@AppStorage("canvas.debugStatusEnabled") private var canvasDebugStatusEnabled: Bool = false
|
||||
@State private var connectStatus = ConnectStatusStore()
|
||||
@State private var connectingBridgeID: String?
|
||||
@State private var connectingGatewayID: String?
|
||||
@State private var localIPAddress: String?
|
||||
@State private var lastLocationModeRaw: String = ClawdbotLocationMode.off.rawValue
|
||||
@State private var gatewayToken: String = ""
|
||||
@State private var gatewayPassword: String = ""
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
@@ -61,12 +64,12 @@ struct SettingsTab: View {
|
||||
LabeledContent("Model", value: self.modelIdentifier())
|
||||
}
|
||||
|
||||
Section("Bridge") {
|
||||
LabeledContent("Discovery", value: self.bridgeController.discoveryStatusText)
|
||||
LabeledContent("Status", value: self.appModel.bridgeStatusText)
|
||||
if let serverName = self.appModel.bridgeServerName {
|
||||
Section("Gateway") {
|
||||
LabeledContent("Discovery", value: self.gatewayController.discoveryStatusText)
|
||||
LabeledContent("Status", value: self.appModel.gatewayStatusText)
|
||||
if let serverName = self.appModel.gatewayServerName {
|
||||
LabeledContent("Server", value: serverName)
|
||||
if let addr = self.appModel.bridgeRemoteAddress {
|
||||
if let addr = self.appModel.gatewayRemoteAddress {
|
||||
let parts = Self.parseHostPort(from: addr)
|
||||
let urlString = Self.httpURLString(host: parts?.host, port: parts?.port, fallback: addr)
|
||||
LabeledContent("Address") {
|
||||
@@ -96,12 +99,12 @@ struct SettingsTab: View {
|
||||
}
|
||||
|
||||
Button("Disconnect", role: .destructive) {
|
||||
self.appModel.disconnectBridge()
|
||||
self.appModel.disconnectGateway()
|
||||
}
|
||||
|
||||
self.bridgeList(showing: .availableOnly)
|
||||
self.gatewayList(showing: .availableOnly)
|
||||
} else {
|
||||
self.bridgeList(showing: .all)
|
||||
self.gatewayList(showing: .all)
|
||||
}
|
||||
|
||||
if let text = self.connectStatus.text {
|
||||
@@ -111,19 +114,21 @@ struct SettingsTab: View {
|
||||
}
|
||||
|
||||
DisclosureGroup("Advanced") {
|
||||
Toggle("Use Manual Bridge", isOn: self.$manualBridgeEnabled)
|
||||
Toggle("Use Manual Gateway", isOn: self.$manualGatewayEnabled)
|
||||
|
||||
TextField("Host", text: self.$manualBridgeHost)
|
||||
TextField("Host", text: self.$manualGatewayHost)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
|
||||
TextField("Port", value: self.$manualBridgePort, format: .number)
|
||||
TextField("Port", value: self.$manualGatewayPort, format: .number)
|
||||
.keyboardType(.numberPad)
|
||||
|
||||
Toggle("Use TLS", isOn: self.$manualGatewayTLS)
|
||||
|
||||
Button {
|
||||
Task { await self.connectManual() }
|
||||
} label: {
|
||||
if self.connectingBridgeID == "manual" {
|
||||
if self.connectingGatewayID == "manual" {
|
||||
HStack(spacing: 8) {
|
||||
ProgressView()
|
||||
.progressViewStyle(.circular)
|
||||
@@ -133,26 +138,32 @@ struct SettingsTab: View {
|
||||
Text("Connect (Manual)")
|
||||
}
|
||||
}
|
||||
.disabled(self.connectingBridgeID != nil || self.manualBridgeHost
|
||||
.disabled(self.connectingGatewayID != nil || self.manualGatewayHost
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.isEmpty || self.manualBridgePort <= 0 || self.manualBridgePort > 65535)
|
||||
.isEmpty || self.manualGatewayPort <= 0 || self.manualGatewayPort > 65535)
|
||||
|
||||
Text(
|
||||
"Use this when mDNS/Bonjour discovery is blocked. "
|
||||
+ "The bridge runs on the gateway (default port 18790).")
|
||||
+ "The gateway WebSocket listens on port 18789 by default.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Toggle("Discovery Debug Logs", isOn: self.$discoveryDebugLogsEnabled)
|
||||
.onChange(of: self.discoveryDebugLogsEnabled) { _, newValue in
|
||||
self.bridgeController.setDiscoveryDebugLoggingEnabled(newValue)
|
||||
self.gatewayController.setDiscoveryDebugLoggingEnabled(newValue)
|
||||
}
|
||||
|
||||
NavigationLink("Discovery Logs") {
|
||||
BridgeDiscoveryDebugLogView()
|
||||
GatewayDiscoveryDebugLogView()
|
||||
}
|
||||
|
||||
Toggle("Debug Canvas Status", isOn: self.$canvasDebugStatusEnabled)
|
||||
|
||||
TextField("Gateway Token", text: self.$gatewayToken)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
|
||||
SecureField("Gateway Password", text: self.$gatewayPassword)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,7 +190,7 @@ struct SettingsTab: View {
|
||||
|
||||
Section("Camera") {
|
||||
Toggle("Allow Camera", isOn: self.$cameraEnabled)
|
||||
Text("Allows the bridge to request photos or short video clips (foreground only).")
|
||||
Text("Allows the gateway to request photos or short video clips (foreground only).")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
@@ -221,13 +232,30 @@ struct SettingsTab: View {
|
||||
.onAppear {
|
||||
self.localIPAddress = Self.primaryIPv4Address()
|
||||
self.lastLocationModeRaw = self.locationEnabledModeRaw
|
||||
let trimmedInstanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !trimmedInstanceId.isEmpty {
|
||||
self.gatewayToken = GatewaySettingsStore.loadGatewayToken(instanceId: trimmedInstanceId) ?? ""
|
||||
self.gatewayPassword = GatewaySettingsStore.loadGatewayPassword(instanceId: trimmedInstanceId) ?? ""
|
||||
}
|
||||
}
|
||||
.onChange(of: self.preferredBridgeStableID) { _, newValue in
|
||||
.onChange(of: self.preferredGatewayStableID) { _, newValue in
|
||||
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return }
|
||||
BridgeSettingsStore.savePreferredBridgeStableID(trimmed)
|
||||
GatewaySettingsStore.savePreferredGatewayStableID(trimmed)
|
||||
}
|
||||
.onChange(of: self.appModel.bridgeServerName) { _, _ in
|
||||
.onChange(of: self.gatewayToken) { _, newValue in
|
||||
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let instanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !instanceId.isEmpty else { return }
|
||||
GatewaySettingsStore.saveGatewayToken(trimmed, instanceId: instanceId)
|
||||
}
|
||||
.onChange(of: self.gatewayPassword) { _, newValue in
|
||||
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let instanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !instanceId.isEmpty else { return }
|
||||
GatewaySettingsStore.saveGatewayPassword(trimmed, instanceId: instanceId)
|
||||
}
|
||||
.onChange(of: self.appModel.gatewayServerName) { _, _ in
|
||||
self.connectStatus.text = nil
|
||||
}
|
||||
.onChange(of: self.locationEnabledModeRaw) { _, newValue in
|
||||
@@ -248,14 +276,14 @@ struct SettingsTab: View {
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func bridgeList(showing: BridgeListMode) -> some View {
|
||||
if self.bridgeController.bridges.isEmpty {
|
||||
Text("No bridges found yet.")
|
||||
private func gatewayList(showing: GatewayListMode) -> some View {
|
||||
if self.gatewayController.gateways.isEmpty {
|
||||
Text("No gateways found yet.")
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
let connectedID = self.appModel.connectedBridgeID
|
||||
let rows = self.bridgeController.bridges.filter { bridge in
|
||||
let isConnected = bridge.stableID == connectedID
|
||||
let connectedID = self.appModel.connectedGatewayID
|
||||
let rows = self.gatewayController.gateways.filter { gateway in
|
||||
let isConnected = gateway.stableID == connectedID
|
||||
switch showing {
|
||||
case .all:
|
||||
return true
|
||||
@@ -265,14 +293,14 @@ struct SettingsTab: View {
|
||||
}
|
||||
|
||||
if rows.isEmpty, showing == .availableOnly {
|
||||
Text("No other bridges found.")
|
||||
Text("No other gateways found.")
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
ForEach(rows) { bridge in
|
||||
ForEach(rows) { gateway in
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(bridge.name)
|
||||
let detailLines = self.bridgeDetailLines(bridge)
|
||||
Text(gateway.name)
|
||||
let detailLines = self.gatewayDetailLines(gateway)
|
||||
ForEach(detailLines, id: \.self) { line in
|
||||
Text(line)
|
||||
.font(.footnote)
|
||||
@@ -282,31 +310,27 @@ struct SettingsTab: View {
|
||||
Spacer()
|
||||
|
||||
Button {
|
||||
Task { await self.connect(bridge) }
|
||||
Task { await self.connect(gateway) }
|
||||
} label: {
|
||||
if self.connectingBridgeID == bridge.id {
|
||||
if self.connectingGatewayID == gateway.id {
|
||||
ProgressView()
|
||||
.progressViewStyle(.circular)
|
||||
} else {
|
||||
Text("Connect")
|
||||
}
|
||||
}
|
||||
.disabled(self.connectingBridgeID != nil)
|
||||
.disabled(self.connectingGatewayID != nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private enum BridgeListMode: Equatable {
|
||||
private enum GatewayListMode: Equatable {
|
||||
case all
|
||||
case availableOnly
|
||||
}
|
||||
|
||||
private func keychainAccount() -> String {
|
||||
"bridge-token.\(self.instanceId)"
|
||||
}
|
||||
|
||||
private func platformString() -> String {
|
||||
let v = ProcessInfo.processInfo.operatingSystemVersion
|
||||
return "iOS \(v.majorVersion).\(v.minorVersion).\(v.patchVersion)"
|
||||
@@ -341,228 +365,37 @@ struct SettingsTab: View {
|
||||
return trimmed.isEmpty ? "unknown" : trimmed
|
||||
}
|
||||
|
||||
private func currentCaps() -> [String] {
|
||||
var caps = [ClawdbotCapability.canvas.rawValue, ClawdbotCapability.screen.rawValue]
|
||||
private func connect(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) async {
|
||||
self.connectingGatewayID = gateway.id
|
||||
self.manualGatewayEnabled = false
|
||||
self.preferredGatewayStableID = gateway.stableID
|
||||
GatewaySettingsStore.savePreferredGatewayStableID(gateway.stableID)
|
||||
self.lastDiscoveredGatewayStableID = gateway.stableID
|
||||
GatewaySettingsStore.saveLastDiscoveredGatewayStableID(gateway.stableID)
|
||||
defer { self.connectingGatewayID = nil }
|
||||
|
||||
let cameraEnabled =
|
||||
UserDefaults.standard.object(forKey: "camera.enabled") == nil
|
||||
? true
|
||||
: UserDefaults.standard.bool(forKey: "camera.enabled")
|
||||
if cameraEnabled { caps.append(ClawdbotCapability.camera.rawValue) }
|
||||
|
||||
let voiceWakeEnabled = UserDefaults.standard.bool(forKey: VoiceWakePreferences.enabledKey)
|
||||
if voiceWakeEnabled { caps.append(ClawdbotCapability.voiceWake.rawValue) }
|
||||
|
||||
return caps
|
||||
}
|
||||
|
||||
private func currentCommands() -> [String] {
|
||||
var commands: [String] = [
|
||||
ClawdbotCanvasCommand.present.rawValue,
|
||||
ClawdbotCanvasCommand.hide.rawValue,
|
||||
ClawdbotCanvasCommand.navigate.rawValue,
|
||||
ClawdbotCanvasCommand.evalJS.rawValue,
|
||||
ClawdbotCanvasCommand.snapshot.rawValue,
|
||||
ClawdbotCanvasA2UICommand.push.rawValue,
|
||||
ClawdbotCanvasA2UICommand.pushJSONL.rawValue,
|
||||
ClawdbotCanvasA2UICommand.reset.rawValue,
|
||||
ClawdbotScreenCommand.record.rawValue,
|
||||
]
|
||||
|
||||
let caps = Set(self.currentCaps())
|
||||
if caps.contains(ClawdbotCapability.camera.rawValue) {
|
||||
commands.append(ClawdbotCameraCommand.list.rawValue)
|
||||
commands.append(ClawdbotCameraCommand.snap.rawValue)
|
||||
commands.append(ClawdbotCameraCommand.clip.rawValue)
|
||||
}
|
||||
|
||||
return commands
|
||||
}
|
||||
|
||||
private func connect(_ bridge: BridgeDiscoveryModel.DiscoveredBridge) async {
|
||||
self.connectingBridgeID = bridge.id
|
||||
self.manualBridgeEnabled = false
|
||||
self.preferredBridgeStableID = bridge.stableID
|
||||
BridgeSettingsStore.savePreferredBridgeStableID(bridge.stableID)
|
||||
self.lastDiscoveredBridgeStableID = bridge.stableID
|
||||
BridgeSettingsStore.saveLastDiscoveredBridgeStableID(bridge.stableID)
|
||||
defer { self.connectingBridgeID = nil }
|
||||
|
||||
do {
|
||||
let statusStore = self.connectStatus
|
||||
let existing = KeychainStore.loadString(
|
||||
service: "com.clawdbot.bridge",
|
||||
account: self.keychainAccount())
|
||||
let existingToken = (existing?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false) ?
|
||||
existing :
|
||||
nil
|
||||
|
||||
let hello = BridgeHello(
|
||||
nodeId: self.instanceId,
|
||||
displayName: self.displayName,
|
||||
token: existingToken,
|
||||
platform: self.platformString(),
|
||||
version: self.appVersion(),
|
||||
deviceFamily: self.deviceFamily(),
|
||||
modelIdentifier: self.modelIdentifier(),
|
||||
caps: self.currentCaps(),
|
||||
commands: self.currentCommands())
|
||||
let tlsParams = self.resolveDiscoveredTLSParams(bridge: bridge)
|
||||
let token = try await BridgeClient().pairAndHello(
|
||||
endpoint: bridge.endpoint,
|
||||
hello: hello,
|
||||
tls: tlsParams,
|
||||
onStatus: { status in
|
||||
Task { @MainActor in
|
||||
statusStore.text = status
|
||||
}
|
||||
})
|
||||
|
||||
if !token.isEmpty, token != existingToken {
|
||||
_ = KeychainStore.saveString(
|
||||
token,
|
||||
service: "com.clawdbot.bridge",
|
||||
account: self.keychainAccount())
|
||||
}
|
||||
|
||||
self.appModel.connectToBridge(
|
||||
endpoint: bridge.endpoint,
|
||||
bridgeStableID: bridge.stableID,
|
||||
tls: tlsParams,
|
||||
hello: BridgeHello(
|
||||
nodeId: self.instanceId,
|
||||
displayName: self.displayName,
|
||||
token: token,
|
||||
platform: self.platformString(),
|
||||
version: self.appVersion(),
|
||||
deviceFamily: self.deviceFamily(),
|
||||
modelIdentifier: self.modelIdentifier(),
|
||||
caps: self.currentCaps(),
|
||||
commands: self.currentCommands()))
|
||||
|
||||
} catch {
|
||||
self.connectStatus.text = "Failed: \(error.localizedDescription)"
|
||||
}
|
||||
await self.gatewayController.connect(gateway)
|
||||
}
|
||||
|
||||
private func connectManual() async {
|
||||
let host = self.manualBridgeHost.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let host = self.manualGatewayHost.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !host.isEmpty else {
|
||||
self.connectStatus.text = "Failed: host required"
|
||||
return
|
||||
}
|
||||
guard self.manualBridgePort > 0, self.manualBridgePort <= 65535 else {
|
||||
self.connectStatus.text = "Failed: invalid port"
|
||||
return
|
||||
}
|
||||
guard let port = NWEndpoint.Port(rawValue: UInt16(self.manualBridgePort)) else {
|
||||
guard self.manualGatewayPort > 0, self.manualGatewayPort <= 65535 else {
|
||||
self.connectStatus.text = "Failed: invalid port"
|
||||
return
|
||||
}
|
||||
|
||||
self.connectingBridgeID = "manual"
|
||||
self.manualBridgeEnabled = true
|
||||
defer { self.connectingBridgeID = nil }
|
||||
self.connectingGatewayID = "manual"
|
||||
self.manualGatewayEnabled = true
|
||||
defer { self.connectingGatewayID = nil }
|
||||
|
||||
let endpoint: NWEndpoint = .hostPort(host: NWEndpoint.Host(host), port: port)
|
||||
let stableID = BridgeEndpointID.stableID(endpoint)
|
||||
let tlsParams = self.resolveManualTLSParams(stableID: stableID)
|
||||
|
||||
do {
|
||||
let statusStore = self.connectStatus
|
||||
let existing = KeychainStore.loadString(
|
||||
service: "com.clawdbot.bridge",
|
||||
account: self.keychainAccount())
|
||||
let existingToken = (existing?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false) ?
|
||||
existing :
|
||||
nil
|
||||
|
||||
let hello = BridgeHello(
|
||||
nodeId: self.instanceId,
|
||||
displayName: self.displayName,
|
||||
token: existingToken,
|
||||
platform: self.platformString(),
|
||||
version: self.appVersion(),
|
||||
deviceFamily: self.deviceFamily(),
|
||||
modelIdentifier: self.modelIdentifier(),
|
||||
caps: self.currentCaps(),
|
||||
commands: self.currentCommands())
|
||||
let token = try await BridgeClient().pairAndHello(
|
||||
endpoint: endpoint,
|
||||
hello: hello,
|
||||
tls: tlsParams,
|
||||
onStatus: { status in
|
||||
Task { @MainActor in
|
||||
statusStore.text = status
|
||||
}
|
||||
})
|
||||
|
||||
if !token.isEmpty, token != existingToken {
|
||||
_ = KeychainStore.saveString(
|
||||
token,
|
||||
service: "com.clawdbot.bridge",
|
||||
account: self.keychainAccount())
|
||||
}
|
||||
|
||||
self.appModel.connectToBridge(
|
||||
endpoint: endpoint,
|
||||
bridgeStableID: stableID,
|
||||
tls: tlsParams,
|
||||
hello: BridgeHello(
|
||||
nodeId: self.instanceId,
|
||||
displayName: self.displayName,
|
||||
token: token,
|
||||
platform: self.platformString(),
|
||||
version: self.appVersion(),
|
||||
deviceFamily: self.deviceFamily(),
|
||||
modelIdentifier: self.modelIdentifier(),
|
||||
caps: self.currentCaps(),
|
||||
commands: self.currentCommands()))
|
||||
|
||||
} catch {
|
||||
self.connectStatus.text = "Failed: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
|
||||
private func resolveDiscoveredTLSParams(
|
||||
bridge: BridgeDiscoveryModel.DiscoveredBridge) -> BridgeTLSParams?
|
||||
{
|
||||
let stableID = bridge.stableID
|
||||
let stored = BridgeTLSStore.loadFingerprint(stableID: stableID)
|
||||
|
||||
if bridge.tlsEnabled || bridge.tlsFingerprintSha256 != nil {
|
||||
return BridgeTLSParams(
|
||||
required: true,
|
||||
expectedFingerprint: bridge.tlsFingerprintSha256 ?? stored,
|
||||
allowTOFU: stored == nil,
|
||||
storeKey: stableID)
|
||||
}
|
||||
|
||||
if let stored {
|
||||
return BridgeTLSParams(
|
||||
required: true,
|
||||
expectedFingerprint: stored,
|
||||
allowTOFU: false,
|
||||
storeKey: stableID)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private func resolveManualTLSParams(stableID: String) -> BridgeTLSParams? {
|
||||
if let stored = BridgeTLSStore.loadFingerprint(stableID: stableID) {
|
||||
return BridgeTLSParams(
|
||||
required: true,
|
||||
expectedFingerprint: stored,
|
||||
allowTOFU: false,
|
||||
storeKey: stableID)
|
||||
}
|
||||
|
||||
return BridgeTLSParams(
|
||||
required: false,
|
||||
expectedFingerprint: nil,
|
||||
allowTOFU: true,
|
||||
storeKey: stableID)
|
||||
await self.gatewayController.connectManual(
|
||||
host: host,
|
||||
port: self.manualGatewayPort,
|
||||
useTLS: self.manualGatewayTLS)
|
||||
}
|
||||
|
||||
private static func primaryIPv4Address() -> String? {
|
||||
@@ -611,23 +444,21 @@ struct SettingsTab: View {
|
||||
SettingsNetworkingHelpers.httpURLString(host: host, port: port, fallback: fallback)
|
||||
}
|
||||
|
||||
private func bridgeDetailLines(_ bridge: BridgeDiscoveryModel.DiscoveredBridge) -> [String] {
|
||||
private func gatewayDetailLines(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) -> [String] {
|
||||
var lines: [String] = []
|
||||
if let lanHost = bridge.lanHost { lines.append("LAN: \(lanHost)") }
|
||||
if let tailnet = bridge.tailnetDns { lines.append("Tailnet: \(tailnet)") }
|
||||
if let lanHost = gateway.lanHost { lines.append("LAN: \(lanHost)") }
|
||||
if let tailnet = gateway.tailnetDns { lines.append("Tailnet: \(tailnet)") }
|
||||
|
||||
let gatewayPort = bridge.gatewayPort
|
||||
let bridgePort = bridge.bridgePort
|
||||
let canvasPort = bridge.canvasPort
|
||||
if gatewayPort != nil || bridgePort != nil || canvasPort != nil {
|
||||
let gatewayPort = gateway.gatewayPort
|
||||
let canvasPort = gateway.canvasPort
|
||||
if gatewayPort != nil || canvasPort != nil {
|
||||
let gw = gatewayPort.map(String.init) ?? "—"
|
||||
let br = bridgePort.map(String.init) ?? "—"
|
||||
let canvas = canvasPort.map(String.init) ?? "—"
|
||||
lines.append("Ports: gw \(gw) · bridge \(br) · canvas \(canvas)")
|
||||
lines.append("Ports: gateway \(gw) · canvas \(canvas)")
|
||||
}
|
||||
|
||||
if lines.isEmpty {
|
||||
lines.append(bridge.debugID)
|
||||
lines.append(gateway.debugID)
|
||||
}
|
||||
|
||||
return lines
|
||||
|
||||
@@ -42,7 +42,7 @@ struct VoiceWakeWordsSettingsView: View {
|
||||
}
|
||||
}
|
||||
.onChange(of: self.triggerWords) { _, newValue in
|
||||
// Keep local voice wake responsive even if bridge isn't connected yet.
|
||||
// Keep local voice wake responsive even if the gateway isn't connected yet.
|
||||
VoiceWakePreferences.saveTriggerWords(newValue)
|
||||
|
||||
let snapshot = VoiceWakePreferences.sanitizeTriggerWords(newValue)
|
||||
|
||||
@@ -3,7 +3,7 @@ import SwiftUI
|
||||
struct StatusPill: View {
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
|
||||
enum BridgeState: Equatable {
|
||||
enum GatewayState: Equatable {
|
||||
case connected
|
||||
case connecting
|
||||
case error
|
||||
@@ -34,7 +34,7 @@ struct StatusPill: View {
|
||||
var tint: Color?
|
||||
}
|
||||
|
||||
var bridge: BridgeState
|
||||
var gateway: GatewayState
|
||||
var voiceWakeEnabled: Bool
|
||||
var activity: Activity?
|
||||
var brighten: Bool = false
|
||||
@@ -47,12 +47,12 @@ struct StatusPill: View {
|
||||
HStack(spacing: 10) {
|
||||
HStack(spacing: 8) {
|
||||
Circle()
|
||||
.fill(self.bridge.color)
|
||||
.fill(self.gateway.color)
|
||||
.frame(width: 9, height: 9)
|
||||
.scaleEffect(self.bridge == .connecting ? (self.pulse ? 1.15 : 0.85) : 1.0)
|
||||
.opacity(self.bridge == .connecting ? (self.pulse ? 1.0 : 0.6) : 1.0)
|
||||
.scaleEffect(self.gateway == .connecting ? (self.pulse ? 1.15 : 0.85) : 1.0)
|
||||
.opacity(self.gateway == .connecting ? (self.pulse ? 1.0 : 0.6) : 1.0)
|
||||
|
||||
Text(self.bridge.title)
|
||||
Text(self.gateway.title)
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundStyle(.primary)
|
||||
}
|
||||
@@ -95,26 +95,26 @@ struct StatusPill: View {
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("Status")
|
||||
.accessibilityValue(self.accessibilityValue)
|
||||
.onAppear { self.updatePulse(for: self.bridge, scenePhase: self.scenePhase) }
|
||||
.onAppear { self.updatePulse(for: self.gateway, scenePhase: self.scenePhase) }
|
||||
.onDisappear { self.pulse = false }
|
||||
.onChange(of: self.bridge) { _, newValue in
|
||||
.onChange(of: self.gateway) { _, newValue in
|
||||
self.updatePulse(for: newValue, scenePhase: self.scenePhase)
|
||||
}
|
||||
.onChange(of: self.scenePhase) { _, newValue in
|
||||
self.updatePulse(for: self.bridge, scenePhase: newValue)
|
||||
self.updatePulse(for: self.gateway, scenePhase: newValue)
|
||||
}
|
||||
.animation(.easeInOut(duration: 0.18), value: self.activity?.title)
|
||||
}
|
||||
|
||||
private var accessibilityValue: String {
|
||||
if let activity {
|
||||
return "\(self.bridge.title), \(activity.title)"
|
||||
return "\(self.gateway.title), \(activity.title)"
|
||||
}
|
||||
return "\(self.bridge.title), Voice Wake \(self.voiceWakeEnabled ? "enabled" : "disabled")"
|
||||
return "\(self.gateway.title), Voice Wake \(self.voiceWakeEnabled ? "enabled" : "disabled")"
|
||||
}
|
||||
|
||||
private func updatePulse(for bridge: BridgeState, scenePhase: ScenePhase) {
|
||||
guard bridge == .connecting, scenePhase == .active else {
|
||||
private func updatePulse(for gateway: GatewayState, scenePhase: ScenePhase) {
|
||||
guard gateway == .connecting, scenePhase == .active else {
|
||||
withAnimation(.easeOut(duration: 0.2)) { self.pulse = false }
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import AVFAudio
|
||||
import ClawdbotKit
|
||||
import ClawdbotProtocol
|
||||
import Foundation
|
||||
import Observation
|
||||
import OSLog
|
||||
@@ -42,15 +43,15 @@ final class TalkModeManager: NSObject {
|
||||
var pcmPlayer: PCMStreamingAudioPlaying = PCMStreamingAudioPlayer.shared
|
||||
var mp3Player: StreamingAudioPlaying = StreamingAudioPlayer.shared
|
||||
|
||||
private var bridge: BridgeSession?
|
||||
private var gateway: GatewayNodeSession?
|
||||
private let silenceWindow: TimeInterval = 0.7
|
||||
|
||||
private var chatSubscribedSessionKeys = Set<String>()
|
||||
|
||||
private let logger = Logger(subsystem: "com.clawdbot", category: "TalkMode")
|
||||
|
||||
func attachBridge(_ bridge: BridgeSession) {
|
||||
self.bridge = bridge
|
||||
func attachGateway(_ gateway: GatewayNodeSession) {
|
||||
self.gateway = gateway
|
||||
}
|
||||
|
||||
func updateMainSessionKey(_ sessionKey: String?) {
|
||||
@@ -232,9 +233,9 @@ final class TalkModeManager: NSObject {
|
||||
|
||||
await self.reloadConfig()
|
||||
let prompt = self.buildPrompt(transcript: transcript)
|
||||
guard let bridge else {
|
||||
self.statusText = "Bridge not connected"
|
||||
self.logger.warning("finalize: bridge not connected")
|
||||
guard let gateway else {
|
||||
self.statusText = "Gateway not connected"
|
||||
self.logger.warning("finalize: gateway not connected")
|
||||
await self.start()
|
||||
return
|
||||
}
|
||||
@@ -245,9 +246,9 @@ final class TalkModeManager: NSObject {
|
||||
await self.subscribeChatIfNeeded(sessionKey: sessionKey)
|
||||
self.logger.info(
|
||||
"chat.send start sessionKey=\(sessionKey, privacy: .public) chars=\(prompt.count, privacy: .public)")
|
||||
let runId = try await self.sendChat(prompt, bridge: bridge)
|
||||
let runId = try await self.sendChat(prompt, gateway: gateway)
|
||||
self.logger.info("chat.send ok runId=\(runId, privacy: .public)")
|
||||
let completion = await self.waitForChatCompletion(runId: runId, bridge: bridge, timeoutSeconds: 120)
|
||||
let completion = await self.waitForChatCompletion(runId: runId, gateway: gateway, timeoutSeconds: 120)
|
||||
if completion == .timeout {
|
||||
self.logger.warning(
|
||||
"chat completion timeout runId=\(runId, privacy: .public); attempting history fallback")
|
||||
@@ -264,7 +265,7 @@ final class TalkModeManager: NSObject {
|
||||
}
|
||||
|
||||
guard let assistantText = try await self.waitForAssistantText(
|
||||
bridge: bridge,
|
||||
gateway: gateway,
|
||||
since: startedAt,
|
||||
timeoutSeconds: completion == .final ? 12 : 25)
|
||||
else {
|
||||
@@ -286,31 +287,22 @@ final class TalkModeManager: NSObject {
|
||||
private func subscribeChatIfNeeded(sessionKey: String) async {
|
||||
let key = sessionKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !key.isEmpty else { return }
|
||||
guard let bridge else { return }
|
||||
guard let gateway else { return }
|
||||
guard !self.chatSubscribedSessionKeys.contains(key) else { return }
|
||||
|
||||
do {
|
||||
let payload = "{\"sessionKey\":\"\(key)\"}"
|
||||
try await bridge.sendEvent(event: "chat.subscribe", payloadJSON: payload)
|
||||
self.chatSubscribedSessionKeys.insert(key)
|
||||
self.logger.info("chat.subscribe ok sessionKey=\(key, privacy: .public)")
|
||||
} catch {
|
||||
let err = error.localizedDescription
|
||||
self.logger.warning("chat.subscribe failed key=\(key, privacy: .public) err=\(err, privacy: .public)")
|
||||
}
|
||||
let payload = "{\"sessionKey\":\"\(key)\"}"
|
||||
await gateway.sendEvent(event: "chat.subscribe", payloadJSON: payload)
|
||||
self.chatSubscribedSessionKeys.insert(key)
|
||||
self.logger.info("chat.subscribe ok sessionKey=\(key, privacy: .public)")
|
||||
}
|
||||
|
||||
private func unsubscribeAllChats() async {
|
||||
guard let bridge else { return }
|
||||
guard let gateway else { return }
|
||||
let keys = self.chatSubscribedSessionKeys
|
||||
self.chatSubscribedSessionKeys.removeAll()
|
||||
for key in keys {
|
||||
do {
|
||||
let payload = "{\"sessionKey\":\"\(key)\"}"
|
||||
try await bridge.sendEvent(event: "chat.unsubscribe", payloadJSON: payload)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
let payload = "{\"sessionKey\":\"\(key)\"}"
|
||||
await gateway.sendEvent(event: "chat.unsubscribe", payloadJSON: payload)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -336,7 +328,7 @@ final class TalkModeManager: NSObject {
|
||||
}
|
||||
}
|
||||
|
||||
private func sendChat(_ message: String, bridge: BridgeSession) async throws -> String {
|
||||
private func sendChat(_ message: String, gateway: GatewayNodeSession) async throws -> String {
|
||||
struct SendResponse: Decodable { let runId: String }
|
||||
let payload: [String: Any] = [
|
||||
"sessionKey": self.mainSessionKey,
|
||||
@@ -352,26 +344,27 @@ final class TalkModeManager: NSObject {
|
||||
code: 1,
|
||||
userInfo: [NSLocalizedDescriptionKey: "Failed to encode chat payload"])
|
||||
}
|
||||
let res = try await bridge.request(method: "chat.send", paramsJSON: json, timeoutSeconds: 30)
|
||||
let res = try await gateway.request(method: "chat.send", paramsJSON: json, timeoutSeconds: 30)
|
||||
let decoded = try JSONDecoder().decode(SendResponse.self, from: res)
|
||||
return decoded.runId
|
||||
}
|
||||
|
||||
private func waitForChatCompletion(
|
||||
runId: String,
|
||||
bridge: BridgeSession,
|
||||
gateway: GatewayNodeSession,
|
||||
timeoutSeconds: Int = 120) async -> ChatCompletionState
|
||||
{
|
||||
let stream = await bridge.subscribeServerEvents(bufferingNewest: 200)
|
||||
let stream = await gateway.subscribeServerEvents(bufferingNewest: 200)
|
||||
return await withTaskGroup(of: ChatCompletionState.self) { group in
|
||||
group.addTask { [runId] in
|
||||
for await evt in stream {
|
||||
if Task.isCancelled { return .timeout }
|
||||
guard evt.event == "chat", let payload = evt.payloadJSON else { continue }
|
||||
guard let data = payload.data(using: .utf8) else { continue }
|
||||
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { continue }
|
||||
if (json["runId"] as? String) != runId { continue }
|
||||
if let state = json["state"] as? String {
|
||||
guard evt.event == "chat", let payload = evt.payload else { continue }
|
||||
guard let chatEvent = try? GatewayPayloadDecoding.decode(payload, as: ChatEvent.self) else {
|
||||
continue
|
||||
}
|
||||
guard chatEvent.runid == runId else { continue }
|
||||
if let state = chatEvent.state.value as? String {
|
||||
switch state {
|
||||
case "final": return .final
|
||||
case "aborted": return .aborted
|
||||
@@ -393,13 +386,13 @@ final class TalkModeManager: NSObject {
|
||||
}
|
||||
|
||||
private func waitForAssistantText(
|
||||
bridge: BridgeSession,
|
||||
gateway: GatewayNodeSession,
|
||||
since: Double,
|
||||
timeoutSeconds: Int) async throws -> String?
|
||||
{
|
||||
let deadline = Date().addingTimeInterval(TimeInterval(timeoutSeconds))
|
||||
while Date() < deadline {
|
||||
if let text = try await self.fetchLatestAssistantText(bridge: bridge, since: since) {
|
||||
if let text = try await self.fetchLatestAssistantText(gateway: gateway, since: since) {
|
||||
return text
|
||||
}
|
||||
try? await Task.sleep(nanoseconds: 300_000_000)
|
||||
@@ -407,8 +400,8 @@ final class TalkModeManager: NSObject {
|
||||
return nil
|
||||
}
|
||||
|
||||
private func fetchLatestAssistantText(bridge: BridgeSession, since: Double? = nil) async throws -> String? {
|
||||
let res = try await bridge.request(
|
||||
private func fetchLatestAssistantText(gateway: GatewayNodeSession, since: Double? = nil) async throws -> String? {
|
||||
let res = try await gateway.request(
|
||||
method: "chat.history",
|
||||
paramsJSON: "{\"sessionKey\":\"\(self.mainSessionKey)\"}",
|
||||
timeoutSeconds: 15)
|
||||
@@ -649,9 +642,9 @@ final class TalkModeManager: NSObject {
|
||||
}
|
||||
|
||||
private func reloadConfig() async {
|
||||
guard let bridge else { return }
|
||||
guard let gateway else { return }
|
||||
do {
|
||||
let res = try await bridge.request(method: "config.get", paramsJSON: "{}", timeoutSeconds: 8)
|
||||
let res = try await gateway.request(method: "config.get", paramsJSON: "{}", timeoutSeconds: 8)
|
||||
guard let json = try JSONSerialization.jsonObject(with: res) as? [String: Any] else { return }
|
||||
guard let config = json["config"] as? [String: Any] else { return }
|
||||
let talk = config["talk"] as? [String: Any]
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
79
apps/ios/Tests/GatewayConnectionControllerTests.swift
Normal file
79
apps/ios/Tests/GatewayConnectionControllerTests.swift
Normal 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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import Testing
|
||||
@testable import Clawdbot
|
||||
|
||||
@Suite(.serialized) struct BridgeDiscoveryModelTests {
|
||||
@Suite(.serialized) struct GatewayDiscoveryModelTests {
|
||||
@Test @MainActor func debugLoggingCapturesLifecycleAndResets() {
|
||||
let model = BridgeDiscoveryModel()
|
||||
let model = GatewayDiscoveryModel()
|
||||
|
||||
#expect(model.debugLog.isEmpty)
|
||||
#expect(model.statusText == "Idle")
|
||||
@@ -13,7 +13,7 @@ import Testing
|
||||
|
||||
model.stop()
|
||||
#expect(model.statusText == "Stopped")
|
||||
#expect(model.bridges.isEmpty)
|
||||
#expect(model.gateways.isEmpty)
|
||||
#expect(model.debugLog.count >= 3)
|
||||
|
||||
model.setDebugLoggingEnabled(false)
|
||||
@@ -3,30 +3,30 @@ import Network
|
||||
import Testing
|
||||
@testable import Clawdbot
|
||||
|
||||
@Suite struct BridgeEndpointIDTests {
|
||||
@Suite struct GatewayEndpointIDTests {
|
||||
@Test func stableIDForServiceDecodesAndNormalizesName() {
|
||||
let endpoint = NWEndpoint.service(
|
||||
name: "Clawdbot\\032Bridge \\032 Node\n",
|
||||
type: "_clawdbot-bridge._tcp",
|
||||
name: "Clawdbot\\032Gateway \\032 Node\n",
|
||||
type: "_clawdbot-gateway._tcp",
|
||||
domain: "local.",
|
||||
interface: nil)
|
||||
|
||||
#expect(BridgeEndpointID.stableID(endpoint) == "_clawdbot-bridge._tcp|local.|Clawdbot Bridge Node")
|
||||
#expect(GatewayEndpointID.stableID(endpoint) == "_clawdbot-gateway._tcp|local.|Clawdbot Gateway Node")
|
||||
}
|
||||
|
||||
@Test func stableIDForNonServiceUsesEndpointDescription() {
|
||||
let endpoint = NWEndpoint.hostPort(host: NWEndpoint.Host("127.0.0.1"), port: 4242)
|
||||
#expect(BridgeEndpointID.stableID(endpoint) == String(describing: endpoint))
|
||||
#expect(GatewayEndpointID.stableID(endpoint) == String(describing: endpoint))
|
||||
}
|
||||
|
||||
@Test func prettyDescriptionDecodesBonjourEscapes() {
|
||||
let endpoint = NWEndpoint.service(
|
||||
name: "Clawdbot\\032Bridge",
|
||||
type: "_clawdbot-bridge._tcp",
|
||||
name: "Clawdbot\\032Gateway",
|
||||
type: "_clawdbot-gateway._tcp",
|
||||
domain: "local.",
|
||||
interface: nil)
|
||||
|
||||
let pretty = BridgeEndpointID.prettyDescription(endpoint)
|
||||
let pretty = GatewayEndpointID.prettyDescription(endpoint)
|
||||
#expect(pretty == BonjourEscapes.decode(String(describing: endpoint)))
|
||||
#expect(!pretty.localizedCaseInsensitiveContains("\\032"))
|
||||
}
|
||||
@@ -7,11 +7,11 @@ private struct KeychainEntry: Hashable {
|
||||
let account: String
|
||||
}
|
||||
|
||||
private let bridgeService = "com.clawdbot.bridge"
|
||||
private let gatewayService = "com.clawdbot.gateway"
|
||||
private let nodeService = "com.clawdbot.node"
|
||||
private let instanceIdEntry = KeychainEntry(service: nodeService, account: "instanceId")
|
||||
private let preferredBridgeEntry = KeychainEntry(service: bridgeService, account: "preferredStableID")
|
||||
private let lastBridgeEntry = KeychainEntry(service: bridgeService, account: "lastDiscoveredStableID")
|
||||
private let preferredGatewayEntry = KeychainEntry(service: gatewayService, account: "preferredStableID")
|
||||
private let lastGatewayEntry = KeychainEntry(service: gatewayService, account: "lastDiscoveredStableID")
|
||||
|
||||
private func snapshotDefaults(_ keys: [String]) -> [String: Any?] {
|
||||
let defaults = UserDefaults.standard
|
||||
@@ -59,14 +59,14 @@ private func restoreKeychain(_ snapshot: [KeychainEntry: String?]) {
|
||||
applyKeychain(snapshot)
|
||||
}
|
||||
|
||||
@Suite(.serialized) struct BridgeSettingsStoreTests {
|
||||
@Suite(.serialized) struct GatewaySettingsStoreTests {
|
||||
@Test func bootstrapCopiesDefaultsToKeychainWhenMissing() {
|
||||
let defaultsKeys = [
|
||||
"node.instanceId",
|
||||
"bridge.preferredStableID",
|
||||
"bridge.lastDiscoveredStableID",
|
||||
"gateway.preferredStableID",
|
||||
"gateway.lastDiscoveredStableID",
|
||||
]
|
||||
let entries = [instanceIdEntry, preferredBridgeEntry, lastBridgeEntry]
|
||||
let entries = [instanceIdEntry, preferredGatewayEntry, lastGatewayEntry]
|
||||
let defaultsSnapshot = snapshotDefaults(defaultsKeys)
|
||||
let keychainSnapshot = snapshotKeychain(entries)
|
||||
defer {
|
||||
@@ -76,29 +76,29 @@ private func restoreKeychain(_ snapshot: [KeychainEntry: String?]) {
|
||||
|
||||
applyDefaults([
|
||||
"node.instanceId": "node-test",
|
||||
"bridge.preferredStableID": "preferred-test",
|
||||
"bridge.lastDiscoveredStableID": "last-test",
|
||||
"gateway.preferredStableID": "preferred-test",
|
||||
"gateway.lastDiscoveredStableID": "last-test",
|
||||
])
|
||||
applyKeychain([
|
||||
instanceIdEntry: nil,
|
||||
preferredBridgeEntry: nil,
|
||||
lastBridgeEntry: nil,
|
||||
preferredGatewayEntry: nil,
|
||||
lastGatewayEntry: nil,
|
||||
])
|
||||
|
||||
BridgeSettingsStore.bootstrapPersistence()
|
||||
GatewaySettingsStore.bootstrapPersistence()
|
||||
|
||||
#expect(KeychainStore.loadString(service: nodeService, account: "instanceId") == "node-test")
|
||||
#expect(KeychainStore.loadString(service: bridgeService, account: "preferredStableID") == "preferred-test")
|
||||
#expect(KeychainStore.loadString(service: bridgeService, account: "lastDiscoveredStableID") == "last-test")
|
||||
#expect(KeychainStore.loadString(service: gatewayService, account: "preferredStableID") == "preferred-test")
|
||||
#expect(KeychainStore.loadString(service: gatewayService, account: "lastDiscoveredStableID") == "last-test")
|
||||
}
|
||||
|
||||
@Test func bootstrapCopiesKeychainToDefaultsWhenMissing() {
|
||||
let defaultsKeys = [
|
||||
"node.instanceId",
|
||||
"bridge.preferredStableID",
|
||||
"bridge.lastDiscoveredStableID",
|
||||
"gateway.preferredStableID",
|
||||
"gateway.lastDiscoveredStableID",
|
||||
]
|
||||
let entries = [instanceIdEntry, preferredBridgeEntry, lastBridgeEntry]
|
||||
let entries = [instanceIdEntry, preferredGatewayEntry, lastGatewayEntry]
|
||||
let defaultsSnapshot = snapshotDefaults(defaultsKeys)
|
||||
let keychainSnapshot = snapshotKeychain(entries)
|
||||
defer {
|
||||
@@ -108,20 +108,20 @@ private func restoreKeychain(_ snapshot: [KeychainEntry: String?]) {
|
||||
|
||||
applyDefaults([
|
||||
"node.instanceId": nil,
|
||||
"bridge.preferredStableID": nil,
|
||||
"bridge.lastDiscoveredStableID": nil,
|
||||
"gateway.preferredStableID": nil,
|
||||
"gateway.lastDiscoveredStableID": nil,
|
||||
])
|
||||
applyKeychain([
|
||||
instanceIdEntry: "node-from-keychain",
|
||||
preferredBridgeEntry: "preferred-from-keychain",
|
||||
lastBridgeEntry: "last-from-keychain",
|
||||
preferredGatewayEntry: "preferred-from-keychain",
|
||||
lastGatewayEntry: "last-from-keychain",
|
||||
])
|
||||
|
||||
BridgeSettingsStore.bootstrapPersistence()
|
||||
GatewaySettingsStore.bootstrapPersistence()
|
||||
|
||||
let defaults = UserDefaults.standard
|
||||
#expect(defaults.string(forKey: "node.instanceId") == "node-from-keychain")
|
||||
#expect(defaults.string(forKey: "bridge.preferredStableID") == "preferred-from-keychain")
|
||||
#expect(defaults.string(forKey: "bridge.lastDiscoveredStableID") == "last-from-keychain")
|
||||
#expect(defaults.string(forKey: "gateway.preferredStableID") == "preferred-from-keychain")
|
||||
#expect(defaults.string(forKey: "gateway.lastDiscoveredStableID") == "last-from-keychain")
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,15 @@
|
||||
import ClawdbotKit
|
||||
import Testing
|
||||
@testable import Clawdbot
|
||||
|
||||
@Suite struct IOSBridgeChatTransportTests {
|
||||
@Test func requestsFailFastWhenBridgeNotConnected() async {
|
||||
let bridge = BridgeSession()
|
||||
let transport = IOSBridgeChatTransport(bridge: bridge)
|
||||
|
||||
do {
|
||||
try await transport.setActiveSessionKey("node-test")
|
||||
Issue.record("Expected setActiveSessionKey to throw when bridge not connected")
|
||||
} catch {}
|
||||
@Suite struct IOSGatewayChatTransportTests {
|
||||
@Test func requestsFailFastWhenGatewayNotConnected() async {
|
||||
let gateway = GatewayNodeSession()
|
||||
let transport = IOSGatewayChatTransport(gateway: gateway)
|
||||
|
||||
do {
|
||||
_ = try await transport.requestHistory(sessionKey: "node-test")
|
||||
Issue.record("Expected requestHistory to throw when bridge not connected")
|
||||
Issue.record("Expected requestHistory to throw when gateway not connected")
|
||||
} catch {}
|
||||
|
||||
do {
|
||||
@@ -23,11 +19,12 @@ import Testing
|
||||
thinking: "low",
|
||||
idempotencyKey: "idempotency",
|
||||
attachments: [])
|
||||
Issue.record("Expected sendMessage to throw when bridge not connected")
|
||||
Issue.record("Expected sendMessage to throw when gateway not connected")
|
||||
} catch {}
|
||||
|
||||
do {
|
||||
_ = try await transport.requestHealth(timeoutMs: 250)
|
||||
Issue.record("Expected requestHealth to throw when gateway not connected")
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
@@ -159,7 +159,7 @@ private func withUserDefaults<T>(_ updates: [String: Any?], _ body: () throws ->
|
||||
let appModel = NodeAppModel()
|
||||
let url = URL(string: "clawdbot://agent?message=hello")!
|
||||
await appModel.handleDeepLink(url: url)
|
||||
#expect(appModel.screen.errorText?.contains("Bridge not connected") == true)
|
||||
#expect(appModel.screen.errorText?.contains("Gateway not connected") == true)
|
||||
}
|
||||
|
||||
@Test @MainActor func handleDeepLinkRejectsOversizedMessage() async {
|
||||
@@ -170,7 +170,7 @@ private func withUserDefaults<T>(_ updates: [String: Any?], _ body: () throws ->
|
||||
#expect(appModel.screen.errorText?.contains("Deep link too large") == true)
|
||||
}
|
||||
|
||||
@Test @MainActor func sendVoiceTranscriptThrowsWhenBridgeOffline() async {
|
||||
@Test @MainActor func sendVoiceTranscriptThrowsWhenGatewayOffline() async {
|
||||
let appModel = NodeAppModel()
|
||||
await #expect(throws: Error.self) {
|
||||
try await appModel.sendVoiceTranscript(text: "hello", sessionKey: "main")
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import ClawdbotKit
|
||||
import SwiftUI
|
||||
import Testing
|
||||
import UIKit
|
||||
@@ -14,35 +15,35 @@ import UIKit
|
||||
}
|
||||
|
||||
@Test @MainActor func statusPillConnectingBuildsAViewHierarchy() {
|
||||
let root = StatusPill(bridge: .connecting, voiceWakeEnabled: true, brighten: true) {}
|
||||
let root = StatusPill(gateway: .connecting, voiceWakeEnabled: true, brighten: true) {}
|
||||
_ = Self.host(root)
|
||||
}
|
||||
|
||||
@Test @MainActor func statusPillDisconnectedBuildsAViewHierarchy() {
|
||||
let root = StatusPill(bridge: .disconnected, voiceWakeEnabled: false) {}
|
||||
let root = StatusPill(gateway: .disconnected, voiceWakeEnabled: false) {}
|
||||
_ = Self.host(root)
|
||||
}
|
||||
|
||||
@Test @MainActor func settingsTabBuildsAViewHierarchy() {
|
||||
let appModel = NodeAppModel()
|
||||
let bridgeController = BridgeConnectionController(appModel: appModel, startDiscovery: false)
|
||||
let gatewayController = GatewayConnectionController(appModel: appModel, startDiscovery: false)
|
||||
|
||||
let root = SettingsTab()
|
||||
.environment(appModel)
|
||||
.environment(appModel.voiceWake)
|
||||
.environment(bridgeController)
|
||||
.environment(gatewayController)
|
||||
|
||||
_ = Self.host(root)
|
||||
}
|
||||
|
||||
@Test @MainActor func rootTabsBuildAViewHierarchy() {
|
||||
let appModel = NodeAppModel()
|
||||
let bridgeController = BridgeConnectionController(appModel: appModel, startDiscovery: false)
|
||||
let gatewayController = GatewayConnectionController(appModel: appModel, startDiscovery: false)
|
||||
|
||||
let root = RootTabs()
|
||||
.environment(appModel)
|
||||
.environment(appModel.voiceWake)
|
||||
.environment(bridgeController)
|
||||
.environment(gatewayController)
|
||||
|
||||
_ = Self.host(root)
|
||||
}
|
||||
@@ -66,8 +67,8 @@ import UIKit
|
||||
|
||||
@Test @MainActor func chatSheetBuildsAViewHierarchy() {
|
||||
let appModel = NodeAppModel()
|
||||
let bridge = BridgeSession()
|
||||
let root = ChatSheet(bridge: bridge, sessionKey: "test")
|
||||
let gateway = GatewayNodeSession()
|
||||
let root = ChatSheet(gateway: gateway, sessionKey: "test")
|
||||
.environment(appModel)
|
||||
.environment(appModel.voiceWake)
|
||||
_ = Self.host(root)
|
||||
|
||||
@@ -35,6 +35,8 @@ targets:
|
||||
- package: ClawdbotKit
|
||||
- package: ClawdbotKit
|
||||
product: ClawdbotChatUI
|
||||
- package: ClawdbotKit
|
||||
product: ClawdbotProtocol
|
||||
- package: Swabble
|
||||
product: SwabbleKit
|
||||
- sdk: AppIntents.framework
|
||||
@@ -86,12 +88,12 @@ targets:
|
||||
UIApplicationSupportsMultipleScenes: false
|
||||
UIBackgroundModes:
|
||||
- audio
|
||||
NSLocalNetworkUsageDescription: Clawdbot discovers and connects to your Clawdbot bridge on the local network.
|
||||
NSLocalNetworkUsageDescription: Clawdbot discovers and connects to your Clawdbot gateway on the local network.
|
||||
NSAppTransportSecurity:
|
||||
NSAllowsArbitraryLoadsInWebContent: true
|
||||
NSBonjourServices:
|
||||
- _clawdbot-bridge._tcp
|
||||
NSCameraUsageDescription: Clawdbot can capture photos or short video clips when requested via the bridge.
|
||||
- _clawdbot-gateway._tcp
|
||||
NSCameraUsageDescription: Clawdbot can capture photos or short video clips when requested via the gateway.
|
||||
NSLocationWhenInUseUsageDescription: Clawdbot uses your location when you allow location sharing.
|
||||
NSLocationAlwaysAndWhenInUseUsageDescription: Clawdbot can share your location in the background when you enable Always.
|
||||
NSMicrophoneUsageDescription: Clawdbot needs microphone access for voice wake.
|
||||
|
||||
@@ -25,13 +25,6 @@ let package = Package(
|
||||
.package(path: "../../Swabble"),
|
||||
],
|
||||
targets: [
|
||||
.target(
|
||||
name: "ClawdbotProtocol",
|
||||
dependencies: [],
|
||||
path: "Sources/ClawdbotProtocol",
|
||||
swiftSettings: [
|
||||
.enableUpcomingFeature("StrictConcurrency"),
|
||||
]),
|
||||
.target(
|
||||
name: "ClawdbotIPC",
|
||||
dependencies: [],
|
||||
@@ -52,9 +45,9 @@ let package = Package(
|
||||
dependencies: [
|
||||
"ClawdbotIPC",
|
||||
"ClawdbotDiscovery",
|
||||
"ClawdbotProtocol",
|
||||
.product(name: "ClawdbotKit", package: "ClawdbotKit"),
|
||||
.product(name: "ClawdbotChatUI", package: "ClawdbotKit"),
|
||||
.product(name: "ClawdbotProtocol", package: "ClawdbotKit"),
|
||||
.product(name: "SwabbleKit", package: "swabble"),
|
||||
.product(name: "MenuBarExtraAccess", package: "MenuBarExtraAccess"),
|
||||
.product(name: "Subprocess", package: "swift-subprocess"),
|
||||
@@ -85,7 +78,7 @@ let package = Package(
|
||||
.executableTarget(
|
||||
name: "ClawdbotWizardCLI",
|
||||
dependencies: [
|
||||
"ClawdbotProtocol",
|
||||
.product(name: "ClawdbotProtocol", package: "ClawdbotKit"),
|
||||
],
|
||||
path: "Sources/ClawdbotWizardCLI",
|
||||
swiftSettings: [
|
||||
@@ -97,7 +90,7 @@ let package = Package(
|
||||
"ClawdbotIPC",
|
||||
"Clawdbot",
|
||||
"ClawdbotDiscovery",
|
||||
"ClawdbotProtocol",
|
||||
.product(name: "ClawdbotProtocol", package: "ClawdbotKit"),
|
||||
.product(name: "SwabbleKit", package: "swabble"),
|
||||
],
|
||||
swiftSettings: [
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import AppKit
|
||||
import ClawdbotIPC
|
||||
import ClawdbotKit
|
||||
import Foundation
|
||||
import OSLog
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import ClawdbotKit
|
||||
import ClawdbotProtocol
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import ClawdbotKit
|
||||
import ClawdbotProtocol
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import AppKit
|
||||
import ClawdbotKit
|
||||
import ClawdbotProtocol
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import ClawdbotKit
|
||||
import ClawdbotProtocol
|
||||
import Foundation
|
||||
import OSLog
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import ClawdbotChatUI
|
||||
import ClawdbotKit
|
||||
import ClawdbotProtocol
|
||||
import Foundation
|
||||
import OSLog
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}()
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import ClawdbotKit
|
||||
import ClawdbotProtocol
|
||||
import Cocoa
|
||||
import Foundation
|
||||
|
||||
@@ -9,7 +9,7 @@ final class MacNodeModeCoordinator {
|
||||
private let logger = Logger(subsystem: "com.clawdbot", category: "mac-node")
|
||||
private var task: Task<Void, Never>?
|
||||
private let runtime = MacNodeRuntime()
|
||||
private let session = MacNodeGatewaySession()
|
||||
private let session = GatewayNodeSession()
|
||||
|
||||
func start() {
|
||||
guard self.task == nil else { return }
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import AppKit
|
||||
import ClawdbotDiscovery
|
||||
import ClawdbotIPC
|
||||
import ClawdbotKit
|
||||
import ClawdbotProtocol
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import ClawdbotKit
|
||||
import ClawdbotProtocol
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import ClawdbotKit
|
||||
import Foundation
|
||||
import OSLog
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import AppKit
|
||||
import ClawdbotChatUI
|
||||
import ClawdbotKit
|
||||
import ClawdbotProtocol
|
||||
import Foundation
|
||||
import OSLog
|
||||
|
||||
@@ -9,6 +9,7 @@ let package = Package(
|
||||
.macOS(.v15),
|
||||
],
|
||||
products: [
|
||||
.library(name: "ClawdbotProtocol", targets: ["ClawdbotProtocol"]),
|
||||
.library(name: "ClawdbotKit", targets: ["ClawdbotKit"]),
|
||||
.library(name: "ClawdbotChatUI", targets: ["ClawdbotChatUI"]),
|
||||
],
|
||||
@@ -17,9 +18,15 @@ let package = Package(
|
||||
.package(url: "https://github.com/gonzalezreal/textual", exact: "0.2.0"),
|
||||
],
|
||||
targets: [
|
||||
.target(
|
||||
name: "ClawdbotProtocol",
|
||||
swiftSettings: [
|
||||
.enableUpcomingFeature("StrictConcurrency"),
|
||||
]),
|
||||
.target(
|
||||
name: "ClawdbotKit",
|
||||
dependencies: [
|
||||
"ClawdbotProtocol",
|
||||
.product(name: "ElevenLabsKit", package: "ElevenLabsKit"),
|
||||
],
|
||||
resources: [
|
||||
|
||||
@@ -8,6 +8,25 @@ struct DeviceIdentity: Codable, Sendable {
|
||||
var createdAtMs: Int
|
||||
}
|
||||
|
||||
enum DeviceIdentityPaths {
|
||||
private static let stateDirEnv = "CLAWDBOT_STATE_DIR"
|
||||
|
||||
static func stateDirURL() -> URL {
|
||||
if let raw = getenv(self.stateDirEnv) {
|
||||
let value = String(cString: raw).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !value.isEmpty {
|
||||
return URL(fileURLWithPath: value, isDirectory: true)
|
||||
}
|
||||
}
|
||||
|
||||
if let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first {
|
||||
return appSupport.appendingPathComponent("clawdbot", isDirectory: true)
|
||||
}
|
||||
|
||||
return FileManager.default.temporaryDirectory.appendingPathComponent("clawdbot", isDirectory: true)
|
||||
}
|
||||
}
|
||||
|
||||
enum DeviceIdentityStore {
|
||||
private static let fileName = "device.json"
|
||||
|
||||
@@ -76,7 +95,7 @@ enum DeviceIdentityStore {
|
||||
}
|
||||
|
||||
private static func fileURL() -> URL {
|
||||
let base = ClawdbotPaths.stateDirURL
|
||||
let base = DeviceIdentityPaths.stateDirURL()
|
||||
return base
|
||||
.appendingPathComponent("identity", isDirectory: true)
|
||||
.appendingPathComponent(fileName, isDirectory: false)
|
||||
@@ -1,9 +1,8 @@
|
||||
import ClawdbotKit
|
||||
import ClawdbotProtocol
|
||||
import Foundation
|
||||
import OSLog
|
||||
|
||||
protocol WebSocketTasking: AnyObject {
|
||||
public protocol WebSocketTasking: AnyObject {
|
||||
var state: URLSessionTask.State { get }
|
||||
func resume()
|
||||
func cancel(with closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?)
|
||||
@@ -14,31 +13,33 @@ protocol WebSocketTasking: AnyObject {
|
||||
|
||||
extension URLSessionWebSocketTask: WebSocketTasking {}
|
||||
|
||||
struct WebSocketTaskBox: @unchecked Sendable {
|
||||
let task: any WebSocketTasking
|
||||
public struct WebSocketTaskBox: @unchecked Sendable {
|
||||
public let task: any WebSocketTasking
|
||||
|
||||
var state: URLSessionTask.State { self.task.state }
|
||||
public var state: URLSessionTask.State { self.task.state }
|
||||
|
||||
func resume() { self.task.resume() }
|
||||
public func resume() { self.task.resume() }
|
||||
|
||||
func cancel(with closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) {
|
||||
public func cancel(with closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) {
|
||||
self.task.cancel(with: closeCode, reason: reason)
|
||||
}
|
||||
|
||||
func send(_ message: URLSessionWebSocketTask.Message) async throws {
|
||||
public func send(_ message: URLSessionWebSocketTask.Message) async throws {
|
||||
try await self.task.send(message)
|
||||
}
|
||||
|
||||
func receive() async throws -> URLSessionWebSocketTask.Message {
|
||||
public func receive() async throws -> URLSessionWebSocketTask.Message {
|
||||
try await self.task.receive()
|
||||
}
|
||||
|
||||
func receive(completionHandler: @escaping @Sendable (Result<URLSessionWebSocketTask.Message, Error>) -> Void) {
|
||||
public func receive(
|
||||
completionHandler: @escaping @Sendable (Result<URLSessionWebSocketTask.Message, Error>) -> Void)
|
||||
{
|
||||
self.task.receive(completionHandler: completionHandler)
|
||||
}
|
||||
}
|
||||
|
||||
protocol WebSocketSessioning: AnyObject {
|
||||
public protocol WebSocketSessioning: AnyObject {
|
||||
func makeWebSocketTask(url: URL) -> WebSocketTaskBox
|
||||
}
|
||||
|
||||
@@ -51,25 +52,45 @@ extension URLSession: WebSocketSessioning {
|
||||
}
|
||||
}
|
||||
|
||||
struct WebSocketSessionBox: @unchecked Sendable {
|
||||
let session: any WebSocketSessioning
|
||||
public struct WebSocketSessionBox: @unchecked Sendable {
|
||||
public let session: any WebSocketSessioning
|
||||
}
|
||||
|
||||
struct GatewayConnectOptions: Sendable {
|
||||
var role: String
|
||||
var scopes: [String]
|
||||
var caps: [String]
|
||||
var commands: [String]
|
||||
var permissions: [String: Bool]
|
||||
var clientId: String
|
||||
var clientMode: String
|
||||
var clientDisplayName: String?
|
||||
public struct GatewayConnectOptions: Sendable {
|
||||
public var role: String
|
||||
public var scopes: [String]
|
||||
public var caps: [String]
|
||||
public var commands: [String]
|
||||
public var permissions: [String: Bool]
|
||||
public var clientId: String
|
||||
public var clientMode: String
|
||||
public var clientDisplayName: String?
|
||||
|
||||
public init(
|
||||
role: String,
|
||||
scopes: [String],
|
||||
caps: [String],
|
||||
commands: [String],
|
||||
permissions: [String: Bool],
|
||||
clientId: String,
|
||||
clientMode: String,
|
||||
clientDisplayName: String?)
|
||||
{
|
||||
self.role = role
|
||||
self.scopes = scopes
|
||||
self.caps = caps
|
||||
self.commands = commands
|
||||
self.permissions = permissions
|
||||
self.clientId = clientId
|
||||
self.clientMode = clientMode
|
||||
self.clientDisplayName = clientDisplayName
|
||||
}
|
||||
}
|
||||
|
||||
// Avoid ambiguity with the app's own AnyCodable type.
|
||||
private typealias ProtoAnyCodable = ClawdbotProtocol.AnyCodable
|
||||
|
||||
actor GatewayChannelActor {
|
||||
public actor GatewayChannelActor {
|
||||
private let logger = Logger(subsystem: "com.clawdbot", category: "gateway")
|
||||
private var task: WebSocketTaskBox?
|
||||
private var pending: [String: CheckedContinuation<GatewayFrame, Error>] = [:]
|
||||
@@ -95,7 +116,7 @@ actor GatewayChannelActor {
|
||||
private let connectOptions: GatewayConnectOptions?
|
||||
private let disconnectHandler: (@Sendable (String) async -> Void)?
|
||||
|
||||
init(
|
||||
public init(
|
||||
url: URL,
|
||||
token: String?,
|
||||
password: String? = nil,
|
||||
@@ -116,7 +137,7 @@ actor GatewayChannelActor {
|
||||
}
|
||||
}
|
||||
|
||||
func shutdown() async {
|
||||
public func shutdown() async {
|
||||
self.shouldReconnect = false
|
||||
self.connected = false
|
||||
|
||||
@@ -167,7 +188,7 @@ actor GatewayChannelActor {
|
||||
}
|
||||
}
|
||||
|
||||
func connect() async throws {
|
||||
public func connect() async throws {
|
||||
if self.connected, self.task?.state == .running { return }
|
||||
if self.isConnecting {
|
||||
try await withCheckedThrowingContinuation { cont in
|
||||
@@ -217,8 +238,7 @@ actor GatewayChannelActor {
|
||||
}
|
||||
|
||||
private func sendConnect() async throws {
|
||||
let osVersion = ProcessInfo.processInfo.operatingSystemVersion
|
||||
let platform = "macos \(osVersion.majorVersion).\(osVersion.minorVersion).\(osVersion.patchVersion)"
|
||||
let platform = InstanceIdentity.platformString
|
||||
let primaryLocale = Locale.preferredLanguages.first ?? Locale.current.identifier
|
||||
let options = self.connectOptions ?? GatewayConnectOptions(
|
||||
role: "operator",
|
||||
@@ -243,7 +263,7 @@ actor GatewayChannelActor {
|
||||
"mode": ProtoAnyCodable(clientMode),
|
||||
"instanceId": ProtoAnyCodable(InstanceIdentity.instanceId),
|
||||
]
|
||||
client["deviceFamily"] = ProtoAnyCodable("Mac")
|
||||
client["deviceFamily"] = ProtoAnyCodable(InstanceIdentity.deviceFamily)
|
||||
if let model = InstanceIdentity.modelIdentifier {
|
||||
client["modelIdentifier"] = ProtoAnyCodable(model)
|
||||
}
|
||||
@@ -450,7 +470,7 @@ actor GatewayChannelActor {
|
||||
}
|
||||
}
|
||||
|
||||
func request(
|
||||
public func request(
|
||||
method: String,
|
||||
params: [String: ClawdbotProtocol.AnyCodable]?,
|
||||
timeoutMs: Double? = nil) async throws -> Data
|
||||
@@ -1,4 +1,3 @@
|
||||
import ClawdbotKit
|
||||
import Foundation
|
||||
import Network
|
||||
|
||||
@@ -2,13 +2,13 @@ import ClawdbotProtocol
|
||||
import Foundation
|
||||
|
||||
/// Structured error surfaced when the gateway responds with `{ ok: false }`.
|
||||
struct GatewayResponseError: LocalizedError, @unchecked Sendable {
|
||||
let method: String
|
||||
let code: String
|
||||
let message: String
|
||||
let details: [String: AnyCodable]
|
||||
public struct GatewayResponseError: LocalizedError, @unchecked Sendable {
|
||||
public let method: String
|
||||
public let code: String
|
||||
public let message: String
|
||||
public let details: [String: AnyCodable]
|
||||
|
||||
init(method: String, code: String?, message: String?, details: [String: AnyCodable]?) {
|
||||
public init(method: String, code: String?, message: String?, details: [String: AnyCodable]?) {
|
||||
self.method = method
|
||||
self.code = (code?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false)
|
||||
? code!.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
@@ -19,15 +19,15 @@ struct GatewayResponseError: LocalizedError, @unchecked Sendable {
|
||||
self.details = details ?? [:]
|
||||
}
|
||||
|
||||
var errorDescription: String? {
|
||||
public var errorDescription: String? {
|
||||
if self.code == "GATEWAY_ERROR" { return "\(self.method): \(self.message)" }
|
||||
return "\(self.method): [\(self.code)] \(self.message)"
|
||||
}
|
||||
}
|
||||
|
||||
struct GatewayDecodingError: LocalizedError, Sendable {
|
||||
let method: String
|
||||
let message: String
|
||||
public struct GatewayDecodingError: LocalizedError, Sendable {
|
||||
public let method: String
|
||||
public let message: String
|
||||
|
||||
var errorDescription: String? { "\(self.method): \(self.message)" }
|
||||
public var errorDescription: String? { "\(self.method): \(self.message)" }
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import ClawdbotKit
|
||||
import ClawdbotProtocol
|
||||
import Foundation
|
||||
import OSLog
|
||||
@@ -12,7 +11,7 @@ private struct NodeInvokeRequestPayload: Codable, Sendable {
|
||||
var idempotencyKey: String?
|
||||
}
|
||||
|
||||
actor MacNodeGatewaySession {
|
||||
public actor GatewayNodeSession {
|
||||
private let logger = Logger(subsystem: "com.clawdbot", category: "node.gateway")
|
||||
private let decoder = JSONDecoder()
|
||||
private let encoder = JSONEncoder()
|
||||
@@ -24,8 +23,10 @@ actor MacNodeGatewaySession {
|
||||
private var onConnected: (@Sendable () async -> Void)?
|
||||
private var onDisconnected: (@Sendable (String) async -> Void)?
|
||||
private var onInvoke: (@Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse)?
|
||||
private var serverEventSubscribers: [UUID: AsyncStream<EventFrame>.Continuation] = [:]
|
||||
private var canvasHostUrl: String?
|
||||
|
||||
func connect(
|
||||
public func connect(
|
||||
url: URL,
|
||||
token: String?,
|
||||
password: String?,
|
||||
@@ -82,7 +83,7 @@ actor MacNodeGatewaySession {
|
||||
}
|
||||
}
|
||||
|
||||
func disconnect() async {
|
||||
public func disconnect() async {
|
||||
await self.channel?.shutdown()
|
||||
self.channel = nil
|
||||
self.activeURL = nil
|
||||
@@ -90,7 +91,21 @@ actor MacNodeGatewaySession {
|
||||
self.activePassword = nil
|
||||
}
|
||||
|
||||
func sendEvent(event: String, payloadJSON: String?) async {
|
||||
public func currentCanvasHostUrl() -> String? {
|
||||
self.canvasHostUrl
|
||||
}
|
||||
|
||||
public func currentRemoteAddress() -> String? {
|
||||
guard let url = self.activeURL else { return nil }
|
||||
guard let host = url.host else { return url.absoluteString }
|
||||
let port = url.port ?? (url.scheme == "wss" ? 443 : 80)
|
||||
if host.contains(":") {
|
||||
return "[\(host)]:\(port)"
|
||||
}
|
||||
return "\(host):\(port)"
|
||||
}
|
||||
|
||||
public func sendEvent(event: String, payloadJSON: String?) async {
|
||||
guard let channel = self.channel else { return }
|
||||
let params: [String: ClawdbotProtocol.AnyCodable] = [
|
||||
"event": ClawdbotProtocol.AnyCodable(event),
|
||||
@@ -103,8 +118,37 @@ actor MacNodeGatewaySession {
|
||||
}
|
||||
}
|
||||
|
||||
public func request(method: String, paramsJSON: String?, timeoutSeconds: Int = 15) async throws -> Data {
|
||||
guard let channel = self.channel else {
|
||||
throw NSError(domain: "Gateway", code: 11, userInfo: [
|
||||
NSLocalizedDescriptionKey: "not connected",
|
||||
])
|
||||
}
|
||||
|
||||
let params = try self.decodeParamsJSON(paramsJSON)
|
||||
return try await channel.request(
|
||||
method: method,
|
||||
params: params,
|
||||
timeoutMs: Double(timeoutSeconds * 1000))
|
||||
}
|
||||
|
||||
public func subscribeServerEvents(bufferingNewest: Int = 200) -> AsyncStream<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 {
|
||||
switch push {
|
||||
case let .snapshot(ok):
|
||||
let raw = ok.canvashosturl?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
self.canvasHostUrl = (raw?.isEmpty == false) ? raw : nil
|
||||
await self.onConnected?()
|
||||
case let .event(evt):
|
||||
await self.handleEvent(evt)
|
||||
default:
|
||||
@@ -113,6 +157,7 @@ actor MacNodeGatewaySession {
|
||||
}
|
||||
|
||||
private func handleEvent(_ evt: EventFrame) async {
|
||||
self.broadcastServerEvent(evt)
|
||||
guard evt.event == "node.invoke.request" else { return }
|
||||
guard let payload = evt.payload else { return }
|
||||
do {
|
||||
@@ -147,4 +192,34 @@ actor MacNodeGatewaySession {
|
||||
self.logger.error("node invoke result failed: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
private func decodeParamsJSON(
|
||||
_ paramsJSON: String?) throws -> [String: ClawdbotProtocol.AnyCodable]?
|
||||
{
|
||||
guard let paramsJSON, !paramsJSON.isEmpty else { return nil }
|
||||
guard let data = paramsJSON.data(using: .utf8) else {
|
||||
throw NSError(domain: "Gateway", code: 12, userInfo: [
|
||||
NSLocalizedDescriptionKey: "paramsJSON not UTF-8",
|
||||
])
|
||||
}
|
||||
let raw = try JSONSerialization.jsonObject(with: data)
|
||||
guard let dict = raw as? [String: Any] else {
|
||||
return nil
|
||||
}
|
||||
return dict.reduce(into: [:]) { acc, entry in
|
||||
acc[entry.key] = ClawdbotProtocol.AnyCodable(entry.value)
|
||||
}
|
||||
}
|
||||
|
||||
private func broadcastServerEvent(_ evt: EventFrame) {
|
||||
for (id, continuation) in self.serverEventSubscribers {
|
||||
if continuation.yield(evt) == .terminated {
|
||||
self.serverEventSubscribers.removeValue(forKey: id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func removeServerEventSubscriber(_ id: UUID) {
|
||||
self.serverEventSubscribers.removeValue(forKey: id)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import ClawdbotProtocol
|
||||
/// Server-push messages from the gateway websocket.
|
||||
///
|
||||
/// This is the in-process replacement for the legacy `NotificationCenter` fan-out.
|
||||
enum GatewayPush: Sendable {
|
||||
public enum GatewayPush: Sendable {
|
||||
/// A full snapshot that arrives on connect (or reconnect).
|
||||
case snapshot(HelloOk)
|
||||
/// A server push event frame.
|
||||
@@ -2,14 +2,21 @@ import CryptoKit
|
||||
import Foundation
|
||||
import Security
|
||||
|
||||
struct GatewayTLSParams: Sendable {
|
||||
let required: Bool
|
||||
let expectedFingerprint: String?
|
||||
let allowTOFU: Bool
|
||||
let storeKey: String?
|
||||
public struct GatewayTLSParams: Sendable {
|
||||
public let required: Bool
|
||||
public let expectedFingerprint: String?
|
||||
public let allowTOFU: Bool
|
||||
public let storeKey: String?
|
||||
|
||||
public init(required: Bool, expectedFingerprint: String?, allowTOFU: Bool, storeKey: String?) {
|
||||
self.required = required
|
||||
self.expectedFingerprint = expectedFingerprint
|
||||
self.allowTOFU = allowTOFU
|
||||
self.storeKey = storeKey
|
||||
}
|
||||
}
|
||||
|
||||
enum GatewayTLSStore {
|
||||
public enum GatewayTLSStore {
|
||||
private static let suiteName = "com.clawdbot.shared"
|
||||
private static let keyPrefix = "gateway.tls."
|
||||
|
||||
@@ -17,19 +24,19 @@ enum GatewayTLSStore {
|
||||
UserDefaults(suiteName: suiteName) ?? .standard
|
||||
}
|
||||
|
||||
static func loadFingerprint(stableID: String) -> String? {
|
||||
public static func loadFingerprint(stableID: String) -> String? {
|
||||
let key = self.keyPrefix + stableID
|
||||
let raw = self.defaults.string(forKey: key)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return raw?.isEmpty == false ? raw : nil
|
||||
}
|
||||
|
||||
static func saveFingerprint(_ value: String, stableID: String) {
|
||||
public static func saveFingerprint(_ value: String, stableID: String) {
|
||||
let key = self.keyPrefix + stableID
|
||||
self.defaults.set(value, forKey: key)
|
||||
}
|
||||
}
|
||||
|
||||
final class GatewayTLSPinningSession: NSObject, WebSocketSessioning, URLSessionDelegate {
|
||||
public final class GatewayTLSPinningSession: NSObject, WebSocketSessioning, URLSessionDelegate {
|
||||
private let params: GatewayTLSParams
|
||||
private lazy var session: URLSession = {
|
||||
let config = URLSessionConfiguration.default
|
||||
@@ -37,18 +44,18 @@ final class GatewayTLSPinningSession: NSObject, WebSocketSessioning, URLSessionD
|
||||
return URLSession(configuration: config, delegate: self, delegateQueue: nil)
|
||||
}()
|
||||
|
||||
init(params: GatewayTLSParams) {
|
||||
public init(params: GatewayTLSParams) {
|
||||
self.params = params
|
||||
super.init()
|
||||
}
|
||||
|
||||
func makeWebSocketTask(url: URL) -> WebSocketTaskBox {
|
||||
public func makeWebSocketTask(url: URL) -> WebSocketTaskBox {
|
||||
let task = self.session.webSocketTask(with: url)
|
||||
task.maximumMessageSize = 16 * 1024 * 1024
|
||||
return WebSocketTaskBox(task: task)
|
||||
}
|
||||
|
||||
func urlSession(
|
||||
public func urlSession(
|
||||
_ session: URLSession,
|
||||
didReceive challenge: URLAuthenticationChallenge,
|
||||
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
|
||||
@@ -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
|
||||
}()
|
||||
}
|
||||
Reference in New Issue
Block a user