Mac: build GatewayProtocol target and typed presence handling

This commit is contained in:
Peter Steinberger
2025-12-09 15:35:06 +01:00
parent a7737912b0
commit 336c9d6caa
6 changed files with 262 additions and 101 deletions

View File

@@ -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: [

View File

@@ -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()
}

View File

@@ -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:

View File

@@ -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)

View File

@@ -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<T: Decodable>(_ type: T.Type, from raw: [String: AnyCodable]) throws -> T {
private static func decodePayload<T: Decodable>(_ 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)

View File

@@ -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<object, string>();
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<string, string> = {
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<T: Decodable>(_ type: T.Type, from raw: [String: AnyCodable]) throws -> T {
private static func decodePayload<T: Decodable>(_ 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);