fix(presence): report bridged iOS nodes
This commit is contained in:
@@ -31,6 +31,19 @@ struct AnyCodable: Codable, @unchecked Sendable {
|
||||
case is NSNull: try container.encodeNil()
|
||||
case let dict as [String: AnyCodable]: try container.encode(dict)
|
||||
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:
|
||||
let context = EncodingError.Context(
|
||||
codingPath: encoder.codingPath,
|
||||
|
||||
@@ -131,6 +131,10 @@ actor BridgeConnectionHandler {
|
||||
}
|
||||
}
|
||||
|
||||
func remoteAddress() -> String? {
|
||||
self.remoteAddressString()
|
||||
}
|
||||
|
||||
private func handlePairResult(_ result: PairResult, serverName: String) async {
|
||||
switch result {
|
||||
case let .ok(token):
|
||||
|
||||
@@ -12,6 +12,7 @@ actor BridgeServer {
|
||||
private var isRunning = false
|
||||
private var store: PairedNodesStore?
|
||||
private var connections: [String: BridgeConnectionHandler] = [:]
|
||||
private var presenceTasks: [String: Task<Void, Never>] = [:]
|
||||
|
||||
func start() async {
|
||||
if self.isRunning { return }
|
||||
@@ -110,12 +111,14 @@ actor BridgeServer {
|
||||
|
||||
private func registerConnection(handler: BridgeConnectionHandler, nodeId: String) async {
|
||||
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 {
|
||||
await self.beaconPresence(nodeId: nodeId, reason: "disconnect")
|
||||
self.stopPresenceTask(nodeId: nodeId)
|
||||
self.connections.removeValue(forKey: nodeId)
|
||||
await self.beacon(text: "Node disconnected", nodeId: nodeId, tags: ["node", "ios"])
|
||||
}
|
||||
|
||||
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 {
|
||||
let params: [String: Any] = [
|
||||
"text": "\(text): \(nodeId)",
|
||||
let paired = await self.store?.find(nodeId: 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,
|
||||
"host": host,
|
||||
"mode": "node",
|
||||
"reason": reason,
|
||||
"tags": tags,
|
||||
]
|
||||
if let ip { params["ip"] = ip }
|
||||
if let version { params["version"] = version }
|
||||
_ = try await AgentRPC.shared.controlRequest(
|
||||
method: "system-event",
|
||||
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 {
|
||||
let nodeId = hello.nodeId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if nodeId.isEmpty {
|
||||
|
||||
@@ -31,6 +31,19 @@ public struct AnyCodable: Codable, @unchecked Sendable {
|
||||
case is NSNull: try container.encodeNil()
|
||||
case let dict as [String: AnyCodable]: try container.encode(dict)
|
||||
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:
|
||||
let context = EncodingError.Context(
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user