Bridge: advertise node capabilities
This commit is contained in:
@@ -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")
|
||||||
|
},
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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))) }
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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))) }
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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…")
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user