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]
|
||||
|
||||
Reference in New Issue
Block a user