clawdis-mac: fetch node list via gateway
parent
9f73131621
commit
d862ae17eb
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue