diff --git a/apps/macos/Sources/Clawdis/AnyCodable.swift b/apps/macos/Sources/Clawdis/AnyCodable.swift index 80efc47fe..7c9a4668d 100644 --- a/apps/macos/Sources/Clawdis/AnyCodable.swift +++ b/apps/macos/Sources/Clawdis/AnyCodable.swift @@ -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, diff --git a/apps/macos/Sources/Clawdis/Bridge/BridgeConnectionHandler.swift b/apps/macos/Sources/Clawdis/Bridge/BridgeConnectionHandler.swift index b9fda454b..2d56c25a9 100644 --- a/apps/macos/Sources/Clawdis/Bridge/BridgeConnectionHandler.swift +++ b/apps/macos/Sources/Clawdis/Bridge/BridgeConnectionHandler.swift @@ -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): diff --git a/apps/macos/Sources/Clawdis/Bridge/BridgeServer.swift b/apps/macos/Sources/Clawdis/Bridge/BridgeServer.swift index a8a5c6936..63706136c 100644 --- a/apps/macos/Sources/Clawdis/Bridge/BridgeServer.swift +++ b/apps/macos/Sources/Clawdis/Bridge/BridgeServer.swift @@ -12,6 +12,7 @@ actor BridgeServer { private var isRunning = false private var store: PairedNodesStore? private var connections: [String: BridgeConnectionHandler] = [:] + private var presenceTasks: [String: Task] = [:] 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 { diff --git a/apps/macos/Sources/ClawdisProtocol/AnyCodable.swift b/apps/macos/Sources/ClawdisProtocol/AnyCodable.swift index 89e4aa1ba..ad0c33872 100644 --- a/apps/macos/Sources/ClawdisProtocol/AnyCodable.swift +++ b/apps/macos/Sources/ClawdisProtocol/AnyCodable.swift @@ -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, diff --git a/apps/macos/Tests/ClawdisIPCTests/AnyCodableEncodingTests.swift b/apps/macos/Tests/ClawdisIPCTests/AnyCodableEncodingTests.swift new file mode 100644 index 000000000..9d8166c09 --- /dev/null +++ b/apps/macos/Tests/ClawdisIPCTests/AnyCodableEncodingTests.swift @@ -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) + } +}