fix(presence): report bridged iOS nodes
parent
5118ba3dd2
commit
21649d81d2
|
|
@ -31,6 +31,19 @@ struct AnyCodable: Codable, @unchecked Sendable {
|
||||||
case is NSNull: try container.encodeNil()
|
case is NSNull: try container.encodeNil()
|
||||||
case let dict as [String: AnyCodable]: try container.encode(dict)
|
case let dict as [String: AnyCodable]: try container.encode(dict)
|
||||||
case let array as [AnyCodable]: try container.encode(array)
|
case let array as [AnyCodable]: try container.encode(array)
|
||||||
|
case let dict as [String: Any]:
|
||||||
|
try container.encode(dict.mapValues { AnyCodable($0) })
|
||||||
|
case let array as [Any]:
|
||||||
|
try container.encode(array.map { AnyCodable($0) })
|
||||||
|
case let dict as NSDictionary:
|
||||||
|
var converted: [String: AnyCodable] = [:]
|
||||||
|
for (k, v) in dict {
|
||||||
|
guard let key = k as? String else { continue }
|
||||||
|
converted[key] = AnyCodable(v)
|
||||||
|
}
|
||||||
|
try container.encode(converted)
|
||||||
|
case let array as NSArray:
|
||||||
|
try container.encode(array.map { AnyCodable($0) })
|
||||||
default:
|
default:
|
||||||
let context = EncodingError.Context(
|
let context = EncodingError.Context(
|
||||||
codingPath: encoder.codingPath,
|
codingPath: encoder.codingPath,
|
||||||
|
|
|
||||||
|
|
@ -131,6 +131,10 @@ actor BridgeConnectionHandler {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func remoteAddress() -> String? {
|
||||||
|
self.remoteAddressString()
|
||||||
|
}
|
||||||
|
|
||||||
private func handlePairResult(_ result: PairResult, serverName: String) async {
|
private func handlePairResult(_ result: PairResult, serverName: String) async {
|
||||||
switch result {
|
switch result {
|
||||||
case let .ok(token):
|
case let .ok(token):
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,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 presenceTasks: [String: Task<Void, Never>] = [:]
|
||||||
|
|
||||||
func start() async {
|
func start() async {
|
||||||
if self.isRunning { return }
|
if self.isRunning { return }
|
||||||
|
|
@ -110,12 +111,14 @@ actor BridgeServer {
|
||||||
|
|
||||||
private func registerConnection(handler: BridgeConnectionHandler, nodeId: String) async {
|
private func registerConnection(handler: BridgeConnectionHandler, nodeId: String) async {
|
||||||
self.connections[nodeId] = handler
|
self.connections[nodeId] = handler
|
||||||
await self.beacon(text: "Node connected", nodeId: nodeId, tags: ["node", "ios"])
|
await self.beaconPresence(nodeId: nodeId, reason: "connect")
|
||||||
|
self.startPresenceTask(nodeId: nodeId)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func unregisterConnection(nodeId: String) async {
|
private func unregisterConnection(nodeId: String) async {
|
||||||
|
await self.beaconPresence(nodeId: nodeId, reason: "disconnect")
|
||||||
|
self.stopPresenceTask(nodeId: nodeId)
|
||||||
self.connections.removeValue(forKey: nodeId)
|
self.connections.removeValue(forKey: nodeId)
|
||||||
await self.beacon(text: "Node disconnected", nodeId: nodeId, tags: ["node", "ios"])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct VoiceTranscriptPayload: Codable, Sendable {
|
private struct VoiceTranscriptPayload: Codable, Sendable {
|
||||||
|
|
@ -175,14 +178,36 @@ actor BridgeServer {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func beacon(text: String, nodeId: String, tags: [String]) async {
|
private func beaconPresence(nodeId: String, reason: String) async {
|
||||||
do {
|
do {
|
||||||
let params: [String: Any] = [
|
let paired = await self.store?.find(nodeId: nodeId)
|
||||||
"text": "\(text): \(nodeId)",
|
let host = paired?.displayName?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
|
||||||
|
?? nodeId
|
||||||
|
let version = paired?.version?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
|
||||||
|
let platform = paired?.platform?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
|
||||||
|
let ip = await self.connections[nodeId]?.remoteAddress()
|
||||||
|
|
||||||
|
var tags: [String] = ["node", "ios"]
|
||||||
|
if let platform { tags.append(platform) }
|
||||||
|
|
||||||
|
let summary = [
|
||||||
|
"Node: \(host)\(ip.map { " (\($0))" } ?? "")",
|
||||||
|
platform.map { "platform \($0)" },
|
||||||
|
version.map { "app \($0)" },
|
||||||
|
"mode node",
|
||||||
|
"reason \(reason)",
|
||||||
|
].compactMap(\.self).joined(separator: " · ")
|
||||||
|
|
||||||
|
var params: [String: Any] = [
|
||||||
|
"text": summary,
|
||||||
"instanceId": nodeId,
|
"instanceId": nodeId,
|
||||||
|
"host": host,
|
||||||
"mode": "node",
|
"mode": "node",
|
||||||
|
"reason": reason,
|
||||||
"tags": tags,
|
"tags": tags,
|
||||||
]
|
]
|
||||||
|
if let ip { params["ip"] = ip }
|
||||||
|
if let version { params["version"] = version }
|
||||||
_ = try await AgentRPC.shared.controlRequest(
|
_ = try await AgentRPC.shared.controlRequest(
|
||||||
method: "system-event",
|
method: "system-event",
|
||||||
params: ControlRequestParams(raw: params))
|
params: ControlRequestParams(raw: params))
|
||||||
|
|
@ -191,6 +216,22 @@ actor BridgeServer {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func startPresenceTask(nodeId: String) {
|
||||||
|
self.presenceTasks[nodeId]?.cancel()
|
||||||
|
self.presenceTasks[nodeId] = Task.detached { [weak self] in
|
||||||
|
while !Task.isCancelled {
|
||||||
|
try? await Task.sleep(nanoseconds: 180 * 1_000_000_000)
|
||||||
|
if Task.isCancelled { return }
|
||||||
|
await self?.beaconPresence(nodeId: nodeId, reason: "periodic")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func stopPresenceTask(nodeId: String) {
|
||||||
|
self.presenceTasks[nodeId]?.cancel()
|
||||||
|
self.presenceTasks.removeValue(forKey: nodeId)
|
||||||
|
}
|
||||||
|
|
||||||
private func authorize(hello: BridgeHello) async -> BridgeConnectionHandler.AuthResult {
|
private func authorize(hello: BridgeHello) async -> BridgeConnectionHandler.AuthResult {
|
||||||
let nodeId = hello.nodeId.trimmingCharacters(in: .whitespacesAndNewlines)
|
let nodeId = hello.nodeId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
if nodeId.isEmpty {
|
if nodeId.isEmpty {
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,19 @@ public struct AnyCodable: Codable, @unchecked Sendable {
|
||||||
case is NSNull: try container.encodeNil()
|
case is NSNull: try container.encodeNil()
|
||||||
case let dict as [String: AnyCodable]: try container.encode(dict)
|
case let dict as [String: AnyCodable]: try container.encode(dict)
|
||||||
case let array as [AnyCodable]: try container.encode(array)
|
case let array as [AnyCodable]: try container.encode(array)
|
||||||
|
case let dict as [String: Any]:
|
||||||
|
try container.encode(dict.mapValues { AnyCodable($0) })
|
||||||
|
case let array as [Any]:
|
||||||
|
try container.encode(array.map { AnyCodable($0) })
|
||||||
|
case let dict as NSDictionary:
|
||||||
|
var converted: [String: AnyCodable] = [:]
|
||||||
|
for (k, v) in dict {
|
||||||
|
guard let key = k as? String else { continue }
|
||||||
|
converted[key] = AnyCodable(v)
|
||||||
|
}
|
||||||
|
try container.encode(converted)
|
||||||
|
case let array as NSArray:
|
||||||
|
try container.encode(array.map { AnyCodable($0) })
|
||||||
default:
|
default:
|
||||||
let context = EncodingError.Context(
|
let context = EncodingError.Context(
|
||||||
codingPath: encoder.codingPath,
|
codingPath: encoder.codingPath,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
import ClawdisProtocol
|
||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
|
||||||
|
@testable import Clawdis
|
||||||
|
|
||||||
|
@Suite struct AnyCodableEncodingTests {
|
||||||
|
@Test func encodesSwiftArrayAndDictionaryValues() throws {
|
||||||
|
let payload: [String: Any] = [
|
||||||
|
"tags": ["node", "ios"],
|
||||||
|
"meta": ["count": 2],
|
||||||
|
"null": NSNull(),
|
||||||
|
]
|
||||||
|
|
||||||
|
let data = try JSONEncoder().encode(Clawdis.AnyCodable(payload))
|
||||||
|
let obj = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any])
|
||||||
|
|
||||||
|
#expect(obj["tags"] as? [String] == ["node", "ios"])
|
||||||
|
#expect((obj["meta"] as? [String: Any])?["count"] as? Int == 2)
|
||||||
|
#expect(obj["null"] is NSNull)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func protocolAnyCodableEncodesPrimitiveArrays() throws {
|
||||||
|
let payload: [String: Any] = [
|
||||||
|
"items": [1, "two", NSNull(), ["ok": true]],
|
||||||
|
]
|
||||||
|
|
||||||
|
let data = try JSONEncoder().encode(ClawdisProtocol.AnyCodable(payload))
|
||||||
|
let obj = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any])
|
||||||
|
|
||||||
|
let items = try #require(obj["items"] as? [Any])
|
||||||
|
#expect(items.count == 4)
|
||||||
|
#expect(items[0] as? Int == 1)
|
||||||
|
#expect(items[1] as? String == "two")
|
||||||
|
#expect(items[2] is NSNull)
|
||||||
|
#expect((items[3] as? [String: Any])?["ok"] as? Bool == true)
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue