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 =
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")
},
),
)
}

View File

@@ -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<String>?,
)
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))) }
},
)

View File

@@ -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<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.platform?.let { put("platform", 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,
version: hello.version,
deviceFamily: hello.deviceFamily,
modelIdentifier: hello.modelIdentifier),
modelIdentifier: hello.modelIdentifier,
caps: hello.caps),
over: connection)
onStatus?("Waiting for approval…")

View File

@@ -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 {

View File

@@ -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)

View File

@@ -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<Void, Never>] = [:]
private var chatSubscriptions: [String: Set<String>] = [:]
private var gatewayPushTask: Task<Void, Never>?
@@ -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()
}

View File

@@ -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
}
}