Bridge: advertise node capabilities

This commit is contained in:
Peter Steinberger
2025-12-17 20:03:27 +00:00
parent 0677567cdd
commit 079c1d8786
8 changed files with 97 additions and 19 deletions

View File

@@ -178,6 +178,10 @@ class NodeRuntime(context: Context) {
val resolved = val resolved =
if (storedToken.isNullOrBlank()) { if (storedToken.isNullOrBlank()) {
_statusText.value = "Pairing…" _statusText.value = "Pairing…"
val caps = buildList {
add("canvas")
if (cameraEnabled.value) add("camera")
}
BridgePairingClient().pairAndHello( BridgePairingClient().pairAndHello(
endpoint = endpoint, endpoint = endpoint,
hello = hello =
@@ -187,6 +191,7 @@ class NodeRuntime(context: Context) {
token = null, token = null,
platform = "Android", platform = "Android",
version = "dev", version = "dev",
caps = caps,
), ),
) )
} else { } else {
@@ -209,6 +214,11 @@ class NodeRuntime(context: Context) {
token = authToken, token = authToken,
platform = "Android", platform = "Android",
version = "dev", version = "dev",
caps =
buildList {
add("canvas")
if (cameraEnabled.value) add("camera")
},
), ),
) )
} }

View File

@@ -3,6 +3,7 @@ package com.steipete.clawdis.node.bridge
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.JsonNull import kotlinx.serialization.json.JsonNull
@@ -24,6 +25,7 @@ class BridgePairingClient {
val token: String?, val token: String?,
val platform: String?, val platform: String?,
val version: String?, val version: String?,
val caps: List<String>?,
) )
data class PairResult(val ok: Boolean, val token: String?, val error: String? = null) 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.token?.let { put("token", JsonPrimitive(it)) }
hello.platform?.let { put("platform", JsonPrimitive(it)) } hello.platform?.let { put("platform", JsonPrimitive(it)) }
hello.version?.let { put("version", 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.displayName?.let { put("displayName", JsonPrimitive(it)) }
hello.platform?.let { put("platform", JsonPrimitive(it)) } hello.platform?.let { put("platform", JsonPrimitive(it)) }
hello.version?.let { put("version", JsonPrimitive(it)) } hello.version?.let { put("version", JsonPrimitive(it)) }
hello.caps?.let { put("caps", JsonArray(it.map(::JsonPrimitive))) }
}, },
) )

View File

@@ -12,6 +12,7 @@ import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonNull import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonElement
@@ -39,6 +40,7 @@ class BridgeSession(
val token: String?, val token: String?,
val platform: String?, val platform: String?,
val version: String?, val version: String?,
val caps: List<String>?,
) )
data class InvokeRequest(val id: String, val command: String, val paramsJson: String?) 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.token?.let { put("token", JsonPrimitive(it)) }
hello.platform?.let { put("platform", JsonPrimitive(it)) } hello.platform?.let { put("platform", JsonPrimitive(it)) }
hello.version?.let { put("version", JsonPrimitive(it)) } hello.version?.let { put("version", JsonPrimitive(it)) }
hello.caps?.let { put("caps", JsonArray(it.map(::JsonPrimitive))) }
}, },
) )

View File

@@ -53,7 +53,8 @@ actor BridgeClient {
platform: hello.platform, platform: hello.platform,
version: hello.version, version: hello.version,
deviceFamily: hello.deviceFamily, deviceFamily: hello.deviceFamily,
modelIdentifier: hello.modelIdentifier), modelIdentifier: hello.modelIdentifier,
caps: hello.caps),
over: connection) over: connection)
onStatus?("Waiting for approval…") onStatus?("Waiting for approval…")

View File

@@ -134,7 +134,21 @@ final class BridgeConnectionController {
platform: self.platformString(), platform: self.platformString(),
version: self.appVersion(), version: self.appVersion(),
deviceFamily: self.deviceFamily(), 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 { private func platformString() -> String {

View File

@@ -3,6 +3,15 @@ import Foundation
import Network import Network
import OSLog 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 { actor BridgeConnectionHandler {
private let connection: NWConnection private let connection: NWConnection
private let logger: Logger private let logger: Logger
@@ -38,7 +47,7 @@ actor BridgeConnectionHandler {
var serverName: String var serverName: String
var resolveAuth: @Sendable (BridgeHello) async -> AuthResult var resolveAuth: @Sendable (BridgeHello) async -> AuthResult
var handlePair: @Sendable (BridgePairRequest) async -> PairResult 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 onEvent: (@Sendable (String, BridgeEventFrame) async -> Void)?
var onRequest: (@Sendable (String, BridgeRPCRequest) async -> BridgeRPCResponse)? var onRequest: (@Sendable (String, BridgeRPCRequest) async -> BridgeRPCResponse)?
} }
@@ -46,7 +55,7 @@ actor BridgeConnectionHandler {
func run( func run(
resolveAuth: @escaping @Sendable (BridgeHello) async -> AuthResult, resolveAuth: @escaping @Sendable (BridgeHello) async -> AuthResult,
handlePair: @escaping @Sendable (BridgePairRequest) async -> PairResult, handlePair: @escaping @Sendable (BridgePairRequest) async -> PairResult,
onAuthenticated: (@Sendable (String) async -> Void)? = nil, onAuthenticated: (@Sendable (BridgeNodeInfo) async -> Void)? = nil,
onDisconnected: (@Sendable (String) async -> Void)? = nil, onDisconnected: (@Sendable (String) async -> Void)? = nil,
onEvent: (@Sendable (String, BridgeEventFrame) async -> Void)? = nil, onEvent: (@Sendable (String, BridgeEventFrame) async -> Void)? = nil,
onRequest: (@Sendable (String, BridgeRPCRequest) async -> BridgeRPCResponse)? = nil) async onRequest: (@Sendable (String, BridgeRPCRequest) async -> BridgeRPCResponse)? = nil) async
@@ -125,11 +134,19 @@ actor BridgeConnectionHandler {
{ {
do { do {
let hello = try self.decoder.decode(BridgeHello.self, from: data) 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) let result = await context.resolveAuth(hello)
await self.handleAuthResult(result, serverName: context.serverName) await self.handleAuthResult(result, serverName: context.serverName)
if case .ok = result, let nodeId = self.nodeId { if case .ok = result {
await context.onAuthenticated?(nodeId) await context.onAuthenticated?(
BridgeNodeInfo(
nodeId: nodeId,
displayName: hello.displayName,
platform: hello.platform,
version: hello.version,
remoteAddress: self.remoteAddressString(),
caps: hello.caps))
} }
} catch { } catch {
await self.sendError(code: "INVALID_REQUEST", message: error.localizedDescription) await self.sendError(code: "INVALID_REQUEST", message: error.localizedDescription)
@@ -142,18 +159,27 @@ actor BridgeConnectionHandler {
{ {
do { do {
let req = try self.decoder.decode(BridgePairRequest.self, from: data) 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( let enriched = BridgePairRequest(
type: req.type, type: req.type,
nodeId: req.nodeId, nodeId: nodeId,
displayName: req.displayName, displayName: req.displayName,
platform: req.platform, platform: req.platform,
version: req.version, version: req.version,
caps: req.caps,
remoteAddress: self.remoteAddressString()) remoteAddress: self.remoteAddressString())
let result = await context.handlePair(enriched) let result = await context.handlePair(enriched)
await self.handlePairResult(result, serverName: context.serverName) await self.handlePairResult(result, serverName: context.serverName)
if case .ok = result, let nodeId = self.nodeId { if case .ok = result {
await context.onAuthenticated?(nodeId) await context.onAuthenticated?(
BridgeNodeInfo(
nodeId: nodeId,
displayName: enriched.displayName,
platform: enriched.platform,
version: enriched.version,
remoteAddress: enriched.remoteAddress,
caps: enriched.caps))
} }
} catch { } catch {
await self.sendError(code: "INVALID_REQUEST", message: error.localizedDescription) await self.sendError(code: "INVALID_REQUEST", message: error.localizedDescription)

View File

@@ -13,6 +13,7 @@ actor BridgeServer {
private var isRunning = false private var isRunning = false
private var store: PairedNodesStore? private var store: PairedNodesStore?
private var connections: [String: BridgeConnectionHandler] = [:] private var connections: [String: BridgeConnectionHandler] = [:]
private var nodeInfoById: [String: BridgeNodeInfo] = [:]
private var presenceTasks: [String: Task<Void, Never>] = [:] private var presenceTasks: [String: Task<Void, Never>] = [:]
private var chatSubscriptions: [String: Set<String>] = [:] private var chatSubscriptions: [String: Set<String>] = [:]
private var gatewayPushTask: Task<Void, Never>? private var gatewayPushTask: Task<Void, Never>?
@@ -73,7 +74,7 @@ actor BridgeServer {
} }
private func handle(connection: NWConnection) async { 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( await handler.run(
resolveAuth: { [weak self] hello in resolveAuth: { [weak self] hello in
await self?.authorize(hello: hello) ?? .error(code: "UNAVAILABLE", message: "bridge unavailable") await self?.authorize(hello: hello) ?? .error(code: "UNAVAILABLE", message: "bridge unavailable")
@@ -81,8 +82,8 @@ actor BridgeServer {
handlePair: { [weak self] request in handlePair: { [weak self] request in
await self?.pair(request: request) ?? .error(code: "UNAVAILABLE", message: "bridge unavailable") await self?.pair(request: request) ?? .error(code: "UNAVAILABLE", message: "bridge unavailable")
}, },
onAuthenticated: { [weak self] nodeId in onAuthenticated: { [weak self] node in
await self?.registerConnection(handler: handler, nodeId: nodeId) await self?.registerConnection(handler: handler, node: node)
}, },
onDisconnected: { [weak self] nodeId in onDisconnected: { [weak self] nodeId in
await self?.unregisterConnection(nodeId: nodeId) await self?.unregisterConnection(nodeId: nodeId)
@@ -112,10 +113,22 @@ actor BridgeServer {
Array(self.connections.keys).sorted() Array(self.connections.keys).sorted()
} }
private func registerConnection(handler: BridgeConnectionHandler, nodeId: String) async { func connectedNodes() -> [BridgeNodeInfo] {
self.connections[nodeId] = handler self.nodeInfoById.values.sorted { a, b in
await self.beaconPresence(nodeId: nodeId, reason: "connect") (a.displayName ?? a.nodeId) < (b.displayName ?? b.nodeId)
self.startPresenceTask(nodeId: 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() self.ensureGatewayPushTask()
} }
@@ -123,6 +136,7 @@ actor BridgeServer {
await self.beaconPresence(nodeId: nodeId, reason: "disconnect") await self.beaconPresence(nodeId: nodeId, reason: "disconnect")
self.stopPresenceTask(nodeId: nodeId) self.stopPresenceTask(nodeId: nodeId)
self.connections.removeValue(forKey: nodeId) self.connections.removeValue(forKey: nodeId)
self.nodeInfoById.removeValue(forKey: nodeId)
self.chatSubscriptions[nodeId] = nil self.chatSubscriptions[nodeId] = nil
self.stopGatewayPushTaskIfIdle() self.stopGatewayPushTaskIfIdle()
} }

View File

@@ -65,6 +65,7 @@ public struct BridgeHello: Codable, Sendable {
public let version: String? public let version: String?
public let deviceFamily: String? public let deviceFamily: String?
public let modelIdentifier: String? public let modelIdentifier: String?
public let caps: [String]?
public init( public init(
type: String = "hello", type: String = "hello",
@@ -74,7 +75,8 @@ public struct BridgeHello: Codable, Sendable {
platform: String?, platform: String?,
version: String?, version: String?,
deviceFamily: String? = nil, deviceFamily: String? = nil,
modelIdentifier: String? = nil) modelIdentifier: String? = nil,
caps: [String]? = nil)
{ {
self.type = type self.type = type
self.nodeId = nodeId self.nodeId = nodeId
@@ -84,6 +86,7 @@ public struct BridgeHello: Codable, Sendable {
self.version = version self.version = version
self.deviceFamily = deviceFamily self.deviceFamily = deviceFamily
self.modelIdentifier = modelIdentifier self.modelIdentifier = modelIdentifier
self.caps = caps
} }
} }
@@ -105,6 +108,7 @@ public struct BridgePairRequest: Codable, Sendable {
public let version: String? public let version: String?
public let deviceFamily: String? public let deviceFamily: String?
public let modelIdentifier: String? public let modelIdentifier: String?
public let caps: [String]?
public let remoteAddress: String? public let remoteAddress: String?
public init( public init(
@@ -115,6 +119,7 @@ public struct BridgePairRequest: Codable, Sendable {
version: String?, version: String?,
deviceFamily: String? = nil, deviceFamily: String? = nil,
modelIdentifier: String? = nil, modelIdentifier: String? = nil,
caps: [String]? = nil,
remoteAddress: String? = nil) remoteAddress: String? = nil)
{ {
self.type = type self.type = type
@@ -124,6 +129,7 @@ public struct BridgePairRequest: Codable, Sendable {
self.version = version self.version = version
self.deviceFamily = deviceFamily self.deviceFamily = deviceFamily
self.modelIdentifier = modelIdentifier self.modelIdentifier = modelIdentifier
self.caps = caps
self.remoteAddress = remoteAddress self.remoteAddress = remoteAddress
} }
} }