diff --git a/apps/macos/Sources/Clawdis/ControlChannel.swift b/apps/macos/Sources/Clawdis/ControlChannel.swift index 3b252cfa0..123dccdaf 100644 --- a/apps/macos/Sources/Clawdis/ControlChannel.swift +++ b/apps/macos/Sources/Clawdis/ControlChannel.swift @@ -157,7 +157,6 @@ final class ControlChannel: ObservableObject { return "Gateway request timed out; check the gateway process on localhost:\(GatewayEnvironment.gatewayPort())." } - let nsError = error as NSError let detail = nsError.localizedDescription.isEmpty ? "unknown gateway error" : nsError.localizedDescription return "Gateway error: \(detail)" } diff --git a/apps/macos/Sources/Clawdis/InstancesStore.swift b/apps/macos/Sources/Clawdis/InstancesStore.swift index d73873557..c7584b935 100644 --- a/apps/macos/Sources/Clawdis/InstancesStore.swift +++ b/apps/macos/Sources/Clawdis/InstancesStore.swift @@ -40,6 +40,10 @@ final class InstancesStore: ObservableObject { private let interval: TimeInterval = 30 private var observers: [NSObjectProtocol] = [] + private struct PresenceEventPayload: Codable { + let presence: [PresenceEntry] + } + init(isPreview: Bool = false) { self.isPreview = isPreview } @@ -77,11 +81,8 @@ final class InstancesStore: ObservableObject { let frame = note.object as? GatewayFrame else { return } switch frame { case let .event(evt) where evt.event == "presence": - if let payload = evt.payload?.value as? [String: Any], - let presence = payload["presence"], - let presenceData = try? JSONSerialization.data(withJSONObject: presence) - { - Task { @MainActor [weak self] in self?.decodeAndApplyPresenceData(presenceData) } + if let payload = evt.payload { + Task { @MainActor [weak self] in self?.handlePresenceEventPayload(payload) } } default: break @@ -133,20 +134,8 @@ final class InstancesStore: ObservableObject { await self.probeHealthIfNeeded(reason: "no payload") return } - let decoded = try JSONDecoder().decode([InstanceInfo].self, from: data) - let withIDs = decoded.map { entry -> InstanceInfo in - let key = entry.host ?? entry.ip ?? entry.text - return InstanceInfo( - id: key, - host: entry.host, - ip: entry.ip, - version: entry.version, - lastInputSeconds: entry.lastInputSeconds, - mode: entry.mode, - reason: entry.reason, - text: entry.text, - ts: entry.ts) - } + let decoded = try JSONDecoder().decode([PresenceEntry].self, from: data) + let withIDs = self.normalizePresence(decoded) if withIDs.isEmpty { self.instances = [self.localFallbackInstance(reason: "no presence entries")] self.lastError = nil @@ -280,28 +269,47 @@ final class InstancesStore: ObservableObject { private func decodeAndApplyPresenceData(_ data: Data) { do { - let decoded = try JSONDecoder().decode([InstanceInfo].self, from: data) - let withIDs = decoded.map { entry -> InstanceInfo in - let key = entry.host ?? entry.ip ?? entry.text - return InstanceInfo( - id: key, - host: entry.host, - ip: entry.ip, - version: entry.version, - lastInputSeconds: entry.lastInputSeconds, - mode: entry.mode, - reason: entry.reason, - text: entry.text, - ts: entry.ts) - } - self.instances = withIDs - self.statusMessage = nil - self.lastError = nil + let decoded = try JSONDecoder().decode([PresenceEntry].self, from: data) + self.applyPresence(decoded) } catch { self.logger.error("presence decode from event failed: \(error.localizedDescription, privacy: .public)") self.lastError = error.localizedDescription } } + + func handlePresenceEventPayload(_ payload: ClawdisProtocol.AnyCodable) { + do { + let payloadData = try JSONEncoder().encode(payload) + let wrapper = try JSONDecoder().decode(PresenceEventPayload.self, from: payloadData) + self.applyPresence(wrapper.presence) + } catch { + self.logger.error("presence event decode failed: \(error.localizedDescription, privacy: .public)") + self.lastError = error.localizedDescription + } + } + + private func normalizePresence(_ entries: [PresenceEntry]) -> [InstanceInfo] { + entries.map { entry -> InstanceInfo in + let key = entry.host ?? entry.ip ?? entry.text ?? entry.instanceid ?? "entry-\(entry.ts)" + return InstanceInfo( + id: key, + host: entry.host, + ip: entry.ip, + version: entry.version, + lastInputSeconds: entry.lastinputseconds, + mode: entry.mode, + reason: entry.reason, + text: entry.text ?? "Unnamed node", + ts: Double(entry.ts)) + } + } + + private func applyPresence(_ entries: [PresenceEntry]) { + let withIDs = self.normalizePresence(entries) + self.instances = withIDs + self.statusMessage = nil + self.lastError = nil + } } extension InstancesStore { diff --git a/apps/macos/Tests/ClawdisIPCTests/InstancesStoreTests.swift b/apps/macos/Tests/ClawdisIPCTests/InstancesStoreTests.swift new file mode 100644 index 000000000..6ce1ac448 --- /dev/null +++ b/apps/macos/Tests/ClawdisIPCTests/InstancesStoreTests.swift @@ -0,0 +1,36 @@ +import Testing +@testable import Clawdis +import ClawdisProtocol + +@Suite struct InstancesStoreTests { + @Test + @MainActor + func presenceEventPayloadDecodesViaJSONEncoder() { + // Build a payload that mirrors the gateway's presence event shape: + // { "presence": [ PresenceEntry ] } + let entry: [String: ClawdisProtocol.AnyCodable] = [ + "host": .init("gw"), + "ip": .init("10.0.0.1"), + "version": .init("2.0.0"), + "mode": .init("gateway"), + "lastInputSeconds": .init(5), + "reason": .init("test"), + "text": .init("Gateway node"), + "ts": .init(1_730_000_000), + ] + let payloadMap: [String: ClawdisProtocol.AnyCodable] = [ + "presence": .init([ClawdisProtocol.AnyCodable(entry)]), + ] + let payload = ClawdisProtocol.AnyCodable(payloadMap) + + let store = InstancesStore(isPreview: true) + store.handlePresenceEventPayload(payload) + + #expect(store.instances.count == 1) + let instance = store.instances.first + #expect(instance?.host == "gw") + #expect(instance?.ip == "10.0.0.1") + #expect(instance?.mode == "gateway") + #expect(instance?.reason == "test") + } +}