Files
clawdbot/scripts/protocol-gen-swift.ts
2025-12-12 16:37:59 +00:00

253 lines
7.3 KiB
TypeScript

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<string, JsonSchema>;
required?: string[];
items?: JsonSchema;
enum?: string[];
patternProperties?: Record<string, JsonSchema>;
};
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`;
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(/\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;
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 = "String";
} 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");
}
const codingKeys: string[] = [];
for (const [key, propSchema] of Object.entries(props)) {
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 = safeName(key);
const req = required.has(key);
return ` ${propName}: ${swiftType(prop, true)}${req ? "" : "?"}`;
})
.join(",\n") +
"\n ) {\n" +
Object.entries(props)
.map(([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 associated: Record<string, string> = {
hello: "Hello",
"hello-ok": "HelloOk",
"hello-error": "HelloError",
req: "RequestFrame",
res: "ResponseFrame",
event: "EventFrame",
};
const caseLines = cases.map((c) => ` case ${safeName(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 Self.decodePayload(Hello.self, from: raw))
case "hello-ok":
self = .helloOk(try Self.decodePayload(HelloOk.self, from: raw))
case "hello-error":
self = .helloError(try Self.decodePayload(HelloError.self, from: raw))
case "req":
self = .req(try Self.decodePayload(RequestFrame.self, from: raw))
case "res":
self = .res(try Self.decodePayload(ResponseFrame.self, from: raw))
case "event":
self = .event(try Self.decodePayload(EventFrame.self, from: raw))
default:
self = .unknown(type: type, raw: raw)
}
}
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)
case .unknown(_, let raw):
var container = encoder.singleValueContainer()
try container.encode(raw)
}
}
`;
const helper = `
private static func decodePayload<T: Decodable>(_ type: T.Type, from raw: [String: AnyCodable]) throws -> T {
// raw is [String: AnyCodable] which is not directly JSONSerialization-compatible.
// Round-trip through JSONEncoder so AnyCodable can encode itself safely.
let data = try JSONEncoder().encode(raw)
let decoder = JSONDecoder()
return try decoder.decode(T.self, from: data)
}
`;
return [
"public enum GatewayFrame: Codable {",
...caseLines,
" case unknown(type: String, raw: [String: AnyCodable])",
initLines,
helper,
"}",
"",
].join("\n");
}
async function generate() {
const definitions = Object.entries(ProtocolSchemas) as Array<
[string, JsonSchema]
>;
for (const [name, schema] of definitions) {
schemaNameByObject.set(schema as object, name);
}
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);
});