From b0c196cf822cee963efd6d77c77cbdfe1ca53a51 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 9 Dec 2025 15:21:16 +0100 Subject: [PATCH] Protocol: add TypeBox-driven Swift generator --- .../Sources/ClawdisProtocol/AnyCodable.swift | 41 + .../ClawdisProtocol/GatewayModels.swift | 387 ++++++++ .../Sources/ClawdisProtocol/Protocol.swift | 901 ------------------ package.json | 3 +- scripts/protocol-gen-swift.ts | 200 ++++ 5 files changed, 630 insertions(+), 902 deletions(-) create mode 100644 apps/macos/Sources/ClawdisProtocol/AnyCodable.swift create mode 100644 apps/macos/Sources/ClawdisProtocol/GatewayModels.swift delete mode 100644 apps/macos/Sources/ClawdisProtocol/Protocol.swift create mode 100644 scripts/protocol-gen-swift.ts diff --git a/apps/macos/Sources/ClawdisProtocol/AnyCodable.swift b/apps/macos/Sources/ClawdisProtocol/AnyCodable.swift new file mode 100644 index 000000000..80efc47fe --- /dev/null +++ b/apps/macos/Sources/ClawdisProtocol/AnyCodable.swift @@ -0,0 +1,41 @@ +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 + + init(_ value: Any) { self.value = value } + + 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 } + if let boolVal = try? container.decode(Bool.self) { self.value = boolVal; return } + if let stringVal = try? container.decode(String.self) { self.value = stringVal; return } + if container.decodeNil() { self.value = NSNull(); return } + if let dict = try? container.decode([String: AnyCodable].self) { self.value = dict; return } + if let array = try? container.decode([AnyCodable].self) { self.value = array; return } + throw DecodingError.dataCorruptedError( + in: container, + debugDescription: "Unsupported type") + } + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self.value { + case let intVal as Int: try container.encode(intVal) + case let doubleVal as Double: try container.encode(doubleVal) + case let boolVal as Bool: try container.encode(boolVal) + case let stringVal as String: try container.encode(stringVal) + 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) + default: + let context = EncodingError.Context( + codingPath: encoder.codingPath, + debugDescription: "Unsupported type") + throw EncodingError.invalidValue(self.value, context) + } + } +} diff --git a/apps/macos/Sources/ClawdisProtocol/GatewayModels.swift b/apps/macos/Sources/ClawdisProtocol/GatewayModels.swift new file mode 100644 index 000000000..40952bc5e --- /dev/null +++ b/apps/macos/Sources/ClawdisProtocol/GatewayModels.swift @@ -0,0 +1,387 @@ +// Generated by scripts/protocol-gen-swift.ts — do not edit by hand +import Foundation + +public let GATEWAY_PROTOCOL_VERSION = 1 + +public enum ErrorCode: String, Codable { + case notLinked = "NOT_LINKED" + case agentTimeout = "AGENT_TIMEOUT" + case invalidRequest = "INVALID_REQUEST" + case unavailable = "UNAVAILABLE" +} + +public struct Hello: Codable { + public let type: String + 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 init( + type: String, + minProtocol: Int, + maxProtocol: Int, + client: [String: AnyCodable], + caps: [String]?, + auth: [String: AnyCodable]?, + locale: String?, + userAgent: String? + ) { + self.type = type + self.minProtocol = minProtocol + self.maxProtocol = maxProtocol + self.client = client + self.caps = caps + self.auth = auth + self.locale = locale + self.userAgent = userAgent + } +} + +public struct HelloOk: Codable { + public let type: String + public let protocol: Int + public let server: [String: AnyCodable] + public let features: [String: AnyCodable] + public let snapshot: [String: AnyCodable] + public let policy: [String: AnyCodable] + + public init( + type: String, + protocol: Int, + server: [String: AnyCodable], + features: [String: AnyCodable], + snapshot: [String: AnyCodable], + policy: [String: AnyCodable] + ) { + self.type = type + self.protocol = protocol + self.server = server + self.features = features + self.snapshot = snapshot + self.policy = policy + } +} + +public struct HelloError: Codable { + public let type: String + public let reason: String + public let expectedProtocol: Int? + public let minClient: String? + + public init( + type: String, + reason: String, + expectedProtocol: Int?, + minClient: String? + ) { + self.type = type + self.reason = reason + self.expectedProtocol = expectedProtocol + self.minClient = minClient + } +} + +public struct RequestFrame: Codable { + public let type: String + public let id: String + public let method: String + public let params: AnyCodable? + + public init( + type: String, + id: String, + method: String, + params: AnyCodable? + ) { + self.type = type + self.id = id + self.method = method + self.params = params + } +} + +public struct ResponseFrame: Codable { + public let type: String + public let id: String + public let ok: Bool + public let payload: AnyCodable? + public let error: [String: AnyCodable]? + + public init( + type: String, + id: String, + ok: Bool, + payload: AnyCodable?, + error: [String: AnyCodable]? + ) { + self.type = type + self.id = id + self.ok = ok + self.payload = payload + self.error = error + } +} + +public struct EventFrame: Codable { + public let type: String + public let event: String + public let payload: AnyCodable? + public let seq: Int? + public let stateVersion: [String: AnyCodable]? + + public init( + type: String, + event: String, + payload: AnyCodable?, + seq: Int?, + stateVersion: [String: AnyCodable]? + ) { + self.type = type + self.event = event + self.payload = payload + self.seq = seq + self.stateVersion = stateVersion + } +} + +public struct PresenceEntry: Codable { + public let host: String? + public let ip: String? + public let version: String? + public let mode: String? + 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 init( + host: String?, + ip: String?, + version: String?, + mode: String?, + lastInputSeconds: Int?, + reason: String?, + tags: [String]?, + text: String?, + ts: Int, + instanceId: String? + ) { + self.host = host + self.ip = ip + self.version = version + self.mode = mode + self.lastInputSeconds = lastInputSeconds + self.reason = reason + self.tags = tags + self.text = text + self.ts = ts + self.instanceId = instanceId + } +} + +public struct StateVersion: Codable { + public let presence: Int + public let health: Int + + public init( + presence: Int, + health: Int + ) { + self.presence = presence + self.health = health + } +} + +public struct Snapshot: Codable { + public let presence: [[String: AnyCodable]] + public let health: AnyCodable + public let stateVersion: [String: AnyCodable] + public let uptimeMs: Int + + public init( + presence: [[String: AnyCodable]], + health: AnyCodable, + stateVersion: [String: AnyCodable], + uptimeMs: Int + ) { + self.presence = presence + self.health = health + self.stateVersion = stateVersion + self.uptimeMs = uptimeMs + } +} + +public struct ErrorShape: Codable { + public let code: String + public let message: String + public let details: AnyCodable? + public let retryable: Bool? + public let retryAfterMs: Int? + + public init( + code: String, + message: String, + details: AnyCodable?, + retryable: Bool?, + retryAfterMs: Int? + ) { + self.code = code + self.message = message + self.details = details + self.retryable = retryable + self.retryAfterMs = retryAfterMs + } +} + +public struct AgentEvent: Codable { + 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, + seq: Int, + stream: String, + ts: Int, + data: [String: AnyCodable] + ) { + self.runId = runId + self.seq = seq + self.stream = stream + self.ts = ts + self.data = data + } +} + +public struct SendParams: Codable { + public let to: String + public let message: String + public let mediaUrl: String? + public let provider: String? + public let idempotencyKey: String + + public init( + to: String, + message: String, + mediaUrl: String?, + provider: String?, + idempotencyKey: String + ) { + self.to = to + self.message = message + self.mediaUrl = mediaUrl + self.provider = provider + self.idempotencyKey = idempotencyKey + } +} + +public struct AgentParams: Codable { + public let message: String + public let to: String? + public let sessionId: String? + public let thinking: String? + public let deliver: Bool? + public let timeout: Int? + public let idempotencyKey: String + + public init( + message: String, + to: String?, + sessionId: String?, + thinking: String?, + deliver: Bool?, + timeout: Int?, + idempotencyKey: String + ) { + self.message = message + self.to = to + self.sessionId = sessionId + self.thinking = thinking + self.deliver = deliver + self.timeout = timeout + self.idempotencyKey = idempotencyKey + } +} + +public struct TickEvent: Codable { + public let ts: Int + + public init( + ts: Int + ) { + self.ts = ts + } +} + +public struct ShutdownEvent: Codable { + public let reason: String + public let restartExpectedMs: Int? + + public init( + reason: String, + restartExpectedMs: Int? + ) { + self.reason = reason + self.restartExpectedMs = restartExpectedMs + } +} + +public enum GatewayFrame: Codable { + case hello(Hello) + case hello-ok(HelloOk) + case hello-error(HelloError) + case req(RequestFrame) + case res(ResponseFrame) + case event(EventFrame) + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let raw = try container.decode([String: AnyCodable].self) + guard let type = raw["type"]?.value as? String else { + throw DecodingError.dataCorruptedError(in: container, debugDescription: "missing type") + } + switch type { + case "hello": + self = .hello(try decodePayload(Hello.self, from: raw)) + case "hello-ok": + self = .helloOk(try decodePayload(HelloOk.self, from: raw)) + case "hello-error": + self = .helloError(try decodePayload(HelloError.self, from: raw)) + case "req": + self = .req(try decodePayload(RequestFrame.self, from: raw)) + case "res": + self = .res(try decodePayload(ResponseFrame.self, from: raw)) + case "event": + self = .event(try decodePayload(EventFrame.self, from: raw)) + default: + throw DecodingError.dataCorruptedError(in: container, debugDescription: "unknown type (type)") + } + } + + public func encode(to encoder: Encoder) throws { + switch self { + case .hello(let v): try v.encode(to: encoder) + case .helloOk(let v): try v.encode(to: encoder) + case .helloError(let v): try v.encode(to: encoder) + case .req(let v): try v.encode(to: encoder) + case .res(let v): try v.encode(to: encoder) + case .event(let v): try v.encode(to: encoder) + } + } + + + private 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/apps/macos/Sources/ClawdisProtocol/Protocol.swift b/apps/macos/Sources/ClawdisProtocol/Protocol.swift deleted file mode 100644 index 3351d96d2..000000000 --- a/apps/macos/Sources/ClawdisProtocol/Protocol.swift +++ /dev/null @@ -1,901 +0,0 @@ -// This file was generated from JSON Schema using quicktype, do not modify it directly. -// To parse the JSON, add this file to your project and do: -// -// let clawdisGateway = try ClawdisGateway(json) - -import Foundation - -// MARK: - ClawdisGateway - -/// Handshake, request/response, and event frames for the Gateway WebSocket. -struct ClawdisGateway: Codable { - let auth: Auth? - let caps: [String]? - let client: Client? - let locale: String? - let maxProtocol, minProtocol: Int? - let type: TypeEnum - let userAgent: String? - let features: Features? - let policy: Policy? - let clawdisGatewayProtocol: Int? - let server: Server? - let snapshot: Snapshot? - let expectedProtocol: Int? - let minClient, reason, id, method: String? - let params: JSONAny? - let error: Error? - let ok: Bool? - let payload: JSONAny? - let event: String? - let seq: Int? - let stateVersion: ClawdisGatewayStateVersion? - - enum CodingKeys: String, CodingKey { - case auth, caps, client, locale, maxProtocol, minProtocol, type, userAgent, features, policy - case clawdisGatewayProtocol = "protocol" - case server, snapshot, expectedProtocol, minClient, reason, id, method, params, error, ok, payload, event, seq, - stateVersion - } -} - -// MARK: ClawdisGateway convenience initializers and mutators - -extension ClawdisGateway { - init(data: Data) throws { - self = try newJSONDecoder().decode(ClawdisGateway.self, from: data) - } - - init(_ json: String, using encoding: String.Encoding = .utf8) throws { - guard let data = json.data(using: encoding) else { - throw NSError(domain: "JSONDecoding", code: 0, userInfo: nil) - } - try self.init(data: data) - } - - init(fromURL url: URL) throws { - try self.init(data: Data(contentsOf: url)) - } - - func with( - auth: Auth?? = nil, - caps: [String]?? = nil, - client: Client?? = nil, - locale: String?? = nil, - maxProtocol: Int?? = nil, - minProtocol: Int?? = nil, - type: TypeEnum? = nil, - userAgent: String?? = nil, - features: Features?? = nil, - policy: Policy?? = nil, - clawdisGatewayProtocol: Int?? = nil, - server: Server?? = nil, - snapshot: Snapshot?? = nil, - expectedProtocol: Int?? = nil, - minClient: String?? = nil, - reason: String?? = nil, - id: String?? = nil, - method: String?? = nil, - params: JSONAny?? = nil, - error: Error?? = nil, - ok: Bool?? = nil, - payload: JSONAny?? = nil, - event: String?? = nil, - seq: Int?? = nil, - stateVersion: ClawdisGatewayStateVersion?? = nil) -> ClawdisGateway - { - ClawdisGateway( - auth: auth ?? self.auth, - caps: caps ?? self.caps, - client: client ?? self.client, - locale: locale ?? self.locale, - maxProtocol: maxProtocol ?? self.maxProtocol, - minProtocol: minProtocol ?? self.minProtocol, - type: type ?? self.type, - userAgent: userAgent ?? self.userAgent, - features: features ?? self.features, - policy: policy ?? self.policy, - clawdisGatewayProtocol: clawdisGatewayProtocol ?? self.clawdisGatewayProtocol, - server: server ?? self.server, - snapshot: snapshot ?? self.snapshot, - expectedProtocol: expectedProtocol ?? self.expectedProtocol, - minClient: minClient ?? self.minClient, - reason: reason ?? self.reason, - id: id ?? self.id, - method: method ?? self.method, - params: params ?? self.params, - error: error ?? self.error, - ok: ok ?? self.ok, - payload: payload ?? self.payload, - event: event ?? self.event, - seq: seq ?? self.seq, - stateVersion: stateVersion ?? self.stateVersion) - } - - func jsonData() throws -> Data { - try newJSONEncoder().encode(self) - } - - func jsonString(encoding: String.Encoding = .utf8) throws -> String? { - try String(data: self.jsonData(), encoding: encoding) - } -} - -// MARK: - Auth - -struct Auth: Codable { - let token: String? -} - -// MARK: Auth convenience initializers and mutators - -extension Auth { - init(data: Data) throws { - self = try newJSONDecoder().decode(Auth.self, from: data) - } - - init(_ json: String, using encoding: String.Encoding = .utf8) throws { - guard let data = json.data(using: encoding) else { - throw NSError(domain: "JSONDecoding", code: 0, userInfo: nil) - } - try self.init(data: data) - } - - init(fromURL url: URL) throws { - try self.init(data: Data(contentsOf: url)) - } - - func with( - token: String?? = nil) -> Auth - { - Auth( - token: token ?? self.token) - } - - func jsonData() throws -> Data { - try newJSONEncoder().encode(self) - } - - func jsonString(encoding: String.Encoding = .utf8) throws -> String? { - try String(data: self.jsonData(), encoding: encoding) - } -} - -// MARK: - Client - -struct Client: Codable { - let instanceID: String? - let mode, name, platform, version: String - - enum CodingKeys: String, CodingKey { - case instanceID = "instanceId" - case mode, name, platform, version - } -} - -// MARK: Client convenience initializers and mutators - -extension Client { - init(data: Data) throws { - self = try newJSONDecoder().decode(Client.self, from: data) - } - - init(_ json: String, using encoding: String.Encoding = .utf8) throws { - guard let data = json.data(using: encoding) else { - throw NSError(domain: "JSONDecoding", code: 0, userInfo: nil) - } - try self.init(data: data) - } - - init(fromURL url: URL) throws { - try self.init(data: Data(contentsOf: url)) - } - - func with( - instanceID: String?? = nil, - mode: String? = nil, - name: String? = nil, - platform: String? = nil, - version: String? = nil) -> Client - { - Client( - instanceID: instanceID ?? self.instanceID, - mode: mode ?? self.mode, - name: name ?? self.name, - platform: platform ?? self.platform, - version: version ?? self.version) - } - - func jsonData() throws -> Data { - try newJSONEncoder().encode(self) - } - - func jsonString(encoding: String.Encoding = .utf8) throws -> String? { - try String(data: self.jsonData(), encoding: encoding) - } -} - -// MARK: - Error - -struct Error: Codable { - let code: String - let details: JSONAny? - let message: String - let retryable: Bool? - let retryAfterMS: Int? - - enum CodingKeys: String, CodingKey { - case code, details, message, retryable - case retryAfterMS = "retryAfterMs" - } -} - -// MARK: Error convenience initializers and mutators - -extension Error { - init(data: Data) throws { - self = try newJSONDecoder().decode(Error.self, from: data) - } - - init(_ json: String, using encoding: String.Encoding = .utf8) throws { - guard let data = json.data(using: encoding) else { - throw NSError(domain: "JSONDecoding", code: 0, userInfo: nil) - } - try self.init(data: data) - } - - init(fromURL url: URL) throws { - try self.init(data: Data(contentsOf: url)) - } - - func with( - code: String? = nil, - details: JSONAny?? = nil, - message: String? = nil, - retryable: Bool?? = nil, - retryAfterMS: Int?? = nil) -> Error - { - Error( - code: code ?? self.code, - details: details ?? self.details, - message: message ?? self.message, - retryable: retryable ?? self.retryable, - retryAfterMS: retryAfterMS ?? self.retryAfterMS) - } - - func jsonData() throws -> Data { - try newJSONEncoder().encode(self) - } - - func jsonString(encoding: String.Encoding = .utf8) throws -> String? { - try String(data: self.jsonData(), encoding: encoding) - } -} - -// MARK: - Features - -struct Features: Codable { - let events, methods: [String] -} - -// MARK: Features convenience initializers and mutators - -extension Features { - init(data: Data) throws { - self = try newJSONDecoder().decode(Features.self, from: data) - } - - init(_ json: String, using encoding: String.Encoding = .utf8) throws { - guard let data = json.data(using: encoding) else { - throw NSError(domain: "JSONDecoding", code: 0, userInfo: nil) - } - try self.init(data: data) - } - - init(fromURL url: URL) throws { - try self.init(data: Data(contentsOf: url)) - } - - func with( - events: [String]? = nil, - methods: [String]? = nil) -> Features - { - Features( - events: events ?? self.events, - methods: methods ?? self.methods) - } - - func jsonData() throws -> Data { - try newJSONEncoder().encode(self) - } - - func jsonString(encoding: String.Encoding = .utf8) throws -> String? { - try String(data: self.jsonData(), encoding: encoding) - } -} - -// MARK: - Policy - -struct Policy: Codable { - let maxBufferedBytes, maxPayload, tickIntervalMS: Int - - enum CodingKeys: String, CodingKey { - case maxBufferedBytes, maxPayload - case tickIntervalMS = "tickIntervalMs" - } -} - -// MARK: Policy convenience initializers and mutators - -extension Policy { - init(data: Data) throws { - self = try newJSONDecoder().decode(Policy.self, from: data) - } - - init(_ json: String, using encoding: String.Encoding = .utf8) throws { - guard let data = json.data(using: encoding) else { - throw NSError(domain: "JSONDecoding", code: 0, userInfo: nil) - } - try self.init(data: data) - } - - init(fromURL url: URL) throws { - try self.init(data: Data(contentsOf: url)) - } - - func with( - maxBufferedBytes: Int? = nil, - maxPayload: Int? = nil, - tickIntervalMS: Int? = nil) -> Policy - { - Policy( - maxBufferedBytes: maxBufferedBytes ?? self.maxBufferedBytes, - maxPayload: maxPayload ?? self.maxPayload, - tickIntervalMS: tickIntervalMS ?? self.tickIntervalMS) - } - - func jsonData() throws -> Data { - try newJSONEncoder().encode(self) - } - - func jsonString(encoding: String.Encoding = .utf8) throws -> String? { - try String(data: self.jsonData(), encoding: encoding) - } -} - -// MARK: - Server - -struct Server: Codable { - let commit: String? - let connID: String - let host: String? - let version: String - - enum CodingKeys: String, CodingKey { - case commit - case connID = "connId" - case host, version - } -} - -// MARK: Server convenience initializers and mutators - -extension Server { - init(data: Data) throws { - self = try newJSONDecoder().decode(Server.self, from: data) - } - - init(_ json: String, using encoding: String.Encoding = .utf8) throws { - guard let data = json.data(using: encoding) else { - throw NSError(domain: "JSONDecoding", code: 0, userInfo: nil) - } - try self.init(data: data) - } - - init(fromURL url: URL) throws { - try self.init(data: Data(contentsOf: url)) - } - - func with( - commit: String?? = nil, - connID: String? = nil, - host: String?? = nil, - version: String? = nil) -> Server - { - Server( - commit: commit ?? self.commit, - connID: connID ?? self.connID, - host: host ?? self.host, - version: version ?? self.version) - } - - func jsonData() throws -> Data { - try newJSONEncoder().encode(self) - } - - func jsonString(encoding: String.Encoding = .utf8) throws -> String? { - try String(data: self.jsonData(), encoding: encoding) - } -} - -// MARK: - Snapshot - -struct Snapshot: Codable { - let health: JSONAny - let presence: [Presence] - let stateVersion: SnapshotStateVersion - let uptimeMS: Int - - enum CodingKeys: String, CodingKey { - case health, presence, stateVersion - case uptimeMS = "uptimeMs" - } -} - -// MARK: Snapshot convenience initializers and mutators - -extension Snapshot { - init(data: Data) throws { - self = try newJSONDecoder().decode(Snapshot.self, from: data) - } - - init(_ json: String, using encoding: String.Encoding = .utf8) throws { - guard let data = json.data(using: encoding) else { - throw NSError(domain: "JSONDecoding", code: 0, userInfo: nil) - } - try self.init(data: data) - } - - init(fromURL url: URL) throws { - try self.init(data: Data(contentsOf: url)) - } - - func with( - health: JSONAny? = nil, - presence: [Presence]? = nil, - stateVersion: SnapshotStateVersion? = nil, - uptimeMS: Int? = nil) -> Snapshot - { - Snapshot( - health: health ?? self.health, - presence: presence ?? self.presence, - stateVersion: stateVersion ?? self.stateVersion, - uptimeMS: uptimeMS ?? self.uptimeMS) - } - - func jsonData() throws -> Data { - try newJSONEncoder().encode(self) - } - - func jsonString(encoding: String.Encoding = .utf8) throws -> String? { - try String(data: self.jsonData(), encoding: encoding) - } -} - -// MARK: - Presence - -struct Presence: Codable { - let host, instanceID, ip: String? - let lastInputSeconds: Int? - let mode, reason: String? - let tags: [String]? - let text: String? - let ts: Int - let version: String? - - enum CodingKeys: String, CodingKey { - case host - case instanceID = "instanceId" - case ip, lastInputSeconds, mode, reason, tags, text, ts, version - } -} - -// MARK: Presence convenience initializers and mutators - -extension Presence { - init(data: Data) throws { - self = try newJSONDecoder().decode(Presence.self, from: data) - } - - init(_ json: String, using encoding: String.Encoding = .utf8) throws { - guard let data = json.data(using: encoding) else { - throw NSError(domain: "JSONDecoding", code: 0, userInfo: nil) - } - try self.init(data: data) - } - - init(fromURL url: URL) throws { - try self.init(data: Data(contentsOf: url)) - } - - func with( - host: String?? = nil, - instanceID: String?? = nil, - ip: String?? = nil, - lastInputSeconds: Int?? = nil, - mode: String?? = nil, - reason: String?? = nil, - tags: [String]?? = nil, - text: String?? = nil, - ts: Int? = nil, - version: String?? = nil) -> Presence - { - Presence( - host: host ?? self.host, - instanceID: instanceID ?? self.instanceID, - ip: ip ?? self.ip, - lastInputSeconds: lastInputSeconds ?? self.lastInputSeconds, - mode: mode ?? self.mode, - reason: reason ?? self.reason, - tags: tags ?? self.tags, - text: text ?? self.text, - ts: ts ?? self.ts, - version: version ?? self.version) - } - - func jsonData() throws -> Data { - try newJSONEncoder().encode(self) - } - - func jsonString(encoding: String.Encoding = .utf8) throws -> String? { - try String(data: self.jsonData(), encoding: encoding) - } -} - -// MARK: - SnapshotStateVersion - -struct SnapshotStateVersion: Codable { - let health, presence: Int -} - -// MARK: SnapshotStateVersion convenience initializers and mutators - -extension SnapshotStateVersion { - init(data: Data) throws { - self = try newJSONDecoder().decode(SnapshotStateVersion.self, from: data) - } - - init(_ json: String, using encoding: String.Encoding = .utf8) throws { - guard let data = json.data(using: encoding) else { - throw NSError(domain: "JSONDecoding", code: 0, userInfo: nil) - } - try self.init(data: data) - } - - init(fromURL url: URL) throws { - try self.init(data: Data(contentsOf: url)) - } - - func with( - health: Int? = nil, - presence: Int? = nil) -> SnapshotStateVersion - { - SnapshotStateVersion( - health: health ?? self.health, - presence: presence ?? self.presence) - } - - func jsonData() throws -> Data { - try newJSONEncoder().encode(self) - } - - func jsonString(encoding: String.Encoding = .utf8) throws -> String? { - try String(data: self.jsonData(), encoding: encoding) - } -} - -// MARK: - ClawdisGatewayStateVersion - -struct ClawdisGatewayStateVersion: Codable { - let health, presence: Int -} - -// MARK: ClawdisGatewayStateVersion convenience initializers and mutators - -extension ClawdisGatewayStateVersion { - init(data: Data) throws { - self = try newJSONDecoder().decode(ClawdisGatewayStateVersion.self, from: data) - } - - init(_ json: String, using encoding: String.Encoding = .utf8) throws { - guard let data = json.data(using: encoding) else { - throw NSError(domain: "JSONDecoding", code: 0, userInfo: nil) - } - try self.init(data: data) - } - - init(fromURL url: URL) throws { - try self.init(data: Data(contentsOf: url)) - } - - func with( - health: Int? = nil, - presence: Int? = nil) -> ClawdisGatewayStateVersion - { - ClawdisGatewayStateVersion( - health: health ?? self.health, - presence: presence ?? self.presence) - } - - func jsonData() throws -> Data { - try newJSONEncoder().encode(self) - } - - func jsonString(encoding: String.Encoding = .utf8) throws -> String? { - try String(data: self.jsonData(), encoding: encoding) - } -} - -enum TypeEnum: String, Codable { - case event - case hello - case helloError = "hello-error" - case helloOk = "hello-ok" - case req - case res -} - -// MARK: - Helper functions for creating encoders and decoders - -func newJSONDecoder() -> JSONDecoder { - let decoder = JSONDecoder() - if #available(iOS 10.0, OSX 10.12, tvOS 10.0, watchOS 3.0, *) { - decoder.dateDecodingStrategy = .iso8601 - } - return decoder -} - -func newJSONEncoder() -> JSONEncoder { - let encoder = JSONEncoder() - if #available(iOS 10.0, OSX 10.12, tvOS 10.0, watchOS 3.0, *) { - encoder.dateEncodingStrategy = .iso8601 - } - return encoder -} - -// MARK: - Encode/decode helpers - -class JSONNull: Codable, Hashable { - static func == (lhs: JSONNull, rhs: JSONNull) -> Bool { - true - } - - func hash(into hasher: inout Hasher) { - hasher.combine(0) - } - - init() {} - - required init(from decoder: Decoder) throws { - let container = try decoder.singleValueContainer() - if !container.decodeNil() { - throw DecodingError.typeMismatch( - JSONNull.self, - DecodingError.Context( - codingPath: decoder.codingPath, - debugDescription: "Wrong type for JSONNull")) - } - } - - func encode(to encoder: Encoder) throws { - var container = encoder.singleValueContainer() - try container.encodeNil() - } -} - -class JSONCodingKey: CodingKey { - let key: String - - required init?(intValue: Int) { - nil - } - - required init?(stringValue: String) { - self.key = stringValue - } - - var intValue: Int? { - nil - } - - var stringValue: String { - self.key - } -} - -class JSONAny: Codable { - let value: Any - - static func decodingError(forCodingPath codingPath: [CodingKey]) -> DecodingError { - let context = DecodingError.Context(codingPath: codingPath, debugDescription: "Cannot decode JSONAny") - return DecodingError.typeMismatch(JSONAny.self, context) - } - - static func encodingError(forValue value: Any, codingPath: [CodingKey]) -> EncodingError { - let context = EncodingError.Context(codingPath: codingPath, debugDescription: "Cannot encode JSONAny") - return EncodingError.invalidValue(value, context) - } - - static func decode(from container: SingleValueDecodingContainer) throws -> Any { - if let value = try? container.decode(Bool.self) { - return value - } - if let value = try? container.decode(Int64.self) { - return value - } - if let value = try? container.decode(Double.self) { - return value - } - if let value = try? container.decode(String.self) { - return value - } - if container.decodeNil() { - return JSONNull() - } - throw self.decodingError(forCodingPath: container.codingPath) - } - - static func decode(from container: inout UnkeyedDecodingContainer) throws -> Any { - if let value = try? container.decode(Bool.self) { - return value - } - if let value = try? container.decode(Int64.self) { - return value - } - if let value = try? container.decode(Double.self) { - return value - } - if let value = try? container.decode(String.self) { - return value - } - if let value = try? container.decodeNil() { - if value { - return JSONNull() - } - } - if var container = try? container.nestedUnkeyedContainer() { - return try self.decodeArray(from: &container) - } - if var container = try? container.nestedContainer(keyedBy: JSONCodingKey.self) { - return try self.decodeDictionary(from: &container) - } - throw self.decodingError(forCodingPath: container.codingPath) - } - - static func decode( - from container: inout KeyedDecodingContainer, - forKey key: JSONCodingKey) throws -> Any - { - if let value = try? container.decode(Bool.self, forKey: key) { - return value - } - if let value = try? container.decode(Int64.self, forKey: key) { - return value - } - if let value = try? container.decode(Double.self, forKey: key) { - return value - } - if let value = try? container.decode(String.self, forKey: key) { - return value - } - if let value = try? container.decodeNil(forKey: key) { - if value { - return JSONNull() - } - } - if var container = try? container.nestedUnkeyedContainer(forKey: key) { - return try self.decodeArray(from: &container) - } - if var container = try? container.nestedContainer(keyedBy: JSONCodingKey.self, forKey: key) { - return try self.decodeDictionary(from: &container) - } - throw self.decodingError(forCodingPath: container.codingPath) - } - - static func decodeArray(from container: inout UnkeyedDecodingContainer) throws -> [Any] { - var arr: [Any] = [] - while !container.isAtEnd { - let value = try decode(from: &container) - arr.append(value) - } - return arr - } - - static func decodeDictionary(from container: inout KeyedDecodingContainer) throws -> [String: Any] { - var dict = [String: Any]() - for key in container.allKeys { - let value = try decode(from: &container, forKey: key) - dict[key.stringValue] = value - } - return dict - } - - static func encode(to container: inout UnkeyedEncodingContainer, array: [Any]) throws { - for value in array { - if let value = value as? Bool { - try container.encode(value) - } else if let value = value as? Int64 { - try container.encode(value) - } else if let value = value as? Double { - try container.encode(value) - } else if let value = value as? String { - try container.encode(value) - } else if value is JSONNull { - try container.encodeNil() - } else if let value = value as? [Any] { - var container = container.nestedUnkeyedContainer() - try self.encode(to: &container, array: value) - } else if let value = value as? [String: Any] { - var container = container.nestedContainer(keyedBy: JSONCodingKey.self) - try self.encode(to: &container, dictionary: value) - } else { - throw self.encodingError(forValue: value, codingPath: container.codingPath) - } - } - } - - static func encode(to container: inout KeyedEncodingContainer, dictionary: [String: Any]) throws { - for (key, value) in dictionary { - let key = JSONCodingKey(stringValue: key)! - if let value = value as? Bool { - try container.encode(value, forKey: key) - } else if let value = value as? Int64 { - try container.encode(value, forKey: key) - } else if let value = value as? Double { - try container.encode(value, forKey: key) - } else if let value = value as? String { - try container.encode(value, forKey: key) - } else if value is JSONNull { - try container.encodeNil(forKey: key) - } else if let value = value as? [Any] { - var container = container.nestedUnkeyedContainer(forKey: key) - try self.encode(to: &container, array: value) - } else if let value = value as? [String: Any] { - var container = container.nestedContainer(keyedBy: JSONCodingKey.self, forKey: key) - try self.encode(to: &container, dictionary: value) - } else { - throw self.encodingError(forValue: value, codingPath: container.codingPath) - } - } - } - - static func encode(to container: inout SingleValueEncodingContainer, value: Any) throws { - if let value = value as? Bool { - try container.encode(value) - } else if let value = value as? Int64 { - try container.encode(value) - } else if let value = value as? Double { - try container.encode(value) - } else if let value = value as? String { - try container.encode(value) - } else if value is JSONNull { - try container.encodeNil() - } else { - throw self.encodingError(forValue: value, codingPath: container.codingPath) - } - } - - required init(from decoder: Decoder) throws { - if var arrayContainer = try? decoder.unkeyedContainer() { - self.value = try JSONAny.decodeArray(from: &arrayContainer) - } else if var container = try? decoder.container(keyedBy: JSONCodingKey.self) { - self.value = try JSONAny.decodeDictionary(from: &container) - } else { - let container = try decoder.singleValueContainer() - self.value = try JSONAny.decode(from: container) - } - } - - func encode(to encoder: Encoder) throws { - if let arr = self.value as? [Any] { - var container = encoder.unkeyedContainer() - try JSONAny.encode(to: &container, array: arr) - } else if let dict = self.value as? [String: Any] { - var container = encoder.container(keyedBy: JSONCodingKey.self) - try JSONAny.encode(to: &container, dictionary: dict) - } else { - var container = encoder.singleValueContainer() - try JSONAny.encode(to: &container, value: self.value) - } - } -} diff --git a/package.json b/package.json index df2d6d8e6..b1fddab7f 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,8 @@ "test": "vitest", "test:coverage": "vitest run --coverage", "protocol:gen": "tsx scripts/protocol-gen.ts", - "protocol:check": "pnpm protocol:gen && git diff --exit-code -- dist/protocol.schema.json apps/macos/Sources/ClawdisProtocol/Protocol.swift", + "protocol:gen:swift": "tsx scripts/protocol-gen-swift.ts", + "protocol:check": "pnpm protocol:gen && pnpm protocol:gen:swift && git diff --exit-code -- dist/protocol.schema.json apps/macos/Sources/ClawdisProtocol/GatewayModels.swift", "webchat:bundle": "rolldown -c apps/macos/Sources/Clawdis/Resources/WebChat/rolldown.config.mjs" }, "keywords": [], diff --git a/scripts/protocol-gen-swift.ts b/scripts/protocol-gen-swift.ts new file mode 100644 index 000000000..293805ad0 --- /dev/null +++ b/scripts/protocol-gen-swift.ts @@ -0,0 +1,200 @@ +import { promises as fs } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { + ErrorCodes, + PROTOCOL_VERSION, + ProtocolSchemas, +} from "../src/gateway/protocol/schema.js"; + +type JsonSchema = { + type?: string | string[]; + properties?: Record; + required?: string[]; + items?: JsonSchema; + enum?: string[]; + patternProperties?: Record; +}; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(__dirname, ".."); +const outPath = path.join( + repoRoot, + "apps", + "macos", + "Sources", + "ClawdisProtocol", + "GatewayModels.swift", +); + +const header = `// Generated by scripts/protocol-gen-swift.ts — do not edit by hand\nimport Foundation\n\npublic let GATEWAY_PROTOCOL_VERSION = ${PROTOCOL_VERSION}\n\npublic enum ErrorCode: String, Codable {\n${Object.values(ErrorCodes) + .map((c) => ` case ${camelCase(c)} = "${c}"`) + .join("\n")}\n}\n`; + +function camelCase(input: string) { + return input + .toLowerCase() + .split("_") + .map((p, i) => (i === 0 ? p : p[0].toUpperCase() + p.slice(1))) + .join(""); +} + +function swiftType(schema: JsonSchema, required: boolean): string { + const t = schema.type; + const isOptional = !required; + let base: string; + 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 + } else if (schema.patternProperties) { + base = "[String: AnyCodable]"; + } else if (t === "object") { + base = "[String: AnyCodable]"; + } else { + base = "AnyCodable"; + } + return isOptional ? `${base}?` : base; +} + +function emitStruct(name: string, schema: JsonSchema): string { + const props = schema.properties ?? {}; + const required = new Set(schema.required ?? []); + const lines: string[] = []; + lines.push(`public struct ${name}: Codable {`); + if (Object.keys(props).length === 0) { + lines.push("}\n"); + return lines.join("\n"); + } + for (const [key, propSchema] of Object.entries(props)) { + const propName = key === "description" ? "desc" : key; + const propType = swiftType(propSchema, required.has(key)); + lines.push(` public let ${propName}: ${propType}`); + } + lines.push("\n public init(\n" + + Object.entries(props) + .map(([key, prop]) => { + const propName = key === "description" ? "desc" : key; + const req = required.has(key); + return ` ${propName}: ${swiftType(prop, true)}${req ? "" : "?"}`; + }) + .join(",\n") + + "\n ) {\n" + + Object.entries(props) + .map(([key]) => { + const propName = key === "description" ? "desc" : key; + return ` self.${propName} = ${propName}`; + }) + .join("\n") + + "\n }\n}"); + lines.push(""); + return lines.join("\n"); +} + +function emitGatewayFrame(): string { + const cases = [ + "hello", + "hello-ok", + "hello-error", + "req", + "res", + "event", + ]; + const associated: Record = { + hello: "Hello", + "hello-ok": "HelloOk", + "hello-error": "HelloError", + req: "RequestFrame", + res: "ResponseFrame", + event: "EventFrame", + }; + const caseLines = cases.map((c) => ` case ${camelCase(c)}(${associated[c]})`); + const initLines = ` + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let raw = try container.decode([String: AnyCodable].self) + guard let type = raw["type"]?.value as? String else { + throw DecodingError.dataCorruptedError(in: container, debugDescription: "missing type") + } + switch type { + case "hello": + self = .hello(try decodePayload(Hello.self, from: raw)) + case "hello-ok": + self = .helloOk(try decodePayload(HelloOk.self, from: raw)) + case "hello-error": + self = .helloError(try decodePayload(HelloError.self, from: raw)) + case "req": + self = .req(try decodePayload(RequestFrame.self, from: raw)) + case "res": + self = .res(try decodePayload(ResponseFrame.self, from: raw)) + case "event": + self = .event(try decodePayload(EventFrame.self, from: raw)) + default: + throw DecodingError.dataCorruptedError(in: container, debugDescription: "unknown type \(type)") + } + } + + public func encode(to encoder: Encoder) throws { + switch self { + case .hello(let v): try v.encode(to: encoder) + case .helloOk(let v): try v.encode(to: encoder) + case .helloError(let v): try v.encode(to: encoder) + case .req(let v): try v.encode(to: encoder) + case .res(let v): try v.encode(to: encoder) + case .event(let v): try v.encode(to: encoder) + } + } +`; + + const helper = ` + private 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) + } +`; + + return [ + "public enum GatewayFrame: Codable {", + ...caseLines, + initLines, + helper, + "}", + "", + ].join("\n"); +} + +async function generate() { + const definitions = Object.entries(ProtocolSchemas) as Array< + [string, JsonSchema] + >; + + const parts: string[] = []; + parts.push(header); + + // Value structs + for (const [name, schema] of definitions) { + if (name === "GatewayFrame") continue; + if (schema.type === "object") { + parts.push(emitStruct(name, schema)); + } + } + + // Frame enum must come after payload structs + parts.push(emitGatewayFrame()); + + const content = parts.join("\n"); + await fs.mkdir(path.dirname(outPath), { recursive: true }); + await fs.writeFile(outPath, content); + console.log(`wrote ${outPath}`); +} + +generate().catch((err) => { + console.error(err); + process.exit(1); +});