fix(macos): harden presence decode
This commit is contained in:
@@ -157,7 +157,6 @@ final class ControlChannel: ObservableObject {
|
|||||||
return "Gateway request timed out; check the gateway process on localhost:\(GatewayEnvironment.gatewayPort())."
|
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
|
let detail = nsError.localizedDescription.isEmpty ? "unknown gateway error" : nsError.localizedDescription
|
||||||
return "Gateway error: \(detail)"
|
return "Gateway error: \(detail)"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,10 @@ final class InstancesStore: ObservableObject {
|
|||||||
private let interval: TimeInterval = 30
|
private let interval: TimeInterval = 30
|
||||||
private var observers: [NSObjectProtocol] = []
|
private var observers: [NSObjectProtocol] = []
|
||||||
|
|
||||||
|
private struct PresenceEventPayload: Codable {
|
||||||
|
let presence: [PresenceEntry]
|
||||||
|
}
|
||||||
|
|
||||||
init(isPreview: Bool = false) {
|
init(isPreview: Bool = false) {
|
||||||
self.isPreview = isPreview
|
self.isPreview = isPreview
|
||||||
}
|
}
|
||||||
@@ -77,11 +81,8 @@ final class InstancesStore: ObservableObject {
|
|||||||
let frame = note.object as? GatewayFrame else { return }
|
let frame = note.object as? GatewayFrame else { return }
|
||||||
switch frame {
|
switch frame {
|
||||||
case let .event(evt) where evt.event == "presence":
|
case let .event(evt) where evt.event == "presence":
|
||||||
if let payload = evt.payload?.value as? [String: Any],
|
if let payload = evt.payload {
|
||||||
let presence = payload["presence"],
|
Task { @MainActor [weak self] in self?.handlePresenceEventPayload(payload) }
|
||||||
let presenceData = try? JSONSerialization.data(withJSONObject: presence)
|
|
||||||
{
|
|
||||||
Task { @MainActor [weak self] in self?.decodeAndApplyPresenceData(presenceData) }
|
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
break
|
break
|
||||||
@@ -133,20 +134,8 @@ final class InstancesStore: ObservableObject {
|
|||||||
await self.probeHealthIfNeeded(reason: "no payload")
|
await self.probeHealthIfNeeded(reason: "no payload")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let decoded = try JSONDecoder().decode([InstanceInfo].self, from: data)
|
let decoded = try JSONDecoder().decode([PresenceEntry].self, from: data)
|
||||||
let withIDs = decoded.map { entry -> InstanceInfo in
|
let withIDs = self.normalizePresence(decoded)
|
||||||
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)
|
|
||||||
}
|
|
||||||
if withIDs.isEmpty {
|
if withIDs.isEmpty {
|
||||||
self.instances = [self.localFallbackInstance(reason: "no presence entries")]
|
self.instances = [self.localFallbackInstance(reason: "no presence entries")]
|
||||||
self.lastError = nil
|
self.lastError = nil
|
||||||
@@ -280,28 +269,47 @@ final class InstancesStore: ObservableObject {
|
|||||||
|
|
||||||
private func decodeAndApplyPresenceData(_ data: Data) {
|
private func decodeAndApplyPresenceData(_ data: Data) {
|
||||||
do {
|
do {
|
||||||
let decoded = try JSONDecoder().decode([InstanceInfo].self, from: data)
|
let decoded = try JSONDecoder().decode([PresenceEntry].self, from: data)
|
||||||
let withIDs = decoded.map { entry -> InstanceInfo in
|
self.applyPresence(decoded)
|
||||||
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
|
|
||||||
} catch {
|
} catch {
|
||||||
self.logger.error("presence decode from event failed: \(error.localizedDescription, privacy: .public)")
|
self.logger.error("presence decode from event failed: \(error.localizedDescription, privacy: .public)")
|
||||||
self.lastError = error.localizedDescription
|
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 {
|
extension InstancesStore {
|
||||||
|
|||||||
36
apps/macos/Tests/ClawdisIPCTests/InstancesStoreTests.swift
Normal file
36
apps/macos/Tests/ClawdisIPCTests/InstancesStoreTests.swift
Normal file
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user