chore: rename project to clawdbot
This commit is contained in:
444
apps/macos/Sources/Clawdbot/Bridge/BridgeConnectionHandler.swift
Normal file
444
apps/macos/Sources/Clawdbot/Bridge/BridgeConnectionHandler.swift
Normal file
@@ -0,0 +1,444 @@
|
||||
import ClawdbotKit
|
||||
import Foundation
|
||||
import Network
|
||||
import OSLog
|
||||
|
||||
struct BridgeNodeInfo: Sendable {
|
||||
var nodeId: String
|
||||
var displayName: String?
|
||||
var platform: String?
|
||||
var version: String?
|
||||
var deviceFamily: String?
|
||||
var modelIdentifier: String?
|
||||
var remoteAddress: String?
|
||||
var caps: [String]?
|
||||
}
|
||||
|
||||
actor BridgeConnectionHandler {
|
||||
private let connection: NWConnection
|
||||
private let logger: Logger
|
||||
private let decoder = JSONDecoder()
|
||||
private let encoder = JSONEncoder()
|
||||
private let queue = DispatchQueue(label: "com.clawdbot.bridge.connection")
|
||||
|
||||
private var buffer = Data()
|
||||
private var isAuthenticated = false
|
||||
private var nodeId: String?
|
||||
private var pendingInvokes: [String: CheckedContinuation<BridgeInvokeResponse, Error>] = [:]
|
||||
private var isClosed = false
|
||||
|
||||
init(connection: NWConnection, logger: Logger) {
|
||||
self.connection = connection
|
||||
self.logger = logger
|
||||
}
|
||||
|
||||
enum AuthResult: Sendable {
|
||||
case ok
|
||||
case notPaired
|
||||
case unauthorized
|
||||
case error(code: String, message: String)
|
||||
}
|
||||
|
||||
enum PairResult: Sendable {
|
||||
case ok(token: String)
|
||||
case rejected
|
||||
case error(code: String, message: String)
|
||||
}
|
||||
|
||||
private struct FrameContext: Sendable {
|
||||
var serverName: String
|
||||
var resolveAuth: @Sendable (BridgeHello) async -> AuthResult
|
||||
var handlePair: @Sendable (BridgePairRequest) async -> PairResult
|
||||
var onAuthenticated: (@Sendable (BridgeNodeInfo) async -> Void)?
|
||||
var onEvent: (@Sendable (String, BridgeEventFrame) async -> Void)?
|
||||
var onRequest: (@Sendable (String, BridgeRPCRequest) async -> BridgeRPCResponse)?
|
||||
}
|
||||
|
||||
func run(
|
||||
resolveAuth: @escaping @Sendable (BridgeHello) async -> AuthResult,
|
||||
handlePair: @escaping @Sendable (BridgePairRequest) async -> PairResult,
|
||||
onAuthenticated: (@Sendable (BridgeNodeInfo) async -> Void)? = nil,
|
||||
onDisconnected: (@Sendable (String) async -> Void)? = nil,
|
||||
onEvent: (@Sendable (String, BridgeEventFrame) async -> Void)? = nil,
|
||||
onRequest: (@Sendable (String, BridgeRPCRequest) async -> BridgeRPCResponse)? = nil) async
|
||||
{
|
||||
self.configureStateLogging()
|
||||
self.connection.start(queue: self.queue)
|
||||
|
||||
let context = FrameContext(
|
||||
serverName: Host.current().localizedName ?? ProcessInfo.processInfo.hostName,
|
||||
resolveAuth: resolveAuth,
|
||||
handlePair: handlePair,
|
||||
onAuthenticated: onAuthenticated,
|
||||
onEvent: onEvent,
|
||||
onRequest: onRequest)
|
||||
|
||||
while true {
|
||||
do {
|
||||
guard let line = try await self.receiveLine() else { break }
|
||||
guard let data = line.data(using: .utf8) else { continue }
|
||||
let base = try self.decoder.decode(BridgeBaseFrame.self, from: data)
|
||||
try await self.handleFrame(
|
||||
baseType: base.type,
|
||||
data: data,
|
||||
context: context)
|
||||
} catch {
|
||||
await self.sendError(code: "INVALID_REQUEST", message: error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
await self.close(with: onDisconnected)
|
||||
}
|
||||
|
||||
private func configureStateLogging() {
|
||||
self.connection.stateUpdateHandler = { [logger] state in
|
||||
switch state {
|
||||
case .ready:
|
||||
logger.debug("bridge conn ready")
|
||||
case let .failed(err):
|
||||
logger.error("bridge conn failed: \(err.localizedDescription, privacy: .public)")
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handleFrame(
|
||||
baseType: String,
|
||||
data: Data,
|
||||
context: FrameContext) async throws
|
||||
{
|
||||
switch baseType {
|
||||
case "hello":
|
||||
await self.handleHelloFrame(
|
||||
data: data,
|
||||
context: context)
|
||||
case "pair-request":
|
||||
await self.handlePairRequestFrame(
|
||||
data: data,
|
||||
context: context)
|
||||
case "event":
|
||||
await self.handleEventFrame(data: data, onEvent: context.onEvent)
|
||||
case "req":
|
||||
try await self.handleRPCRequestFrame(data: data, onRequest: context.onRequest)
|
||||
case "ping":
|
||||
try await self.handlePingFrame(data: data)
|
||||
case "invoke-res":
|
||||
await self.handleInvokeResponseFrame(data: data)
|
||||
default:
|
||||
await self.sendError(code: "INVALID_REQUEST", message: "unknown type")
|
||||
}
|
||||
}
|
||||
|
||||
private func handleHelloFrame(
|
||||
data: Data,
|
||||
context: FrameContext) async
|
||||
{
|
||||
do {
|
||||
let hello = try self.decoder.decode(BridgeHello.self, from: data)
|
||||
let nodeId = hello.nodeId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
self.nodeId = nodeId
|
||||
let result = await context.resolveAuth(hello)
|
||||
await self.handleAuthResult(result, serverName: context.serverName)
|
||||
if case .ok = result {
|
||||
await context.onAuthenticated?(
|
||||
BridgeNodeInfo(
|
||||
nodeId: nodeId,
|
||||
displayName: hello.displayName,
|
||||
platform: hello.platform,
|
||||
version: hello.version,
|
||||
deviceFamily: hello.deviceFamily,
|
||||
modelIdentifier: hello.modelIdentifier,
|
||||
remoteAddress: self.remoteAddressString(),
|
||||
caps: hello.caps))
|
||||
}
|
||||
} catch {
|
||||
await self.sendError(code: "INVALID_REQUEST", message: error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private func handlePairRequestFrame(
|
||||
data: Data,
|
||||
context: FrameContext) async
|
||||
{
|
||||
do {
|
||||
let req = try self.decoder.decode(BridgePairRequest.self, from: data)
|
||||
let nodeId = req.nodeId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
self.nodeId = nodeId
|
||||
let enriched = BridgePairRequest(
|
||||
type: req.type,
|
||||
nodeId: nodeId,
|
||||
displayName: req.displayName,
|
||||
platform: req.platform,
|
||||
version: req.version,
|
||||
deviceFamily: req.deviceFamily,
|
||||
modelIdentifier: req.modelIdentifier,
|
||||
caps: req.caps,
|
||||
commands: req.commands,
|
||||
remoteAddress: self.remoteAddressString(),
|
||||
silent: req.silent)
|
||||
let result = await context.handlePair(enriched)
|
||||
await self.handlePairResult(result, serverName: context.serverName)
|
||||
if case .ok = result {
|
||||
await context.onAuthenticated?(
|
||||
BridgeNodeInfo(
|
||||
nodeId: nodeId,
|
||||
displayName: enriched.displayName,
|
||||
platform: enriched.platform,
|
||||
version: enriched.version,
|
||||
deviceFamily: enriched.deviceFamily,
|
||||
modelIdentifier: enriched.modelIdentifier,
|
||||
remoteAddress: enriched.remoteAddress,
|
||||
caps: enriched.caps))
|
||||
}
|
||||
} catch {
|
||||
await self.sendError(code: "INVALID_REQUEST", message: error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private func handleEventFrame(
|
||||
data: Data,
|
||||
onEvent: (@Sendable (String, BridgeEventFrame) async -> Void)?) async
|
||||
{
|
||||
guard self.isAuthenticated, let nodeId = self.nodeId else {
|
||||
await self.sendError(code: "UNAUTHORIZED", message: "not authenticated")
|
||||
return
|
||||
}
|
||||
do {
|
||||
let evt = try self.decoder.decode(BridgeEventFrame.self, from: data)
|
||||
await onEvent?(nodeId, evt)
|
||||
} catch {
|
||||
await self.sendError(code: "INVALID_REQUEST", message: error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private func handleRPCRequestFrame(
|
||||
data: Data,
|
||||
onRequest: (@Sendable (String, BridgeRPCRequest) async -> BridgeRPCResponse)?) async throws
|
||||
{
|
||||
let req = try self.decoder.decode(BridgeRPCRequest.self, from: data)
|
||||
guard self.isAuthenticated, let nodeId = self.nodeId else {
|
||||
try await self.send(
|
||||
BridgeRPCResponse(
|
||||
id: req.id,
|
||||
ok: false,
|
||||
error: BridgeRPCError(code: "UNAUTHORIZED", message: "not authenticated")))
|
||||
return
|
||||
}
|
||||
|
||||
if let onRequest {
|
||||
let res = await onRequest(nodeId, req)
|
||||
try await self.send(res)
|
||||
} else {
|
||||
try await self.send(
|
||||
BridgeRPCResponse(
|
||||
id: req.id,
|
||||
ok: false,
|
||||
error: BridgeRPCError(code: "UNAVAILABLE", message: "RPC not supported")))
|
||||
}
|
||||
}
|
||||
|
||||
private func handlePingFrame(data: Data) async throws {
|
||||
guard self.isAuthenticated else {
|
||||
await self.sendError(code: "UNAUTHORIZED", message: "not authenticated")
|
||||
return
|
||||
}
|
||||
let ping = try self.decoder.decode(BridgePing.self, from: data)
|
||||
try await self.send(BridgePong(type: "pong", id: ping.id))
|
||||
}
|
||||
|
||||
private func handleInvokeResponseFrame(data: Data) async {
|
||||
guard self.isAuthenticated else {
|
||||
await self.sendError(code: "UNAUTHORIZED", message: "not authenticated")
|
||||
return
|
||||
}
|
||||
do {
|
||||
let res = try self.decoder.decode(BridgeInvokeResponse.self, from: data)
|
||||
if let cont = self.pendingInvokes.removeValue(forKey: res.id) {
|
||||
cont.resume(returning: res)
|
||||
}
|
||||
} catch {
|
||||
await self.sendError(code: "INVALID_REQUEST", message: error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private func remoteAddressString() -> String? {
|
||||
switch self.connection.endpoint {
|
||||
case let .hostPort(host: host, port: _):
|
||||
let value = String(describing: host)
|
||||
return value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : value
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func remoteAddress() -> String? {
|
||||
self.remoteAddressString()
|
||||
}
|
||||
|
||||
private func handlePairResult(_ result: PairResult, serverName: String) async {
|
||||
switch result {
|
||||
case let .ok(token):
|
||||
do {
|
||||
try await self.send(BridgePairOk(type: "pair-ok", token: token))
|
||||
self.isAuthenticated = true
|
||||
try await self.send(BridgeHelloOk(type: "hello-ok", serverName: serverName))
|
||||
} catch {
|
||||
self.logger.error("bridge send pair-ok failed: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
case .rejected:
|
||||
await self.sendError(code: "UNAUTHORIZED", message: "pairing rejected")
|
||||
case let .error(code, message):
|
||||
await self.sendError(code: code, message: message)
|
||||
}
|
||||
}
|
||||
|
||||
private func handleAuthResult(_ result: AuthResult, serverName: String) async {
|
||||
switch result {
|
||||
case .ok:
|
||||
self.isAuthenticated = true
|
||||
do {
|
||||
try await self.send(BridgeHelloOk(type: "hello-ok", serverName: serverName))
|
||||
} catch {
|
||||
self.logger.error("bridge send hello-ok failed: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
case .notPaired:
|
||||
await self.sendError(code: "NOT_PAIRED", message: "pairing required")
|
||||
case .unauthorized:
|
||||
await self.sendError(code: "UNAUTHORIZED", message: "invalid token")
|
||||
case let .error(code, message):
|
||||
await self.sendError(code: code, message: message)
|
||||
}
|
||||
}
|
||||
|
||||
private func sendError(code: String, message: String) async {
|
||||
do {
|
||||
try await self.send(BridgeErrorFrame(type: "error", code: code, message: message))
|
||||
} catch {
|
||||
self.logger.error("bridge send error failed: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
func invoke(command: String, paramsJSON: String?) async throws -> BridgeInvokeResponse {
|
||||
guard self.isAuthenticated else {
|
||||
throw NSError(domain: "Bridge", code: 1, userInfo: [
|
||||
NSLocalizedDescriptionKey: "UNAUTHORIZED: not authenticated",
|
||||
])
|
||||
}
|
||||
let id = UUID().uuidString
|
||||
let req = BridgeInvokeRequest(type: "invoke", id: id, command: command, paramsJSON: paramsJSON)
|
||||
|
||||
let timeoutTask = Task {
|
||||
try await Task.sleep(nanoseconds: 15 * 1_000_000_000)
|
||||
await self.timeoutInvoke(id: id)
|
||||
}
|
||||
defer { timeoutTask.cancel() }
|
||||
|
||||
return try await withCheckedThrowingContinuation { cont in
|
||||
Task { [weak self] in
|
||||
guard let self else { return }
|
||||
await self.beginInvoke(id: id, request: req, continuation: cont)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func beginInvoke(
|
||||
id: String,
|
||||
request: BridgeInvokeRequest,
|
||||
continuation: CheckedContinuation<BridgeInvokeResponse, Error>) async
|
||||
{
|
||||
self.pendingInvokes[id] = continuation
|
||||
do {
|
||||
try await self.send(request)
|
||||
} catch {
|
||||
await self.failInvoke(id: id, error: error)
|
||||
}
|
||||
}
|
||||
|
||||
private func timeoutInvoke(id: String) async {
|
||||
guard let cont = self.pendingInvokes.removeValue(forKey: id) else { return }
|
||||
cont.resume(throwing: NSError(domain: "Bridge", code: 3, userInfo: [
|
||||
NSLocalizedDescriptionKey: "UNAVAILABLE: invoke timeout",
|
||||
]))
|
||||
}
|
||||
|
||||
private func failInvoke(id: String, error: Error) async {
|
||||
guard let cont = self.pendingInvokes.removeValue(forKey: id) else { return }
|
||||
cont.resume(throwing: error)
|
||||
}
|
||||
|
||||
private func send(_ obj: some Encodable) async throws {
|
||||
let data = try self.encoder.encode(obj)
|
||||
var line = Data()
|
||||
line.append(data)
|
||||
line.append(0x0A) // \n
|
||||
let _: Void = try await withCheckedThrowingContinuation { cont in
|
||||
self.connection.send(content: line, completion: .contentProcessed { err in
|
||||
if let err {
|
||||
cont.resume(throwing: err)
|
||||
} else {
|
||||
cont.resume(returning: ())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func sendServerEvent(event: String, payloadJSON: String?) async {
|
||||
guard self.isAuthenticated else { return }
|
||||
do {
|
||||
try await self.send(BridgeEventFrame(type: "event", event: event, payloadJSON: payloadJSON))
|
||||
} catch {
|
||||
self.logger.error("bridge send event failed: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
try await withCheckedThrowingContinuation { cont in
|
||||
self.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 close(with onDisconnected: (@Sendable (String) async -> Void)? = nil) async {
|
||||
if self.isClosed { return }
|
||||
self.isClosed = true
|
||||
|
||||
let nodeId = self.nodeId
|
||||
let pending = self.pendingInvokes.values
|
||||
self.pendingInvokes.removeAll()
|
||||
for cont in pending {
|
||||
cont.resume(throwing: NSError(domain: "Bridge", code: 4, userInfo: [
|
||||
NSLocalizedDescriptionKey: "UNAVAILABLE: connection closed",
|
||||
]))
|
||||
}
|
||||
|
||||
self.connection.cancel()
|
||||
if let nodeId {
|
||||
await onDisconnected?(nodeId)
|
||||
}
|
||||
}
|
||||
}
|
||||
542
apps/macos/Sources/Clawdbot/Bridge/BridgeServer.swift
Normal file
542
apps/macos/Sources/Clawdbot/Bridge/BridgeServer.swift
Normal file
@@ -0,0 +1,542 @@
|
||||
import AppKit
|
||||
import ClawdbotKit
|
||||
import ClawdbotProtocol
|
||||
import Foundation
|
||||
import Network
|
||||
import OSLog
|
||||
|
||||
actor BridgeServer {
|
||||
static let shared = BridgeServer()
|
||||
|
||||
private let logger = Logger(subsystem: "com.clawdbot", category: "bridge")
|
||||
private var listener: NWListener?
|
||||
private var isRunning = false
|
||||
private var store: PairedNodesStore?
|
||||
private var connections: [String: BridgeConnectionHandler] = [:]
|
||||
private var nodeInfoById: [String: BridgeNodeInfo] = [:]
|
||||
private var presenceTasks: [String: Task<Void, Never>] = [:]
|
||||
private var chatSubscriptions: [String: Set<String>] = [:]
|
||||
private var gatewayPushTask: Task<Void, Never>?
|
||||
|
||||
func start() async {
|
||||
if self.isRunning { return }
|
||||
self.isRunning = true
|
||||
|
||||
do {
|
||||
let storeURL = try Self.defaultStoreURL()
|
||||
let store = PairedNodesStore(fileURL: storeURL)
|
||||
await store.load()
|
||||
self.store = store
|
||||
|
||||
let params = NWParameters.tcp
|
||||
params.includePeerToPeer = true
|
||||
let listener = try NWListener(using: params, on: .any)
|
||||
|
||||
listener.newConnectionHandler = { [weak self] connection in
|
||||
guard let self else { return }
|
||||
Task { await self.handle(connection: connection) }
|
||||
}
|
||||
|
||||
listener.stateUpdateHandler = { [weak self] state in
|
||||
guard let self else { return }
|
||||
Task { await self.handleListenerState(state) }
|
||||
}
|
||||
|
||||
listener.start(queue: DispatchQueue(label: "com.clawdbot.bridge"))
|
||||
self.listener = listener
|
||||
} catch {
|
||||
self.logger.error("bridge start failed: \(error.localizedDescription, privacy: .public)")
|
||||
self.isRunning = false
|
||||
}
|
||||
}
|
||||
|
||||
func stop() async {
|
||||
self.isRunning = false
|
||||
self.listener?.cancel()
|
||||
self.listener = nil
|
||||
}
|
||||
|
||||
private func handleListenerState(_ state: NWListener.State) {
|
||||
switch state {
|
||||
case .ready:
|
||||
self.logger.info("bridge listening")
|
||||
case let .failed(err):
|
||||
self.logger.error("bridge listener failed: \(err.localizedDescription, privacy: .public)")
|
||||
case .cancelled:
|
||||
self.logger.info("bridge listener cancelled")
|
||||
case .waiting:
|
||||
self.logger.info("bridge listener waiting")
|
||||
case .setup:
|
||||
break
|
||||
@unknown default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private func handle(connection: NWConnection) async {
|
||||
let handler = BridgeConnectionHandler(connection: connection, logger: self.logger)
|
||||
await handler.run(
|
||||
resolveAuth: { [weak self] hello in
|
||||
await self?.authorize(hello: hello) ?? .error(code: "UNAVAILABLE", message: "bridge unavailable")
|
||||
},
|
||||
handlePair: { [weak self] request in
|
||||
await self?.pair(request: request) ?? .error(code: "UNAVAILABLE", message: "bridge unavailable")
|
||||
},
|
||||
onAuthenticated: { [weak self] node in
|
||||
await self?.registerConnection(handler: handler, node: node)
|
||||
},
|
||||
onDisconnected: { [weak self] nodeId in
|
||||
await self?.unregisterConnection(nodeId: nodeId)
|
||||
},
|
||||
onEvent: { [weak self] nodeId, evt in
|
||||
await self?.handleEvent(nodeId: nodeId, evt: evt)
|
||||
},
|
||||
onRequest: { [weak self] nodeId, req in
|
||||
await self?.handleRequest(nodeId: nodeId, req: req)
|
||||
?? BridgeRPCResponse(
|
||||
id: req.id,
|
||||
ok: false,
|
||||
error: BridgeRPCError(code: "UNAVAILABLE", message: "bridge unavailable"))
|
||||
})
|
||||
}
|
||||
|
||||
func invoke(nodeId: String, command: String, paramsJSON: String?) async throws -> BridgeInvokeResponse {
|
||||
guard let handler = self.connections[nodeId] else {
|
||||
throw NSError(domain: "Bridge", code: 10, userInfo: [
|
||||
NSLocalizedDescriptionKey: "UNAVAILABLE: node not connected",
|
||||
])
|
||||
}
|
||||
return try await handler.invoke(command: command, paramsJSON: paramsJSON)
|
||||
}
|
||||
|
||||
func connectedNodeIds() -> [String] {
|
||||
Array(self.connections.keys).sorted()
|
||||
}
|
||||
|
||||
func connectedNodes() -> [BridgeNodeInfo] {
|
||||
self.nodeInfoById.values.sorted { a, b in
|
||||
(a.displayName ?? a.nodeId) < (b.displayName ?? b.nodeId)
|
||||
}
|
||||
}
|
||||
|
||||
func pairedNodes() async -> [PairedNode] {
|
||||
guard let store = self.store else { return [] }
|
||||
return await store.all()
|
||||
}
|
||||
|
||||
private func registerConnection(handler: BridgeConnectionHandler, node: BridgeNodeInfo) async {
|
||||
self.connections[node.nodeId] = handler
|
||||
self.nodeInfoById[node.nodeId] = node
|
||||
await self.beaconPresence(nodeId: node.nodeId, reason: "connect")
|
||||
self.startPresenceTask(nodeId: node.nodeId)
|
||||
self.ensureGatewayPushTask()
|
||||
}
|
||||
|
||||
private func unregisterConnection(nodeId: String) async {
|
||||
await self.beaconPresence(nodeId: nodeId, reason: "disconnect")
|
||||
self.stopPresenceTask(nodeId: nodeId)
|
||||
self.connections.removeValue(forKey: nodeId)
|
||||
self.nodeInfoById.removeValue(forKey: nodeId)
|
||||
self.chatSubscriptions[nodeId] = nil
|
||||
self.stopGatewayPushTaskIfIdle()
|
||||
}
|
||||
|
||||
private struct VoiceTranscriptPayload: Codable, Sendable {
|
||||
var text: String
|
||||
var sessionKey: String?
|
||||
}
|
||||
|
||||
private func handleEvent(nodeId: String, evt: BridgeEventFrame) async {
|
||||
switch evt.event {
|
||||
case "chat.subscribe":
|
||||
guard let json = evt.payloadJSON, let data = json.data(using: .utf8) else { return }
|
||||
struct Subscribe: Codable { var sessionKey: String }
|
||||
guard let payload = try? JSONDecoder().decode(Subscribe.self, from: data) else { return }
|
||||
let key = payload.sessionKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !key.isEmpty else { return }
|
||||
var set = self.chatSubscriptions[nodeId] ?? Set<String>()
|
||||
set.insert(key)
|
||||
self.chatSubscriptions[nodeId] = set
|
||||
|
||||
case "chat.unsubscribe":
|
||||
guard let json = evt.payloadJSON, let data = json.data(using: .utf8) else { return }
|
||||
struct Unsubscribe: Codable { var sessionKey: String }
|
||||
guard let payload = try? JSONDecoder().decode(Unsubscribe.self, from: data) else { return }
|
||||
let key = payload.sessionKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !key.isEmpty else { return }
|
||||
var set = self.chatSubscriptions[nodeId] ?? Set<String>()
|
||||
set.remove(key)
|
||||
self.chatSubscriptions[nodeId] = set.isEmpty ? nil : set
|
||||
|
||||
case "voice.transcript":
|
||||
guard let json = evt.payloadJSON, let data = json.data(using: .utf8) else {
|
||||
return
|
||||
}
|
||||
guard let payload = try? JSONDecoder().decode(VoiceTranscriptPayload.self, from: data) else {
|
||||
return
|
||||
}
|
||||
let text = payload.text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !text.isEmpty else { return }
|
||||
|
||||
let sessionKey = payload.sessionKey?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
|
||||
?? "main"
|
||||
|
||||
_ = await GatewayConnection.shared.sendAgent(GatewayAgentInvocation(
|
||||
message: text,
|
||||
sessionKey: sessionKey,
|
||||
thinking: "low",
|
||||
deliver: false,
|
||||
to: nil,
|
||||
channel: .last))
|
||||
|
||||
case "agent.request":
|
||||
guard let json = evt.payloadJSON, let data = json.data(using: .utf8) else {
|
||||
return
|
||||
}
|
||||
guard let link = try? JSONDecoder().decode(AgentDeepLink.self, from: data) else {
|
||||
return
|
||||
}
|
||||
|
||||
let message = link.message.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !message.isEmpty else { return }
|
||||
guard message.count <= 20000 else { return }
|
||||
|
||||
let sessionKey = link.sessionKey?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
|
||||
?? "node-\(nodeId)"
|
||||
let thinking = link.thinking?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
|
||||
let to = link.to?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
|
||||
let channel = GatewayAgentChannel(raw: link.channel)
|
||||
|
||||
_ = await GatewayConnection.shared.sendAgent(GatewayAgentInvocation(
|
||||
message: message,
|
||||
sessionKey: sessionKey,
|
||||
thinking: thinking,
|
||||
deliver: link.deliver,
|
||||
to: to,
|
||||
channel: channel))
|
||||
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private func handleRequest(nodeId: String, req: BridgeRPCRequest) async -> BridgeRPCResponse {
|
||||
let allowed: Set<String> = ["chat.history", "chat.send", "health"]
|
||||
guard allowed.contains(req.method) else {
|
||||
return BridgeRPCResponse(
|
||||
id: req.id,
|
||||
ok: false,
|
||||
error: BridgeRPCError(code: "FORBIDDEN", message: "Method not allowed"))
|
||||
}
|
||||
|
||||
let params: [String: AnyCodable]?
|
||||
if let json = req.paramsJSON?.trimmingCharacters(in: .whitespacesAndNewlines), !json.isEmpty {
|
||||
guard let data = json.data(using: .utf8) else {
|
||||
return BridgeRPCResponse(
|
||||
id: req.id,
|
||||
ok: false,
|
||||
error: BridgeRPCError(code: "INVALID_REQUEST", message: "paramsJSON not UTF-8"))
|
||||
}
|
||||
do {
|
||||
params = try JSONDecoder().decode([String: AnyCodable].self, from: data)
|
||||
} catch {
|
||||
return BridgeRPCResponse(
|
||||
id: req.id,
|
||||
ok: false,
|
||||
error: BridgeRPCError(code: "INVALID_REQUEST", message: error.localizedDescription))
|
||||
}
|
||||
} else {
|
||||
params = nil
|
||||
}
|
||||
|
||||
do {
|
||||
let data = try await GatewayConnection.shared.request(method: req.method, params: params, timeoutMs: 30000)
|
||||
guard let json = String(data: data, encoding: .utf8) else {
|
||||
return BridgeRPCResponse(
|
||||
id: req.id,
|
||||
ok: false,
|
||||
error: BridgeRPCError(code: "UNAVAILABLE", message: "Response not UTF-8"))
|
||||
}
|
||||
return BridgeRPCResponse(id: req.id, ok: true, payloadJSON: json)
|
||||
} catch {
|
||||
return BridgeRPCResponse(
|
||||
id: req.id,
|
||||
ok: false,
|
||||
error: BridgeRPCError(code: "UNAVAILABLE", message: error.localizedDescription))
|
||||
}
|
||||
}
|
||||
|
||||
private func ensureGatewayPushTask() {
|
||||
if self.gatewayPushTask != nil { return }
|
||||
self.gatewayPushTask = Task { [weak self] in
|
||||
guard let self else { return }
|
||||
do {
|
||||
try await GatewayConnection.shared.refresh()
|
||||
} catch {
|
||||
// We'll still forward events once the gateway comes up.
|
||||
}
|
||||
let stream = await GatewayConnection.shared.subscribe()
|
||||
for await push in stream {
|
||||
if Task.isCancelled { return }
|
||||
await self.forwardGatewayPush(push)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func stopGatewayPushTaskIfIdle() {
|
||||
guard self.connections.isEmpty else { return }
|
||||
self.gatewayPushTask?.cancel()
|
||||
self.gatewayPushTask = nil
|
||||
}
|
||||
|
||||
private func forwardGatewayPush(_ push: GatewayPush) async {
|
||||
let subscribedNodes = self.chatSubscriptions.keys.filter { self.connections[$0] != nil }
|
||||
guard !subscribedNodes.isEmpty else { return }
|
||||
|
||||
switch push {
|
||||
case let .snapshot(hello):
|
||||
let payloadJSON = (try? JSONEncoder().encode(hello.snapshot.health))
|
||||
.flatMap { String(data: $0, encoding: .utf8) }
|
||||
for nodeId in subscribedNodes {
|
||||
await self.connections[nodeId]?.sendServerEvent(event: "health", payloadJSON: payloadJSON)
|
||||
}
|
||||
case let .event(evt):
|
||||
switch evt.event {
|
||||
case "health":
|
||||
guard let payload = evt.payload else { return }
|
||||
let payloadJSON = (try? JSONEncoder().encode(payload))
|
||||
.flatMap { String(data: $0, encoding: .utf8) }
|
||||
for nodeId in subscribedNodes {
|
||||
await self.connections[nodeId]?.sendServerEvent(event: "health", payloadJSON: payloadJSON)
|
||||
}
|
||||
case "tick":
|
||||
for nodeId in subscribedNodes {
|
||||
await self.connections[nodeId]?.sendServerEvent(event: "tick", payloadJSON: nil)
|
||||
}
|
||||
case "chat":
|
||||
guard let payload = evt.payload else { return }
|
||||
let payloadData = try? JSONEncoder().encode(payload)
|
||||
let payloadJSON = payloadData.flatMap { String(data: $0, encoding: .utf8) }
|
||||
|
||||
struct MinimalChat: Codable { var sessionKey: String }
|
||||
let sessionKey = payloadData.flatMap { try? JSONDecoder().decode(MinimalChat.self, from: $0) }?
|
||||
.sessionKey
|
||||
if let sessionKey {
|
||||
for nodeId in subscribedNodes {
|
||||
guard self.chatSubscriptions[nodeId]?.contains(sessionKey) == true else { continue }
|
||||
await self.connections[nodeId]?.sendServerEvent(event: "chat", payloadJSON: payloadJSON)
|
||||
}
|
||||
} else {
|
||||
for nodeId in subscribedNodes {
|
||||
await self.connections[nodeId]?.sendServerEvent(event: "chat", payloadJSON: payloadJSON)
|
||||
}
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
case .seqGap:
|
||||
for nodeId in subscribedNodes {
|
||||
await self.connections[nodeId]?.sendServerEvent(event: "seqGap", payloadJSON: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func beaconPresence(nodeId: String, reason: String) async {
|
||||
let paired = await self.store?.find(nodeId: nodeId)
|
||||
let host = paired?.displayName?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
|
||||
?? nodeId
|
||||
let version = paired?.version?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
|
||||
let platform = paired?.platform?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
|
||||
let ip = await self.connections[nodeId]?.remoteAddress()
|
||||
|
||||
var tags: [String] = ["node", "ios"]
|
||||
if let platform { tags.append(platform) }
|
||||
|
||||
let summary = [
|
||||
"Node: \(host)\(ip.map { " (\($0))" } ?? "")",
|
||||
platform.map { "platform \($0)" },
|
||||
version.map { "app \($0)" },
|
||||
"mode node",
|
||||
"reason \(reason)",
|
||||
].compactMap(\.self).joined(separator: " · ")
|
||||
|
||||
var params: [String: AnyCodable] = [
|
||||
"text": AnyCodable(summary),
|
||||
"instanceId": AnyCodable(nodeId),
|
||||
"host": AnyCodable(host),
|
||||
"mode": AnyCodable("node"),
|
||||
"reason": AnyCodable(reason),
|
||||
"tags": AnyCodable(tags),
|
||||
]
|
||||
if let ip { params["ip"] = AnyCodable(ip) }
|
||||
if let version { params["version"] = AnyCodable(version) }
|
||||
await GatewayConnection.shared.sendSystemEvent(params)
|
||||
}
|
||||
|
||||
private func startPresenceTask(nodeId: String) {
|
||||
self.presenceTasks[nodeId]?.cancel()
|
||||
self.presenceTasks[nodeId] = Task.detached { [weak self] in
|
||||
while !Task.isCancelled {
|
||||
try? await Task.sleep(nanoseconds: 180 * 1_000_000_000)
|
||||
if Task.isCancelled { return }
|
||||
await self?.beaconPresence(nodeId: nodeId, reason: "periodic")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func stopPresenceTask(nodeId: String) {
|
||||
self.presenceTasks[nodeId]?.cancel()
|
||||
self.presenceTasks.removeValue(forKey: nodeId)
|
||||
}
|
||||
|
||||
private func authorize(hello: BridgeHello) async -> BridgeConnectionHandler.AuthResult {
|
||||
let nodeId = hello.nodeId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if nodeId.isEmpty {
|
||||
return .error(code: "INVALID_REQUEST", message: "nodeId required")
|
||||
}
|
||||
guard let store = self.store else {
|
||||
return .error(code: "UNAVAILABLE", message: "store unavailable")
|
||||
}
|
||||
guard let paired = await store.find(nodeId: nodeId) else {
|
||||
return .notPaired
|
||||
}
|
||||
guard let token = hello.token, token == paired.token else {
|
||||
return .unauthorized
|
||||
}
|
||||
|
||||
do {
|
||||
var updated = paired
|
||||
let name = hello.displayName?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
|
||||
let platform = hello.platform?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
|
||||
let version = hello.version?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
|
||||
let deviceFamily = hello.deviceFamily?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
|
||||
let modelIdentifier = hello.modelIdentifier?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
|
||||
|
||||
if updated.displayName != name { updated.displayName = name }
|
||||
if updated.platform != platform { updated.platform = platform }
|
||||
if updated.version != version { updated.version = version }
|
||||
if updated.deviceFamily != deviceFamily { updated.deviceFamily = deviceFamily }
|
||||
if updated.modelIdentifier != modelIdentifier { updated.modelIdentifier = modelIdentifier }
|
||||
|
||||
if updated != paired {
|
||||
try await store.upsert(updated)
|
||||
} else {
|
||||
try await store.touchSeen(nodeId: nodeId)
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return .ok
|
||||
}
|
||||
|
||||
private func pair(request: BridgePairRequest) async -> BridgeConnectionHandler.PairResult {
|
||||
let nodeId = request.nodeId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if nodeId.isEmpty {
|
||||
return .error(code: "INVALID_REQUEST", message: "nodeId required")
|
||||
}
|
||||
guard let store = self.store else {
|
||||
return .error(code: "UNAVAILABLE", message: "store unavailable")
|
||||
}
|
||||
let existing = await store.find(nodeId: nodeId)
|
||||
|
||||
let approved = await BridgePairingApprover.approve(request: request, isRepair: existing != nil)
|
||||
if !approved {
|
||||
return .rejected
|
||||
}
|
||||
|
||||
let token = UUID().uuidString.replacingOccurrences(of: "-", with: "")
|
||||
let nowMs = Int(Date().timeIntervalSince1970 * 1000)
|
||||
let node = PairedNode(
|
||||
nodeId: nodeId,
|
||||
displayName: request.displayName,
|
||||
platform: request.platform,
|
||||
version: request.version,
|
||||
deviceFamily: request.deviceFamily,
|
||||
modelIdentifier: request.modelIdentifier,
|
||||
token: token,
|
||||
createdAtMs: nowMs,
|
||||
lastSeenAtMs: nowMs)
|
||||
do {
|
||||
try await store.upsert(node)
|
||||
return .ok(token: token)
|
||||
} catch {
|
||||
return .error(code: "UNAVAILABLE", message: "failed to persist pairing")
|
||||
}
|
||||
}
|
||||
|
||||
private static func defaultStoreURL() throws -> URL {
|
||||
let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
|
||||
guard let base else {
|
||||
throw NSError(
|
||||
domain: "Bridge",
|
||||
code: 1,
|
||||
userInfo: [NSLocalizedDescriptionKey: "Application Support unavailable"])
|
||||
}
|
||||
return base
|
||||
.appendingPathComponent("Clawdbot", isDirectory: true)
|
||||
.appendingPathComponent("bridge", isDirectory: true)
|
||||
.appendingPathComponent("paired-nodes.json", isDirectory: false)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
enum BridgePairingApprover {
|
||||
static func approve(request: BridgePairRequest, isRepair: Bool) async -> Bool {
|
||||
await withCheckedContinuation { cont in
|
||||
let name = request.displayName ?? request.nodeId
|
||||
let remote = request.remoteAddress?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
|
||||
let alert = NSAlert()
|
||||
alert.messageText = isRepair ? "Re-pair Clawdbot Node?" : "Pair Clawdbot Node?"
|
||||
alert.informativeText = """
|
||||
Node: \(name)
|
||||
IP: \(remote ?? "unknown")
|
||||
Platform: \(request.platform ?? "unknown")
|
||||
Version: \(request.version ?? "unknown")
|
||||
"""
|
||||
alert.addButton(withTitle: "Approve")
|
||||
alert.addButton(withTitle: "Reject")
|
||||
if #available(macOS 11.0, *), alert.buttons.indices.contains(1) {
|
||||
alert.buttons[1].hasDestructiveAction = true
|
||||
}
|
||||
let resp = alert.runModal()
|
||||
cont.resume(returning: resp == .alertFirstButtonReturn)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
extension BridgeServer {
|
||||
func exerciseForTesting() async {
|
||||
let conn = NWConnection(to: .hostPort(host: "127.0.0.1", port: 22), using: .tcp)
|
||||
let handler = BridgeConnectionHandler(connection: conn, logger: self.logger)
|
||||
self.connections["node-1"] = handler
|
||||
self.nodeInfoById["node-1"] = BridgeNodeInfo(
|
||||
nodeId: "node-1",
|
||||
displayName: "Node One",
|
||||
platform: "macOS",
|
||||
version: "1.0.0",
|
||||
deviceFamily: "Mac",
|
||||
modelIdentifier: "MacBookPro18,1",
|
||||
remoteAddress: "127.0.0.1",
|
||||
caps: ["chat", "voice"])
|
||||
|
||||
_ = self.connectedNodeIds()
|
||||
_ = self.connectedNodes()
|
||||
|
||||
self.handleListenerState(.ready)
|
||||
self.handleListenerState(.failed(NWError.posix(.ECONNREFUSED)))
|
||||
self.handleListenerState(.waiting(NWError.posix(.ETIMEDOUT)))
|
||||
self.handleListenerState(.cancelled)
|
||||
self.handleListenerState(.setup)
|
||||
|
||||
let subscribe = BridgeEventFrame(event: "chat.subscribe", payloadJSON: "{\"sessionKey\":\"main\"}")
|
||||
await self.handleEvent(nodeId: "node-1", evt: subscribe)
|
||||
|
||||
let unsubscribe = BridgeEventFrame(event: "chat.unsubscribe", payloadJSON: "{\"sessionKey\":\"main\"}")
|
||||
await self.handleEvent(nodeId: "node-1", evt: unsubscribe)
|
||||
|
||||
let invalid = BridgeRPCRequest(id: "req-1", method: "invalid.method", paramsJSON: nil)
|
||||
_ = await self.handleRequest(nodeId: "node-1", req: invalid)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
59
apps/macos/Sources/Clawdbot/Bridge/PairedNodesStore.swift
Normal file
59
apps/macos/Sources/Clawdbot/Bridge/PairedNodesStore.swift
Normal file
@@ -0,0 +1,59 @@
|
||||
import Foundation
|
||||
|
||||
struct PairedNode: Codable, Equatable {
|
||||
var nodeId: String
|
||||
var displayName: String?
|
||||
var platform: String?
|
||||
var version: String?
|
||||
var deviceFamily: String?
|
||||
var modelIdentifier: String?
|
||||
var token: String
|
||||
var createdAtMs: Int
|
||||
var lastSeenAtMs: Int?
|
||||
}
|
||||
|
||||
actor PairedNodesStore {
|
||||
private let fileURL: URL
|
||||
private var nodes: [String: PairedNode] = [:]
|
||||
|
||||
init(fileURL: URL) {
|
||||
self.fileURL = fileURL
|
||||
}
|
||||
|
||||
func load() {
|
||||
do {
|
||||
let data = try Data(contentsOf: self.fileURL)
|
||||
let decoded = try JSONDecoder().decode([String: PairedNode].self, from: data)
|
||||
self.nodes = decoded
|
||||
} catch {
|
||||
self.nodes = [:]
|
||||
}
|
||||
}
|
||||
|
||||
func all() -> [PairedNode] {
|
||||
self.nodes.values.sorted { a, b in (a.displayName ?? a.nodeId) < (b.displayName ?? b.nodeId) }
|
||||
}
|
||||
|
||||
func find(nodeId: String) -> PairedNode? {
|
||||
self.nodes[nodeId]
|
||||
}
|
||||
|
||||
func upsert(_ node: PairedNode) async throws {
|
||||
self.nodes[node.nodeId] = node
|
||||
try await self.persist()
|
||||
}
|
||||
|
||||
func touchSeen(nodeId: String) async throws {
|
||||
guard var node = self.nodes[nodeId] else { return }
|
||||
node.lastSeenAtMs = Int(Date().timeIntervalSince1970 * 1000)
|
||||
self.nodes[nodeId] = node
|
||||
try await self.persist()
|
||||
}
|
||||
|
||||
private func persist() async throws {
|
||||
let dir = self.fileURL.deletingLastPathComponent()
|
||||
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||
let data = try JSONEncoder().encode(self.nodes)
|
||||
try data.write(to: self.fileURL, options: [.atomic])
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user