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