From 079c1d878673a711f5e892827da192dbccf38935 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 17 Dec 2025 20:03:27 +0000 Subject: [PATCH] Bridge: advertise node capabilities --- .../com/steipete/clawdis/node/NodeRuntime.kt | 10 +++++ .../node/bridge/BridgePairingClient.kt | 4 ++ .../clawdis/node/bridge/BridgeSession.kt | 3 ++ apps/ios/Sources/Bridge/BridgeClient.swift | 3 +- .../Bridge/BridgeConnectionController.swift | 16 ++++++- .../Bridge/BridgeConnectionHandler.swift | 44 +++++++++++++++---- .../Sources/Clawdis/Bridge/BridgeServer.swift | 28 +++++++++--- .../Sources/ClawdisKit/BridgeFrames.swift | 8 +++- 8 files changed, 97 insertions(+), 19 deletions(-) diff --git a/apps/android/app/src/main/java/com/steipete/clawdis/node/NodeRuntime.kt b/apps/android/app/src/main/java/com/steipete/clawdis/node/NodeRuntime.kt index 2e3b0e6fd..78ae2cde2 100644 --- a/apps/android/app/src/main/java/com/steipete/clawdis/node/NodeRuntime.kt +++ b/apps/android/app/src/main/java/com/steipete/clawdis/node/NodeRuntime.kt @@ -178,6 +178,10 @@ class NodeRuntime(context: Context) { val resolved = if (storedToken.isNullOrBlank()) { _statusText.value = "Pairing…" + val caps = buildList { + add("canvas") + if (cameraEnabled.value) add("camera") + } BridgePairingClient().pairAndHello( endpoint = endpoint, hello = @@ -187,6 +191,7 @@ class NodeRuntime(context: Context) { token = null, platform = "Android", version = "dev", + caps = caps, ), ) } else { @@ -209,6 +214,11 @@ class NodeRuntime(context: Context) { token = authToken, platform = "Android", version = "dev", + caps = + buildList { + add("canvas") + if (cameraEnabled.value) add("camera") + }, ), ) } diff --git a/apps/android/app/src/main/java/com/steipete/clawdis/node/bridge/BridgePairingClient.kt b/apps/android/app/src/main/java/com/steipete/clawdis/node/bridge/BridgePairingClient.kt index a52aa5dbd..09287b64c 100644 --- a/apps/android/app/src/main/java/com/steipete/clawdis/node/bridge/BridgePairingClient.kt +++ b/apps/android/app/src/main/java/com/steipete/clawdis/node/bridge/BridgePairingClient.kt @@ -3,6 +3,7 @@ package com.steipete.clawdis.node.bridge import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.JsonNull @@ -24,6 +25,7 @@ class BridgePairingClient { val token: String?, val platform: String?, val version: String?, + val caps: List?, ) data class PairResult(val ok: Boolean, val token: String?, val error: String? = null) @@ -55,6 +57,7 @@ class BridgePairingClient { hello.token?.let { put("token", JsonPrimitive(it)) } hello.platform?.let { put("platform", JsonPrimitive(it)) } hello.version?.let { put("version", JsonPrimitive(it)) } + hello.caps?.let { put("caps", JsonArray(it.map(::JsonPrimitive))) } }, ) @@ -76,6 +79,7 @@ class BridgePairingClient { hello.displayName?.let { put("displayName", JsonPrimitive(it)) } hello.platform?.let { put("platform", JsonPrimitive(it)) } hello.version?.let { put("version", JsonPrimitive(it)) } + hello.caps?.let { put("caps", JsonArray(it.map(::JsonPrimitive))) } }, ) diff --git a/apps/android/app/src/main/java/com/steipete/clawdis/node/bridge/BridgeSession.kt b/apps/android/app/src/main/java/com/steipete/clawdis/node/bridge/BridgeSession.kt index 9f949f9d8..7261be477 100644 --- a/apps/android/app/src/main/java/com/steipete/clawdis/node/bridge/BridgeSession.kt +++ b/apps/android/app/src/main/java/com/steipete/clawdis/node/bridge/BridgeSession.kt @@ -12,6 +12,7 @@ import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonNull import kotlinx.serialization.json.JsonElement @@ -39,6 +40,7 @@ class BridgeSession( val token: String?, val platform: String?, val version: String?, + val caps: List?, ) data class InvokeRequest(val id: String, val command: String, val paramsJson: String?) @@ -191,6 +193,7 @@ class BridgeSession( hello.token?.let { put("token", JsonPrimitive(it)) } hello.platform?.let { put("platform", JsonPrimitive(it)) } hello.version?.let { put("version", JsonPrimitive(it)) } + hello.caps?.let { put("caps", JsonArray(it.map(::JsonPrimitive))) } }, ) diff --git a/apps/ios/Sources/Bridge/BridgeClient.swift b/apps/ios/Sources/Bridge/BridgeClient.swift index 5b2c6fc9d..1493b6480 100644 --- a/apps/ios/Sources/Bridge/BridgeClient.swift +++ b/apps/ios/Sources/Bridge/BridgeClient.swift @@ -53,7 +53,8 @@ actor BridgeClient { platform: hello.platform, version: hello.version, deviceFamily: hello.deviceFamily, - modelIdentifier: hello.modelIdentifier), + modelIdentifier: hello.modelIdentifier, + caps: hello.caps), over: connection) onStatus?("Waiting for approval…") diff --git a/apps/ios/Sources/Bridge/BridgeConnectionController.swift b/apps/ios/Sources/Bridge/BridgeConnectionController.swift index 4e654fee4..f35f99a66 100644 --- a/apps/ios/Sources/Bridge/BridgeConnectionController.swift +++ b/apps/ios/Sources/Bridge/BridgeConnectionController.swift @@ -134,7 +134,21 @@ final class BridgeConnectionController { platform: self.platformString(), version: self.appVersion(), deviceFamily: self.deviceFamily(), - modelIdentifier: self.modelIdentifier()) + modelIdentifier: self.modelIdentifier(), + caps: self.currentCaps()) + } + + private func currentCaps() -> [String] { + var caps: [String] = ["canvas"] + + // Default-on: if the key doesn't exist yet, treat it as enabled. + let cameraEnabled = + UserDefaults.standard.object(forKey: "camera.enabled") == nil + ? true + : UserDefaults.standard.bool(forKey: "camera.enabled") + if cameraEnabled { caps.append("camera") } + + return caps } private func platformString() -> String { diff --git a/apps/macos/Sources/Clawdis/Bridge/BridgeConnectionHandler.swift b/apps/macos/Sources/Clawdis/Bridge/BridgeConnectionHandler.swift index 5a5ae6c5e..a29552ae8 100644 --- a/apps/macos/Sources/Clawdis/Bridge/BridgeConnectionHandler.swift +++ b/apps/macos/Sources/Clawdis/Bridge/BridgeConnectionHandler.swift @@ -3,6 +3,15 @@ import Foundation import Network import OSLog +struct BridgeNodeInfo: Sendable { + var nodeId: String + var displayName: String? + var platform: String? + var version: String? + var remoteAddress: String? + var caps: [String]? +} + actor BridgeConnectionHandler { private let connection: NWConnection private let logger: Logger @@ -38,7 +47,7 @@ actor BridgeConnectionHandler { var serverName: String var resolveAuth: @Sendable (BridgeHello) async -> AuthResult var handlePair: @Sendable (BridgePairRequest) async -> PairResult - var onAuthenticated: (@Sendable (String) async -> Void)? + var onAuthenticated: (@Sendable (BridgeNodeInfo) async -> Void)? var onEvent: (@Sendable (String, BridgeEventFrame) async -> Void)? var onRequest: (@Sendable (String, BridgeRPCRequest) async -> BridgeRPCResponse)? } @@ -46,7 +55,7 @@ actor BridgeConnectionHandler { func run( resolveAuth: @escaping @Sendable (BridgeHello) async -> AuthResult, handlePair: @escaping @Sendable (BridgePairRequest) async -> PairResult, - onAuthenticated: (@Sendable (String) async -> Void)? = nil, + 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 @@ -125,11 +134,19 @@ actor BridgeConnectionHandler { { do { let hello = try self.decoder.decode(BridgeHello.self, from: data) - self.nodeId = hello.nodeId + 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, let nodeId = self.nodeId { - await context.onAuthenticated?(nodeId) + if case .ok = result { + await context.onAuthenticated?( + BridgeNodeInfo( + nodeId: nodeId, + displayName: hello.displayName, + platform: hello.platform, + version: hello.version, + remoteAddress: self.remoteAddressString(), + caps: hello.caps)) } } catch { await self.sendError(code: "INVALID_REQUEST", message: error.localizedDescription) @@ -142,18 +159,27 @@ actor BridgeConnectionHandler { { do { let req = try self.decoder.decode(BridgePairRequest.self, from: data) - self.nodeId = req.nodeId + let nodeId = req.nodeId.trimmingCharacters(in: .whitespacesAndNewlines) + self.nodeId = nodeId let enriched = BridgePairRequest( type: req.type, - nodeId: req.nodeId, + nodeId: nodeId, displayName: req.displayName, platform: req.platform, version: req.version, + caps: req.caps, remoteAddress: self.remoteAddressString()) let result = await context.handlePair(enriched) await self.handlePairResult(result, serverName: context.serverName) - if case .ok = result, let nodeId = self.nodeId { - await context.onAuthenticated?(nodeId) + if case .ok = result { + await context.onAuthenticated?( + BridgeNodeInfo( + nodeId: nodeId, + displayName: enriched.displayName, + platform: enriched.platform, + version: enriched.version, + remoteAddress: enriched.remoteAddress, + caps: enriched.caps)) } } catch { await self.sendError(code: "INVALID_REQUEST", message: error.localizedDescription) diff --git a/apps/macos/Sources/Clawdis/Bridge/BridgeServer.swift b/apps/macos/Sources/Clawdis/Bridge/BridgeServer.swift index 897684b50..9a9df3118 100644 --- a/apps/macos/Sources/Clawdis/Bridge/BridgeServer.swift +++ b/apps/macos/Sources/Clawdis/Bridge/BridgeServer.swift @@ -13,6 +13,7 @@ actor BridgeServer { private var isRunning = false private var store: PairedNodesStore? private var connections: [String: BridgeConnectionHandler] = [:] + private var nodeInfoById: [String: BridgeNodeInfo] = [:] private var presenceTasks: [String: Task] = [:] private var chatSubscriptions: [String: Set] = [:] private var gatewayPushTask: Task? @@ -73,7 +74,7 @@ actor BridgeServer { } private func handle(connection: NWConnection) async { - let handler = BridgeConnectionHandler(connection: connection, logger: self.logger) + 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") @@ -81,8 +82,8 @@ actor BridgeServer { handlePair: { [weak self] request in await self?.pair(request: request) ?? .error(code: "UNAVAILABLE", message: "bridge unavailable") }, - onAuthenticated: { [weak self] nodeId in - await self?.registerConnection(handler: handler, nodeId: nodeId) + onAuthenticated: { [weak self] node in + await self?.registerConnection(handler: handler, node: node) }, onDisconnected: { [weak self] nodeId in await self?.unregisterConnection(nodeId: nodeId) @@ -112,10 +113,22 @@ actor BridgeServer { Array(self.connections.keys).sorted() } - private func registerConnection(handler: BridgeConnectionHandler, nodeId: String) async { - self.connections[nodeId] = handler - await self.beaconPresence(nodeId: nodeId, reason: "connect") - self.startPresenceTask(nodeId: nodeId) + 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() } @@ -123,6 +136,7 @@ actor BridgeServer { 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() } diff --git a/apps/shared/ClawdisKit/Sources/ClawdisKit/BridgeFrames.swift b/apps/shared/ClawdisKit/Sources/ClawdisKit/BridgeFrames.swift index 666d2df25..9c0926d2b 100644 --- a/apps/shared/ClawdisKit/Sources/ClawdisKit/BridgeFrames.swift +++ b/apps/shared/ClawdisKit/Sources/ClawdisKit/BridgeFrames.swift @@ -65,6 +65,7 @@ public struct BridgeHello: Codable, Sendable { public let version: String? public let deviceFamily: String? public let modelIdentifier: String? + public let caps: [String]? public init( type: String = "hello", @@ -74,7 +75,8 @@ public struct BridgeHello: Codable, Sendable { platform: String?, version: String?, deviceFamily: String? = nil, - modelIdentifier: String? = nil) + modelIdentifier: String? = nil, + caps: [String]? = nil) { self.type = type self.nodeId = nodeId @@ -84,6 +86,7 @@ public struct BridgeHello: Codable, Sendable { self.version = version self.deviceFamily = deviceFamily self.modelIdentifier = modelIdentifier + self.caps = caps } } @@ -105,6 +108,7 @@ public struct BridgePairRequest: Codable, Sendable { public let version: String? public let deviceFamily: String? public let modelIdentifier: String? + public let caps: [String]? public let remoteAddress: String? public init( @@ -115,6 +119,7 @@ public struct BridgePairRequest: Codable, Sendable { version: String?, deviceFamily: String? = nil, modelIdentifier: String? = nil, + caps: [String]? = nil, remoteAddress: String? = nil) { self.type = type @@ -124,6 +129,7 @@ public struct BridgePairRequest: Codable, Sendable { self.version = version self.deviceFamily = deviceFamily self.modelIdentifier = modelIdentifier + self.caps = caps self.remoteAddress = remoteAddress } }