clawdis-mac: fetch node list via gateway

main
Peter Steinberger 2025-12-18 00:12:12 +00:00
parent 9f73131621
commit d862ae17eb
2 changed files with 108 additions and 135 deletions

View File

@ -24,6 +24,23 @@ enum ControlRequestHandler {
var nodes: [NodeListNode] var nodes: [NodeListNode]
} }
struct GatewayNodeListPayload: Decodable {
struct Node: Decodable {
var nodeId: String
var displayName: String?
var platform: String?
var version: String?
var deviceFamily: String?
var modelIdentifier: String?
var remoteIp: String?
var connected: Bool?
var caps: [String]?
}
var ts: Int?
var nodes: [Node]
}
static func process( static func process(
request: Request, request: Request,
notifier: NotificationManager = NotificationManager(), notifier: NotificationManager = NotificationManager(),
@ -413,58 +430,50 @@ enum ControlRequestHandler {
} }
private static func handleNodeList() async -> Response { private static func handleNodeList() async -> Response {
let paired = await BridgeServer.shared.pairedNodes() do {
let connected = await BridgeServer.shared.connectedNodes() let data = try await GatewayConnection.shared.request(
let result = self.buildNodeListResult(paired: paired, connected: connected) method: "node.list",
params: [:],
timeoutMs: 10_000)
let payload = try JSONDecoder().decode(GatewayNodeListPayload.self, from: data)
let result = self.buildNodeListResult(payload: payload)
let encoder = JSONEncoder() let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys] encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
let payload = (try? encoder.encode(result)) let json = (try? encoder.encode(result))
.flatMap { String(data: $0, encoding: .utf8) } ?? "{}" .flatMap { String(data: $0, encoding: .utf8) } ?? "{}"
return Response(ok: true, payload: Data(payload.utf8)) return Response(ok: true, payload: Data(json.utf8))
} catch {
return Response(ok: false, message: error.localizedDescription)
}
} }
static func buildNodeListResult(paired: [PairedNode], connected: [BridgeNodeInfo]) -> NodeListResult { static func buildNodeListResult(payload: GatewayNodeListPayload) -> NodeListResult {
let connectedById = Dictionary(uniqueKeysWithValues: connected.map { ($0.nodeId, $0) }) let nodes = payload.nodes.map { n -> NodeListNode in
NodeListNode(
var nodesById: [String: NodeListNode] = [:] nodeId: n.nodeId,
displayName: n.displayName,
for p in paired { platform: n.platform,
let live = connectedById[p.nodeId] version: n.version,
nodesById[p.nodeId] = NodeListNode( deviceFamily: n.deviceFamily,
nodeId: p.nodeId, modelIdentifier: n.modelIdentifier,
displayName: (live?.displayName ?? p.displayName), remoteAddress: n.remoteIp,
platform: (live?.platform ?? p.platform), connected: n.connected == true,
version: (live?.version ?? p.version), capabilities: n.caps)
deviceFamily: (live?.deviceFamily ?? p.deviceFamily),
modelIdentifier: (live?.modelIdentifier ?? p.modelIdentifier),
remoteAddress: live?.remoteAddress,
connected: live != nil,
capabilities: live?.caps)
} }
for c in connected where nodesById[c.nodeId] == nil { let sorted = nodes.sorted { a, b in
nodesById[c.nodeId] = NodeListNode(
nodeId: c.nodeId,
displayName: c.displayName,
platform: c.platform,
version: c.version,
deviceFamily: c.deviceFamily,
modelIdentifier: c.modelIdentifier,
remoteAddress: c.remoteAddress,
connected: true,
capabilities: c.caps)
}
let nodes = nodesById.values.sorted { a, b in
(a.displayName ?? a.nodeId) < (b.displayName ?? b.nodeId) (a.displayName ?? a.nodeId) < (b.displayName ?? b.nodeId)
} }
let pairedNodeIds = sorted.map(\.nodeId).sorted()
let connectedNodeIds = sorted.filter(\.connected).map(\.nodeId).sorted()
return NodeListResult( return NodeListResult(
ts: Int(Date().timeIntervalSince1970 * 1000), ts: payload.ts ?? Int(Date().timeIntervalSince1970 * 1000),
connectedNodeIds: connected.map(\.nodeId).sorted(), connectedNodeIds: connectedNodeIds,
pairedNodeIds: paired.map(\.nodeId).sorted(), pairedNodeIds: pairedNodeIds,
nodes: nodes) nodes: sorted)
} }
private static func handleNodeInvoke( private static func handleNodeInvoke(
@ -474,13 +483,30 @@ enum ControlRequestHandler {
logger: Logger) async -> Response logger: Logger) async -> Response
{ {
do { do {
let res = try await BridgeServer.shared.invoke(nodeId: nodeId, command: command, paramsJSON: paramsJSON) var paramsObj: Any? = nil
if res.ok { let raw = (paramsJSON ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
let payload = res.payloadJSON ?? "" if !raw.isEmpty {
return Response(ok: true, payload: Data(payload.utf8)) if let data = raw.data(using: .utf8) {
paramsObj = try JSONSerialization.jsonObject(with: data)
} else {
return Response(ok: false, message: "params-json not UTF-8")
}
} }
let errText = res.error?.message ?? "node invoke failed"
return Response(ok: false, message: errText) var params: [String: AnyCodable] = [
"nodeId": AnyCodable(nodeId),
"command": AnyCodable(command),
"idempotencyKey": AnyCodable(UUID().uuidString),
]
if let paramsObj {
params["params"] = AnyCodable(paramsObj)
}
let data = try await GatewayConnection.shared.request(
method: "node.invoke",
params: params,
timeoutMs: 30_000)
return Response(ok: true, payload: data)
} catch { } catch {
logger.error("node invoke failed: \(error.localizedDescription, privacy: .public)") logger.error("node invoke failed: \(error.localizedDescription, privacy: .public)")
return Response(ok: false, message: error.localizedDescription) return Response(ok: false, message: error.localizedDescription)

View File

@ -2,95 +2,42 @@ import Testing
@testable import Clawdis @testable import Clawdis
@Suite struct NodeListTests { @Suite struct NodeListTests {
@Test func nodeListMergesPairedAndConnectedPreferringConnectedMetadata() async { @Test func nodeListMapsGatewayPayloadIncludingHardwareAndCaps() async {
let paired = PairedNode( let payload = ControlRequestHandler.GatewayNodeListPayload(
nodeId: "n1", ts: 123,
displayName: "Paired Name", nodes: [
platform: "iOS 1", ControlRequestHandler.GatewayNodeListPayload.Node(
version: "1.0", nodeId: "n1",
deviceFamily: "iPhone", displayName: "Iris",
modelIdentifier: "iPhone0,0", platform: "iOS",
token: "token", version: "1.0",
createdAtMs: 1, deviceFamily: "iPad",
lastSeenAtMs: nil) modelIdentifier: "iPad14,5",
remoteIp: "192.168.0.88",
connected: true,
caps: ["canvas", "camera"]),
ControlRequestHandler.GatewayNodeListPayload.Node(
nodeId: "n2",
displayName: "Offline",
platform: "iOS",
version: "1.0",
deviceFamily: "iPhone",
modelIdentifier: "iPhone14,2",
remoteIp: nil,
connected: false,
caps: nil),
])
let connected = BridgeNodeInfo( let res = ControlRequestHandler.buildNodeListResult(payload: payload)
nodeId: "n1",
displayName: "Live Name",
platform: "iOS 2",
version: "2.0",
deviceFamily: "iPhone",
modelIdentifier: "iPhone14,2",
remoteAddress: "10.0.0.1",
caps: ["canvas", "camera"])
let res = ControlRequestHandler.buildNodeListResult(paired: [paired], connected: [connected]) #expect(res.ts == 123)
#expect(res.pairedNodeIds.sorted() == ["n1", "n2"])
#expect(res.pairedNodeIds == ["n1"])
#expect(res.connectedNodeIds == ["n1"]) #expect(res.connectedNodeIds == ["n1"])
#expect(res.nodes.count == 1)
let node = res.nodes.first { $0.nodeId == "n1" } let iris = res.nodes.first { $0.nodeId == "n1" }
#expect(node != nil) #expect(iris?.remoteAddress == "192.168.0.88")
#expect(node?.displayName == "Live Name") #expect(iris?.deviceFamily == "iPad")
#expect(node?.platform == "iOS 2") #expect(iris?.modelIdentifier == "iPad14,5")
#expect(node?.version == "2.0") #expect(iris?.capabilities?.sorted() == ["camera", "canvas"])
#expect(node?.deviceFamily == "iPhone")
#expect(node?.modelIdentifier == "iPhone14,2")
#expect(node?.remoteAddress == "10.0.0.1")
#expect(node?.connected == true)
#expect(node?.capabilities?.sorted() == ["camera", "canvas"])
}
@Test func nodeListIncludesConnectedOnlyNodes() async {
let connected = BridgeNodeInfo(
nodeId: "n2",
displayName: "Android Node",
platform: "Android",
version: "dev",
deviceFamily: "Android",
modelIdentifier: "Pixel",
remoteAddress: "192.168.0.10",
caps: ["canvas"])
let res = ControlRequestHandler.buildNodeListResult(paired: [], connected: [connected])
#expect(res.pairedNodeIds == [])
#expect(res.connectedNodeIds == ["n2"])
#expect(res.nodes.count == 1)
let node = res.nodes.first { $0.nodeId == "n2" }
#expect(node != nil)
#expect(node?.connected == true)
#expect(node?.capabilities == ["canvas"])
#expect(node?.deviceFamily == "Android")
#expect(node?.modelIdentifier == "Pixel")
}
@Test func nodeListIncludesPairedDisconnectedNodesWithoutCapabilities() async {
let paired = PairedNode(
nodeId: "n3",
displayName: "Offline Node",
platform: "iOS",
version: "1.2.3",
deviceFamily: "iPad",
modelIdentifier: "iPad1,1",
token: "token",
createdAtMs: 1,
lastSeenAtMs: nil)
let res = ControlRequestHandler.buildNodeListResult(paired: [paired], connected: [])
#expect(res.pairedNodeIds == ["n3"])
#expect(res.connectedNodeIds == [])
#expect(res.nodes.count == 1)
let node = res.nodes.first { $0.nodeId == "n3" }
#expect(node != nil)
#expect(node?.connected == false)
#expect(node?.capabilities == nil)
#expect(node?.remoteAddress == nil)
#expect(node?.deviceFamily == "iPad")
#expect(node?.modelIdentifier == "iPad1,1")
} }
} }