diff --git a/apps/macos/Sources/Clawdis/ControlRequestHandler.swift b/apps/macos/Sources/Clawdis/ControlRequestHandler.swift index 68edbdb44..6a8e78b5d 100644 --- a/apps/macos/Sources/Clawdis/ControlRequestHandler.swift +++ b/apps/macos/Sources/Clawdis/ControlRequestHandler.swift @@ -24,6 +24,23 @@ enum ControlRequestHandler { 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( request: Request, notifier: NotificationManager = NotificationManager(), @@ -413,58 +430,50 @@ enum ControlRequestHandler { } private static func handleNodeList() async -> Response { - let paired = await BridgeServer.shared.pairedNodes() - let connected = await BridgeServer.shared.connectedNodes() - let result = self.buildNodeListResult(paired: paired, connected: connected) + do { + let data = try await GatewayConnection.shared.request( + 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() - encoder.outputFormatting = [.prettyPrinted, .sortedKeys] - let payload = (try? encoder.encode(result)) - .flatMap { String(data: $0, encoding: .utf8) } ?? "{}" - return Response(ok: true, payload: Data(payload.utf8)) + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + let json = (try? encoder.encode(result)) + .flatMap { String(data: $0, encoding: .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 { - let connectedById = Dictionary(uniqueKeysWithValues: connected.map { ($0.nodeId, $0) }) - - var nodesById: [String: NodeListNode] = [:] - - for p in paired { - let live = connectedById[p.nodeId] - nodesById[p.nodeId] = NodeListNode( - nodeId: p.nodeId, - displayName: (live?.displayName ?? p.displayName), - platform: (live?.platform ?? p.platform), - version: (live?.version ?? p.version), - deviceFamily: (live?.deviceFamily ?? p.deviceFamily), - modelIdentifier: (live?.modelIdentifier ?? p.modelIdentifier), - remoteAddress: live?.remoteAddress, - connected: live != nil, - capabilities: live?.caps) + static func buildNodeListResult(payload: GatewayNodeListPayload) -> NodeListResult { + let nodes = payload.nodes.map { n -> NodeListNode in + NodeListNode( + nodeId: n.nodeId, + displayName: n.displayName, + platform: n.platform, + version: n.version, + deviceFamily: n.deviceFamily, + modelIdentifier: n.modelIdentifier, + remoteAddress: n.remoteIp, + connected: n.connected == true, + capabilities: n.caps) } - for c in connected where nodesById[c.nodeId] == nil { - 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 + let sorted = nodes.sorted { a, b in (a.displayName ?? a.nodeId) < (b.displayName ?? b.nodeId) } + let pairedNodeIds = sorted.map(\.nodeId).sorted() + let connectedNodeIds = sorted.filter(\.connected).map(\.nodeId).sorted() + return NodeListResult( - ts: Int(Date().timeIntervalSince1970 * 1000), - connectedNodeIds: connected.map(\.nodeId).sorted(), - pairedNodeIds: paired.map(\.nodeId).sorted(), - nodes: nodes) + ts: payload.ts ?? Int(Date().timeIntervalSince1970 * 1000), + connectedNodeIds: connectedNodeIds, + pairedNodeIds: pairedNodeIds, + nodes: sorted) } private static func handleNodeInvoke( @@ -474,13 +483,30 @@ enum ControlRequestHandler { logger: Logger) async -> Response { do { - let res = try await BridgeServer.shared.invoke(nodeId: nodeId, command: command, paramsJSON: paramsJSON) - if res.ok { - let payload = res.payloadJSON ?? "" - return Response(ok: true, payload: Data(payload.utf8)) + var paramsObj: Any? = nil + let raw = (paramsJSON ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + if !raw.isEmpty { + 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 { logger.error("node invoke failed: \(error.localizedDescription, privacy: .public)") return Response(ok: false, message: error.localizedDescription) diff --git a/apps/macos/Tests/ClawdisIPCTests/NodeListTests.swift b/apps/macos/Tests/ClawdisIPCTests/NodeListTests.swift index b1d357410..0795ce57d 100644 --- a/apps/macos/Tests/ClawdisIPCTests/NodeListTests.swift +++ b/apps/macos/Tests/ClawdisIPCTests/NodeListTests.swift @@ -2,95 +2,42 @@ import Testing @testable import Clawdis @Suite struct NodeListTests { - @Test func nodeListMergesPairedAndConnectedPreferringConnectedMetadata() async { - let paired = PairedNode( - nodeId: "n1", - displayName: "Paired Name", - platform: "iOS 1", - version: "1.0", - deviceFamily: "iPhone", - modelIdentifier: "iPhone0,0", - token: "token", - createdAtMs: 1, - lastSeenAtMs: nil) + @Test func nodeListMapsGatewayPayloadIncludingHardwareAndCaps() async { + let payload = ControlRequestHandler.GatewayNodeListPayload( + ts: 123, + nodes: [ + ControlRequestHandler.GatewayNodeListPayload.Node( + nodeId: "n1", + displayName: "Iris", + platform: "iOS", + version: "1.0", + deviceFamily: "iPad", + 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( - 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(payload: payload) - let res = ControlRequestHandler.buildNodeListResult(paired: [paired], connected: [connected]) - - #expect(res.pairedNodeIds == ["n1"]) + #expect(res.ts == 123) + #expect(res.pairedNodeIds.sorted() == ["n1", "n2"]) #expect(res.connectedNodeIds == ["n1"]) - #expect(res.nodes.count == 1) - let node = res.nodes.first { $0.nodeId == "n1" } - #expect(node != nil) - #expect(node?.displayName == "Live Name") - #expect(node?.platform == "iOS 2") - #expect(node?.version == "2.0") - #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") + let iris = res.nodes.first { $0.nodeId == "n1" } + #expect(iris?.remoteAddress == "192.168.0.88") + #expect(iris?.deviceFamily == "iPad") + #expect(iris?.modelIdentifier == "iPad14,5") + #expect(iris?.capabilities?.sorted() == ["camera", "canvas"]) } }