From 336c9d6caacd4fb548e5d8189843806724c3f98a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 9 Dec 2025 15:35:06 +0100 Subject: [PATCH] Mac: build GatewayProtocol target and typed presence handling --- apps/macos/Package.swift | 9 + .../Sources/Clawdis/GatewayChannel.swift | 11 +- .../Sources/Clawdis/InstancesStore.swift | 4 +- .../Sources/ClawdisProtocol/AnyCodable.swift | 10 +- .../ClawdisProtocol/GatewayModels.swift | 237 +++++++++++++----- scripts/protocol-gen-swift.ts | 92 +++++-- 6 files changed, 262 insertions(+), 101 deletions(-) diff --git a/apps/macos/Package.swift b/apps/macos/Package.swift index 16e797344..7f020f4cb 100644 --- a/apps/macos/Package.swift +++ b/apps/macos/Package.swift @@ -20,6 +20,13 @@ let package = Package( .package(url: "https://github.com/sparkle-project/Sparkle", from: "2.8.1"), ], targets: [ + .target( + name: "ClawdisProtocol", + dependencies: [], + path: "Sources/ClawdisProtocol", + swiftSettings: [ + .enableUpcomingFeature("StrictConcurrency"), + ]), .target( name: "ClawdisIPC", dependencies: [], @@ -30,6 +37,7 @@ let package = Package( name: "Clawdis", dependencies: [ "ClawdisIPC", + "ClawdisProtocol", .product(name: "AsyncXPCConnection", package: "AsyncXPCConnection"), .product(name: "MenuBarExtraAccess", package: "MenuBarExtraAccess"), .product(name: "Subprocess", package: "swift-subprocess"), @@ -46,6 +54,7 @@ let package = Package( name: "ClawdisCLI", dependencies: [ "ClawdisIPC", + "ClawdisProtocol", .product(name: "AsyncXPCConnection", package: "AsyncXPCConnection"), ], swiftSettings: [ diff --git a/apps/macos/Sources/Clawdis/GatewayChannel.swift b/apps/macos/Sources/Clawdis/GatewayChannel.swift index 41426668a..f3c068b3a 100644 --- a/apps/macos/Sources/Clawdis/GatewayChannel.swift +++ b/apps/macos/Sources/Clawdis/GatewayChannel.swift @@ -120,7 +120,8 @@ private actor GatewayChannelActor { } switch frame { case let .res(res): - if let id = res.id, let waiter = pending.removeValue(forKey: id) { + let id = res.id + if let waiter = pending.removeValue(forKey: id) { waiter.resume(returning: .res(res)) } case let .event(evt): @@ -182,12 +183,14 @@ private actor GatewayChannelActor { throw NSError(domain: "Gateway", code: 2, userInfo: [NSLocalizedDescriptionKey: "unexpected frame"]) } if res.ok == false { - let msg = (res.error?.message) ?? "gateway error" + let msg = (res.error?["message"]?.value as? String) ?? "gateway error" throw NSError(domain: "Gateway", code: 3, userInfo: [NSLocalizedDescriptionKey: msg]) } if let payload = res.payload?.value { - let payloadData = try JSONSerialization.data(withJSONObject: payload) - return payloadData + if JSONSerialization.isValidJSONObject(payload) { + let payloadData = try JSONSerialization.data(withJSONObject: payload) + return payloadData + } } return Data() } diff --git a/apps/macos/Sources/Clawdis/InstancesStore.swift b/apps/macos/Sources/Clawdis/InstancesStore.swift index 64c55c8cc..814997e84 100644 --- a/apps/macos/Sources/Clawdis/InstancesStore.swift +++ b/apps/macos/Sources/Clawdis/InstancesStore.swift @@ -97,8 +97,8 @@ final class InstancesStore: ObservableObject { let frame = note.object as? GatewayFrame else { return } switch frame { case let .helloOk(hello): - let presence = hello.snapshot.presence - if let data = try? JSONEncoder().encode(presence) { + if JSONSerialization.isValidJSONObject(hello.snapshot.presence), + let data = try? JSONEncoder().encode(hello.snapshot.presence) { Task { @MainActor [weak self] in self?.decodeAndApplyPresenceData(data) } } default: diff --git a/apps/macos/Sources/ClawdisProtocol/AnyCodable.swift b/apps/macos/Sources/ClawdisProtocol/AnyCodable.swift index 80efc47fe..89e4aa1ba 100644 --- a/apps/macos/Sources/ClawdisProtocol/AnyCodable.swift +++ b/apps/macos/Sources/ClawdisProtocol/AnyCodable.swift @@ -2,12 +2,12 @@ import Foundation /// Lightweight `Codable` wrapper that round-trips heterogeneous JSON payloads. /// Marked `@unchecked Sendable` because it can hold reference types. -struct AnyCodable: Codable, @unchecked Sendable { - let value: Any +public struct AnyCodable: Codable, @unchecked Sendable { + public let value: Any - init(_ value: Any) { self.value = value } + public init(_ value: Any) { self.value = value } - init(from decoder: Decoder) throws { + public init(from decoder: Decoder) throws { let container = try decoder.singleValueContainer() if let intVal = try? container.decode(Int.self) { self.value = intVal; return } if let doubleVal = try? container.decode(Double.self) { self.value = doubleVal; return } @@ -21,7 +21,7 @@ struct AnyCodable: Codable, @unchecked Sendable { debugDescription: "Unsupported type") } - func encode(to encoder: Encoder) throws { + public func encode(to encoder: Encoder) throws { var container = encoder.singleValueContainer() switch self.value { case let intVal as Int: try container.encode(intVal) diff --git a/apps/macos/Sources/ClawdisProtocol/GatewayModels.swift b/apps/macos/Sources/ClawdisProtocol/GatewayModels.swift index 11f7e84d7..ed9796e93 100644 --- a/apps/macos/Sources/ClawdisProtocol/GatewayModels.swift +++ b/apps/macos/Sources/ClawdisProtocol/GatewayModels.swift @@ -12,76 +12,100 @@ public enum ErrorCode: String, Codable { public struct Hello: Codable { public let type: String - public let minProtocol: Int - public let maxProtocol: Int + public let minprotocol: Int + public let maxprotocol: Int public let client: [String: AnyCodable] public let caps: [String]? public let auth: [String: AnyCodable]? public let locale: String? - public let userAgent: String? + public let useragent: String? public init( type: String, - minProtocol: Int, - maxProtocol: Int, + minprotocol: Int, + maxprotocol: Int, client: [String: AnyCodable], caps: [String]?, auth: [String: AnyCodable]?, locale: String?, - userAgent: String? + useragent: String? ) { self.type = type - self.minProtocol = minProtocol - self.maxProtocol = maxProtocol + self.minprotocol = minprotocol + self.maxprotocol = maxprotocol self.client = client self.caps = caps self.auth = auth self.locale = locale - self.userAgent = userAgent + self.useragent = useragent + } + private enum CodingKeys: String, CodingKey { + case type + case minprotocol = "minProtocol" + case maxprotocol = "maxProtocol" + case client + case caps + case auth + case locale + case useragent = "userAgent" } } public struct HelloOk: Codable { public let type: String - public let protocol: Int + public let _protocol: Int public let server: [String: AnyCodable] public let features: [String: AnyCodable] - public let snapshot: [String: AnyCodable] + public let snapshot: Snapshot public let policy: [String: AnyCodable] public init( type: String, - protocol: Int, + _protocol: Int, server: [String: AnyCodable], features: [String: AnyCodable], - snapshot: [String: AnyCodable], + snapshot: Snapshot, policy: [String: AnyCodable] ) { self.type = type - self.protocol = protocol + self._protocol = _protocol self.server = server self.features = features self.snapshot = snapshot self.policy = policy } + private enum CodingKeys: String, CodingKey { + case type + case _protocol = "protocol" + case server + case features + case snapshot + case policy + } } public struct HelloError: Codable { public let type: String public let reason: String - public let expectedProtocol: Int? - public let minClient: String? + public let expectedprotocol: Int? + public let minclient: String? public init( type: String, reason: String, - expectedProtocol: Int?, - minClient: String? + expectedprotocol: Int?, + minclient: String? ) { self.type = type self.reason = reason - self.expectedProtocol = expectedProtocol - self.minClient = minClient + self.expectedprotocol = expectedprotocol + self.minclient = minclient + } + private enum CodingKeys: String, CodingKey { + case type + case reason + case expectedprotocol = "expectedProtocol" + case minclient = "minClient" } } @@ -102,6 +126,12 @@ public struct RequestFrame: Codable { self.method = method self.params = params } + private enum CodingKeys: String, CodingKey { + case type + case id + case method + case params + } } public struct ResponseFrame: Codable { @@ -124,6 +154,13 @@ public struct ResponseFrame: Codable { self.payload = payload self.error = error } + private enum CodingKeys: String, CodingKey { + case type + case id + case ok + case payload + case error + } } public struct EventFrame: Codable { @@ -131,20 +168,27 @@ public struct EventFrame: Codable { public let event: String public let payload: AnyCodable? public let seq: Int? - public let stateVersion: [String: AnyCodable]? + public let stateversion: [String: AnyCodable]? public init( type: String, event: String, payload: AnyCodable?, seq: Int?, - stateVersion: [String: AnyCodable]? + stateversion: [String: AnyCodable]? ) { self.type = type self.event = event self.payload = payload self.seq = seq - self.stateVersion = stateVersion + self.stateversion = stateversion + } + private enum CodingKeys: String, CodingKey { + case type + case event + case payload + case seq + case stateversion = "stateVersion" } } @@ -153,35 +197,47 @@ public struct PresenceEntry: Codable { public let ip: String? public let version: String? public let mode: String? - public let lastInputSeconds: Int? + public let lastinputseconds: Int? public let reason: String? public let tags: [String]? public let text: String? public let ts: Int - public let instanceId: String? + public let instanceid: String? public init( host: String?, ip: String?, version: String?, mode: String?, - lastInputSeconds: Int?, + lastinputseconds: Int?, reason: String?, tags: [String]?, text: String?, ts: Int, - instanceId: String? + instanceid: String? ) { self.host = host self.ip = ip self.version = version self.mode = mode - self.lastInputSeconds = lastInputSeconds + self.lastinputseconds = lastinputseconds self.reason = reason self.tags = tags self.text = text self.ts = ts - self.instanceId = instanceId + self.instanceid = instanceid + } + private enum CodingKeys: String, CodingKey { + case host + case ip + case version + case mode + case lastinputseconds = "lastInputSeconds" + case reason + case tags + case text + case ts + case instanceid = "instanceId" } } @@ -196,24 +252,34 @@ public struct StateVersion: Codable { self.presence = presence self.health = health } + private enum CodingKeys: String, CodingKey { + case presence + case health + } } public struct Snapshot: Codable { - public let presence: [[String: AnyCodable]] + public let presence: [PresenceEntry] public let health: AnyCodable - public let stateVersion: [String: AnyCodable] - public let uptimeMs: Int + public let stateversion: StateVersion + public let uptimems: Int public init( - presence: [[String: AnyCodable]], + presence: [PresenceEntry], health: AnyCodable, - stateVersion: [String: AnyCodable], - uptimeMs: Int + stateversion: StateVersion, + uptimems: Int ) { self.presence = presence self.health = health - self.stateVersion = stateVersion - self.uptimeMs = uptimeMs + self.stateversion = stateversion + self.uptimems = uptimems + } + private enum CodingKeys: String, CodingKey { + case presence + case health + case stateversion = "stateVersion" + case uptimems = "uptimeMs" } } @@ -222,92 +288,122 @@ public struct ErrorShape: Codable { public let message: String public let details: AnyCodable? public let retryable: Bool? - public let retryAfterMs: Int? + public let retryafterms: Int? public init( code: String, message: String, details: AnyCodable?, retryable: Bool?, - retryAfterMs: Int? + retryafterms: Int? ) { self.code = code self.message = message self.details = details self.retryable = retryable - self.retryAfterMs = retryAfterMs + self.retryafterms = retryafterms + } + private enum CodingKeys: String, CodingKey { + case code + case message + case details + case retryable + case retryafterms = "retryAfterMs" } } public struct AgentEvent: Codable { - public let runId: String + public let runid: String public let seq: Int public let stream: String public let ts: Int public let data: [String: AnyCodable] public init( - runId: String, + runid: String, seq: Int, stream: String, ts: Int, data: [String: AnyCodable] ) { - self.runId = runId + self.runid = runid self.seq = seq self.stream = stream self.ts = ts self.data = data } + private enum CodingKeys: String, CodingKey { + case runid = "runId" + case seq + case stream + case ts + case data + } } public struct SendParams: Codable { public let to: String public let message: String - public let mediaUrl: String? + public let mediaurl: String? public let provider: String? - public let idempotencyKey: String + public let idempotencykey: String public init( to: String, message: String, - mediaUrl: String?, + mediaurl: String?, provider: String?, - idempotencyKey: String + idempotencykey: String ) { self.to = to self.message = message - self.mediaUrl = mediaUrl + self.mediaurl = mediaurl self.provider = provider - self.idempotencyKey = idempotencyKey + self.idempotencykey = idempotencykey + } + private enum CodingKeys: String, CodingKey { + case to + case message + case mediaurl = "mediaUrl" + case provider + case idempotencykey = "idempotencyKey" } } public struct AgentParams: Codable { public let message: String public let to: String? - public let sessionId: String? + public let sessionid: String? public let thinking: String? public let deliver: Bool? public let timeout: Int? - public let idempotencyKey: String + public let idempotencykey: String public init( message: String, to: String?, - sessionId: String?, + sessionid: String?, thinking: String?, deliver: Bool?, timeout: Int?, - idempotencyKey: String + idempotencykey: String ) { self.message = message self.to = to - self.sessionId = sessionId + self.sessionid = sessionid self.thinking = thinking self.deliver = deliver self.timeout = timeout - self.idempotencyKey = idempotencyKey + self.idempotencykey = idempotencykey + } + private enum CodingKeys: String, CodingKey { + case message + case to + case sessionid = "sessionId" + case thinking + case deliver + case timeout + case idempotencykey = "idempotencyKey" } } @@ -319,25 +415,32 @@ public struct TickEvent: Codable { ) { self.ts = ts } + private enum CodingKeys: String, CodingKey { + case ts + } } public struct ShutdownEvent: Codable { public let reason: String - public let restartExpectedMs: Int? + public let restartexpectedms: Int? public init( reason: String, - restartExpectedMs: Int? + restartexpectedms: Int? ) { self.reason = reason - self.restartExpectedMs = restartExpectedMs + self.restartexpectedms = restartexpectedms + } + private enum CodingKeys: String, CodingKey { + case reason + case restartexpectedms = "restartExpectedMs" } } public enum GatewayFrame: Codable { case hello(Hello) - case hello-ok(HelloOk) - case hello-error(HelloError) + case helloOk(HelloOk) + case helloError(HelloError) case req(RequestFrame) case res(ResponseFrame) case event(EventFrame) @@ -351,17 +454,17 @@ public enum GatewayFrame: Codable { } switch type { case "hello": - self = .hello(try decodePayload(Hello.self, from: raw)) + self = .hello(try Self.decodePayload(Hello.self, from: raw)) case "hello-ok": - self = .helloOk(try decodePayload(HelloOk.self, from: raw)) + self = .helloOk(try Self.decodePayload(HelloOk.self, from: raw)) case "hello-error": - self = .helloError(try decodePayload(HelloError.self, from: raw)) + self = .helloError(try Self.decodePayload(HelloError.self, from: raw)) case "req": - self = .req(try decodePayload(RequestFrame.self, from: raw)) + self = .req(try Self.decodePayload(RequestFrame.self, from: raw)) case "res": - self = .res(try decodePayload(ResponseFrame.self, from: raw)) + self = .res(try Self.decodePayload(ResponseFrame.self, from: raw)) case "event": - self = .event(try decodePayload(EventFrame.self, from: raw)) + self = .event(try Self.decodePayload(EventFrame.self, from: raw)) default: self = .unknown(type: type, raw: raw) } @@ -382,7 +485,7 @@ public enum GatewayFrame: Codable { } - private func decodePayload(_ type: T.Type, from raw: [String: AnyCodable]) throws -> T { + private static func decodePayload(_ type: T.Type, from raw: [String: AnyCodable]) throws -> T { let data = try JSONSerialization.data(withJSONObject: raw) let decoder = JSONDecoder() return try decoder.decode(T.self, from: data) diff --git a/scripts/protocol-gen-swift.ts b/scripts/protocol-gen-swift.ts index 1a46488b6..d5c18342c 100644 --- a/scripts/protocol-gen-swift.ts +++ b/scripts/protocol-gen-swift.ts @@ -31,27 +31,67 @@ const header = `// Generated by scripts/protocol-gen-swift.ts — do not edit by .map((c) => ` case ${camelCase(c)} = "${c}"`) .join("\n")}\n}\n`; +const reserved = new Set([ + "associatedtype", + "class", + "deinit", + "enum", + "extension", + "fileprivate", + "func", + "import", + "init", + "inout", + "internal", + "let", + "open", + "operator", + "private", + "precedencegroup", + "protocol", + "public", + "rethrows", + "static", + "struct", + "subscript", + "typealias", + "var", +]); + function camelCase(input: string) { return input + .replace(/[^a-zA-Z0-9]+/g, " ") + .trim() .toLowerCase() - .split("_") + .split(/\s+/) .map((p, i) => (i === 0 ? p : p[0].toUpperCase() + p.slice(1))) .join(""); } +function safeName(name: string) { + const cc = camelCase(name.replace(/-/g, "_")); + if (reserved.has(cc)) return `_${cc}`; + return cc; +} + +// filled later once schemas are loaded +const schemaNameByObject = new Map(); + function swiftType(schema: JsonSchema, required: boolean): string { const t = schema.type; const isOptional = !required; let base: string; - if (t === "string") base = "String"; + const named = schemaNameByObject.get(schema as object); + if (named) { + base = named; + } else if (t === "string") base = "String"; else if (t === "integer") base = "Int"; else if (t === "number") base = "Double"; else if (t === "boolean") base = "Bool"; else if (t === "array") { base = `[${swiftType(schema.items ?? { type: "Any" }, true)}]`; } else if (schema.enum) { - base = schema.enum.map((v) => `\"${v}\"`).join(" | "); - base = "String"; // simplify enums to String; custom enums could be added if needed + base = "String"; } else if (schema.patternProperties) { base = "[String: AnyCodable]"; } else if (t === "object") { @@ -71,15 +111,21 @@ function emitStruct(name: string, schema: JsonSchema): string { lines.push("}\n"); return lines.join("\n"); } + const codingKeys: string[] = []; for (const [key, propSchema] of Object.entries(props)) { - const propName = key === "description" ? "desc" : key; + const propName = safeName(key); const propType = swiftType(propSchema, required.has(key)); lines.push(` public let ${propName}: ${propType}`); + if (propName !== key) { + codingKeys.push(` case ${propName} = "${key}"`); + } else { + codingKeys.push(` case ${propName}`); + } } lines.push("\n public init(\n" + Object.entries(props) .map(([key, prop]) => { - const propName = key === "description" ? "desc" : key; + const propName = safeName(key); const req = required.has(key); return ` ${propName}: ${swiftType(prop, true)}${req ? "" : "?"}`; }) @@ -87,24 +133,20 @@ function emitStruct(name: string, schema: JsonSchema): string { "\n ) {\n" + Object.entries(props) .map(([key]) => { - const propName = key === "description" ? "desc" : key; + const propName = safeName(key); return ` self.${propName} = ${propName}`; }) .join("\n") + + "\n }\n" + + " private enum CodingKeys: String, CodingKey {\n" + + codingKeys.join("\n") + "\n }\n}"); lines.push(""); return lines.join("\n"); } function emitGatewayFrame(): string { - const cases = [ - "hello", - "hello-ok", - "hello-error", - "req", - "res", - "event", - ]; + const cases = ["hello", "hello-ok", "hello-error", "req", "res", "event"]; const associated: Record = { hello: "Hello", "hello-ok": "HelloOk", @@ -113,7 +155,7 @@ function emitGatewayFrame(): string { res: "ResponseFrame", event: "EventFrame", }; - const caseLines = cases.map((c) => ` case ${camelCase(c)}(${associated[c]})`); + const caseLines = cases.map((c) => ` case ${safeName(c)}(${associated[c]})`); const initLines = ` public init(from decoder: Decoder) throws { let container = try decoder.singleValueContainer() @@ -123,17 +165,17 @@ function emitGatewayFrame(): string { } switch type { case "hello": - self = .hello(try decodePayload(Hello.self, from: raw)) + self = .hello(try Self.decodePayload(Hello.self, from: raw)) case "hello-ok": - self = .helloOk(try decodePayload(HelloOk.self, from: raw)) + self = .helloOk(try Self.decodePayload(HelloOk.self, from: raw)) case "hello-error": - self = .helloError(try decodePayload(HelloError.self, from: raw)) + self = .helloError(try Self.decodePayload(HelloError.self, from: raw)) case "req": - self = .req(try decodePayload(RequestFrame.self, from: raw)) + self = .req(try Self.decodePayload(RequestFrame.self, from: raw)) case "res": - self = .res(try decodePayload(ResponseFrame.self, from: raw)) + self = .res(try Self.decodePayload(ResponseFrame.self, from: raw)) case "event": - self = .event(try decodePayload(EventFrame.self, from: raw)) + self = .event(try Self.decodePayload(EventFrame.self, from: raw)) default: self = .unknown(type: type, raw: raw) } @@ -155,7 +197,7 @@ function emitGatewayFrame(): string { `; const helper = ` - private func decodePayload(_ type: T.Type, from raw: [String: AnyCodable]) throws -> T { + private static func decodePayload(_ type: T.Type, from raw: [String: AnyCodable]) throws -> T { let data = try JSONSerialization.data(withJSONObject: raw) let decoder = JSONDecoder() return try decoder.decode(T.self, from: data) @@ -178,6 +220,10 @@ async function generate() { [string, JsonSchema] >; + for (const [name, schema] of definitions) { + schemaNameByObject.set(schema as object, name); + } + const parts: string[] = []; parts.push(header);