From f280f997e8903ea70d35148a129df3d45bc44c34 Mon Sep 17 00:00:00 2001 From: u2bo <285077078@qq.com> Date: Fri, 24 Jul 2026 16:44:05 +0800 Subject: [PATCH 01/23] build: freeze v0.2.0 go migration baseline --- contracts/baseline/v0.2.0/release-assets.json | 172 ++++++++++++ package.json | 1 + scripts/contracts/baseline.ts | 261 ++++++++++++++++++ scripts/contracts/verify-baseline.ts | 9 + tests/contracts/baseline-metadata.test.ts | 27 ++ 5 files changed, 470 insertions(+) create mode 100644 contracts/baseline/v0.2.0/release-assets.json create mode 100644 scripts/contracts/baseline.ts create mode 100644 scripts/contracts/verify-baseline.ts create mode 100644 tests/contracts/baseline-metadata.test.ts diff --git a/contracts/baseline/v0.2.0/release-assets.json b/contracts/baseline/v0.2.0/release-assets.json new file mode 100644 index 0000000..f9f3daf --- /dev/null +++ b/contracts/baseline/v0.2.0/release-assets.json @@ -0,0 +1,172 @@ +{ + "schemaVersion": 1, + "baselineVersion": "0.2.0", + "release": { + "tag": "v0.2.0", + "publishedAt": "2026-07-24T06:54:04Z", + "assets": [ + { + "name": "OpenChatGPTSkin_0.2.0_windows_x64_Setup.exe", + "platform": "windows", + "arch": "x64", + "kind": "setup", + "bytes": 34083496, + "sha256": "2919f5564270932222c2dfd75d776ab08af32922dc600ec64bec687b9b26fadc" + }, + { + "name": "OpenChatGPTSkin_0.2.0_windows_x64.zip", + "platform": "windows", + "arch": "x64", + "kind": "zip", + "bytes": 44744668, + "sha256": "a4d36b5c36479179222dfc1c77bad33fb51358c1b7a90cbc84f13e2fb67fd4b5" + }, + { + "name": "OpenChatGPTSkin_0.2.0_macos_arm64.dmg", + "platform": "macos", + "arch": "arm64", + "kind": "dmg", + "bytes": 59251126, + "sha256": "9d5c0cbbe888c9fb8262800d94fd08c997e7115a5e74dfc1484321237268245d" + }, + { + "name": "OpenChatGPTSkin_0.2.0_macos_arm64.tar.gz", + "platform": "macos", + "arch": "arm64", + "kind": "tar.gz", + "bytes": 48772737, + "sha256": "7ff9db996a31a59b766a20d0f2302a58294ed20cb0b1b015f1ba94a83e1408b6" + }, + { + "name": "OpenChatGPTSkin_0.2.0_macos_x64.dmg", + "platform": "macos", + "arch": "x64", + "kind": "dmg", + "bytes": 62221496, + "sha256": "5e9f08c624ec65244fe3a761cfc961da35bbd5eafbf397dafb4a9f0f8214ca94" + }, + { + "name": "OpenChatGPTSkin_0.2.0_macos_x64.tar.gz", + "platform": "macos", + "arch": "x64", + "kind": "tar.gz", + "bytes": 51253278, + "sha256": "9c7fe62b8bcbfc69962be1804f0af115a1b31b4e9a8cc044220045e616ca0cf5" + }, + { + "name": "checksums.txt", + "platform": "all", + "arch": "all", + "kind": "checksums", + "bytes": 632, + "sha256": "ba4da68cdccd7c04ad9e5b847f07ec0c553beea3e5e3d4e256c397574d28def3" + } + ] + }, + "stages": [ + { + "target": "windows-x64", + "sourceAsset": "OpenChatGPTSkin_0.2.0_windows_x64.zip", + "totalBytes": 115070815, + "composition": { + "apps": 502939, + "LICENSE": 1577, + "node_modules": 24632575, + "launcher": 204, + "README.en.md": 24611, + "README.md": 23125, + "release-manifest.json": 115757, + "runtime": 85347134, + "themes": 4422893 + } + }, + { + "target": "macos-arm64", + "sourceAsset": "OpenChatGPTSkin_0.2.0_macos_arm64.tar.gz", + "totalBytes": 136974297, + "composition": { + "apps": 502927, + "LICENSE": 1546, + "node_modules": 21105393, + "launcher": 282, + "README.en.md": 24611, + "README.md": 23125, + "release-manifest.json": 116339, + "runtime": 110777181, + "themes": 4422893 + } + }, + { + "target": "macos-x64", + "sourceAsset": "OpenChatGPTSkin_0.2.0_macos_x64.tar.gz", + "totalBytes": 141958550, + "composition": { + "apps": 502927, + "LICENSE": 1546, + "node_modules": 23002262, + "launcher": 282, + "README.en.md": 24611, + "README.md": 23125, + "release-manifest.json": 116315, + "runtime": 113864589, + "themes": 4422893 + } + } + ], + "contracts": { + "studioProtocol": 2, + "runtimeControl": 1, + "theme": 4, + "draft": 1, + "runtimeState": 2, + "trustedInstallCache": 1, + "themeStore": 1, + "controllerLock": 1, + "releaseManifest": 1, + "themeCatalog": 1 + }, + "themes": { + "catalog": { + "bytes": 1718, + "sha256": "db222609e1eb0df84a001ff3823171a20dc000f8f91bec1155a852de059c7749" + }, + "directoryHashAlgorithm": "sha256-path-length-content-v1", + "builtins": [ + { + "id": "future-idol-cyan", + "path": "builtin/future-idol-cyan", + "bytes": 443292, + "fileCount": 10, + "sha256": "cc55d0c4ddc7fc17667b7a1763599e11f565dc4c066a226aa2082ad8c4e6d35e" + }, + { + "id": "glacier-aurora", + "path": "builtin/glacier-aurora", + "bytes": 370100, + "fileCount": 10, + "sha256": "50692ab6612c5c8a09dacc883403263d739811e145f13e08339461daa4930f6a" + }, + { + "id": "mountain-mist", + "path": "builtin/mountain-mist", + "bytes": 253055, + "fileCount": 10, + "sha256": "de63ea7d9de6c759b56eb444f15b4cfb2d9ea24c2962fd9cfb917b0b36fcdd8a" + }, + { + "id": "rose-carpet-star", + "path": "builtin/rose-carpet-star", + "bytes": 339181, + "fileCount": 10, + "sha256": "51208967070e6337fb7111f6722ad596bab0240c9e948c404df13e5563c8ce54" + }, + { + "id": "yua-mikami-starlight", + "path": "builtin/yua-mikami-starlight", + "bytes": 3015547, + "fileCount": 15, + "sha256": "cb3c6b37c64edd5dd67038bd8123510ecd6c14823bfb703d746451cd216962b5" + } + ] + } +} diff --git a/package.json b/package.json index 6e4725e..554b6ec 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "scripts": { "build": "tsc -b", "themes:build": "tsx --tsconfig tsconfig.scripts.json scripts/build-theme-catalog.ts", + "go:baseline:verify": "tsx --tsconfig tsconfig.scripts.json scripts/contracts/verify-baseline.ts", "typecheck": "tsc -b --pretty false", "test": "vitest run", "test:watch": "vitest", diff --git a/scripts/contracts/baseline.ts b/scripts/contracts/baseline.ts new file mode 100644 index 0000000..6105444 --- /dev/null +++ b/scripts/contracts/baseline.ts @@ -0,0 +1,261 @@ +import { createHash } from "node:crypto"; +import { readFile, readdir } from "node:fs/promises"; +import { join, relative, resolve, sep } from "node:path"; +import { STUDIO_PROTOCOL_VERSION } from + "../../packages/theme-studio-core/src/contracts.js"; +import { THEME_SCHEMA_VERSION } from + "../../packages/theme-schema/src/index.js"; +import { CONTROL_PROTOCOL_VERSION } from + "../../runtime/windows/src/control/result.js"; +import { z } from "zod"; + +const SHA256_PATTERN = /^[0-9a-f]{64}$/; +const BASELINE_RELATIVE_PATH = "contracts/baseline/v0.2.0/release-assets.json"; +const DIRECTORY_HASH_ALGORITHM = "sha256-path-length-content-v1"; + +const HashSchema = z.string().regex(SHA256_PATTERN); + +const FileMeasurementSchema = z.object({ + bytes: z.number().int().nonnegative(), + sha256: HashSchema, +}).strict(); + +const BaselineSchema = z.object({ + schemaVersion: z.literal(1), + baselineVersion: z.literal("0.2.0"), + release: z.object({ + tag: z.literal("v0.2.0"), + publishedAt: z.string().datetime(), + assets: z.array(z.object({ + name: z.string().min(1), + platform: z.enum(["windows", "macos", "all"]), + arch: z.enum(["x64", "arm64", "all"]), + kind: z.enum(["setup", "zip", "dmg", "tar.gz", "checksums"]), + bytes: z.number().int().positive(), + sha256: HashSchema, + }).strict()).length(7), + }).strict(), + stages: z.array(z.object({ + target: z.enum(["windows-x64", "macos-arm64", "macos-x64"]), + sourceAsset: z.string().min(1), + totalBytes: z.number().int().positive(), + composition: z.record(z.string(), z.number().int().nonnegative()), + }).strict()).length(3), + contracts: z.object({ + studioProtocol: z.literal(2), + runtimeControl: z.literal(1), + theme: z.literal(4), + draft: z.literal(1), + runtimeState: z.literal(2), + trustedInstallCache: z.literal(1), + themeStore: z.literal(1), + controllerLock: z.literal(1), + releaseManifest: z.literal(1), + themeCatalog: z.literal(1), + }).strict(), + themes: z.object({ + catalog: FileMeasurementSchema, + directoryHashAlgorithm: z.literal(DIRECTORY_HASH_ALGORITHM), + builtins: z.array(z.object({ + id: z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/), + path: z.string().regex(/^builtin\/[a-z0-9]+(?:-[a-z0-9]+)*$/), + bytes: z.number().int().positive(), + fileCount: z.number().int().positive(), + sha256: HashSchema, + }).strict()).length(5), + }).strict(), +}).strict(); + +type FrozenBaseline = z.infer; + +export interface BaselineVerificationReport { + readonly baselineVersion: "0.2.0"; + readonly releaseAssetCount: 7; + readonly stageCount: 3; + readonly themeCount: 5; + readonly verified: true; +} + +interface DirectoryMeasurement { + readonly bytes: number; + readonly fileCount: number; + readonly sha256: string; +} + +interface ThemeCatalog { + readonly schemaVersion: number; + readonly builtins: readonly { + readonly id: string; + readonly path: string; + }[]; +} + +async function walkFiles(root: string, current = root): Promise { + const files: string[] = []; + for (const entry of await readdir(current, { withFileTypes: true })) { + const path = join(current, entry.name); + if (entry.isDirectory()) { + files.push(...await walkFiles(root, path)); + } else if (entry.isFile()) { + files.push(path); + } else { + throw new Error(`Baseline theme contains a non-file entry: ${path}`); + } + } + return files.sort((left, right) => + toPortablePath(relative(root, left)).localeCompare( + toPortablePath(relative(root, right)), + "en", + ) + ); +} + +function toPortablePath(path: string): string { + return path.split(sep).join("/"); +} + +async function measureDirectory(root: string): Promise { + const hash = createHash("sha256"); + let bytes = 0; + const files = await walkFiles(root); + for (const path of files) { + const relativePath = toPortablePath(relative(root, path)); + const contents = await readFile(path); + bytes += contents.length; + hash.update(relativePath, "utf8"); + hash.update(Buffer.from([0])); + hash.update(String(contents.length), "ascii"); + hash.update(Buffer.from([0])); + hash.update(contents); + } + return { bytes, fileCount: files.length, sha256: hash.digest("hex") }; +} + +function sha256(contents: Uint8Array): string { + return createHash("sha256").update(contents).digest("hex"); +} + +function assertUnique(values: readonly string[], label: string): void { + if (new Set(values).size !== values.length) { + throw new Error(`Baseline ${label} must be unique`); + } +} + +function assertPrivacy(value: unknown, path = "baseline"): void { + if (Array.isArray(value)) { + value.forEach((entry, index) => assertPrivacy(entry, `${path}[${index}]`)); + return; + } + if (typeof value === "object" && value !== null) { + for (const [key, entry] of Object.entries(value)) { + if (/^(?:pid|port|token|session|userName|projectName|absolutePath)$/i.test(key)) { + throw new Error(`Baseline contains forbidden sensitive field: ${path}.${key}`); + } + assertPrivacy(entry, `${path}.${key}`); + } + return; + } + if (typeof value !== "string") return; + if (/^[a-z]:[\\/]/i.test(value) || + /^\/(?:Users|home)\//.test(value) || + /(?:^|[\\/])AppData(?:[\\/]|$)/i.test(value)) { + throw new Error(`Baseline contains an absolute user path: ${path}`); + } +} + +function assertStage(stage: FrozenBaseline["stages"][number]): void { + const required = [ + "apps", + "LICENSE", + "node_modules", + "launcher", + "README.en.md", + "README.md", + "release-manifest.json", + "runtime", + "themes", + ]; + for (const component of required) { + if (!(component in stage.composition)) { + throw new Error(`Baseline stage ${stage.target} is missing ${component}`); + } + } + const measuredTotal = Object.values(stage.composition).reduce( + (total, componentBytes) => total + componentBytes, + 0, + ); + if (measuredTotal !== stage.totalBytes) { + throw new Error( + `Baseline stage ${stage.target} total mismatch: ${measuredTotal} != ${stage.totalBytes}`, + ); + } +} + +async function readBaseline(workspaceRoot: string): Promise { + const path = join(workspaceRoot, ...BASELINE_RELATIVE_PATH.split("/")); + const value = JSON.parse(await readFile(path, "utf8")) as unknown; + assertPrivacy(value); + return BaselineSchema.parse(value); +} + +export async function verifyFrozenBaseline( + workspaceRootInput: string, +): Promise { + const workspaceRoot = resolve(workspaceRootInput); + const baseline = await readBaseline(workspaceRoot); + + assertUnique(baseline.release.assets.map((asset) => asset.name), "release asset names"); + assertUnique(baseline.stages.map((stage) => stage.target), "stage targets"); + assertUnique(baseline.themes.builtins.map((theme) => theme.id), "theme IDs"); + baseline.stages.forEach(assertStage); + + if (baseline.contracts.studioProtocol !== STUDIO_PROTOCOL_VERSION || + baseline.contracts.runtimeControl !== CONTROL_PROTOCOL_VERSION || + baseline.contracts.theme !== THEME_SCHEMA_VERSION) { + throw new Error("Baseline protocol versions do not match the Node author sources"); + } + + const themesRoot = join(workspaceRoot, "themes"); + const catalogBytes = await readFile(join(themesRoot, "catalog.json")); + if (catalogBytes.length !== baseline.themes.catalog.bytes || + sha256(catalogBytes) !== baseline.themes.catalog.sha256) { + throw new Error("Baseline theme catalog hash does not match the workspace"); + } + const catalog = JSON.parse(catalogBytes.toString("utf8")) as ThemeCatalog; + if (catalog.schemaVersion !== baseline.contracts.themeCatalog) { + throw new Error("Baseline theme catalog schema version does not match the workspace"); + } + + const actualCatalogThemes = catalog.builtins.map(({ id, path }) => ({ id, path })); + const expectedCatalogThemes = baseline.themes.builtins.map(({ id, path }) => ({ id, path })); + if (JSON.stringify(actualCatalogThemes) !== JSON.stringify(expectedCatalogThemes)) { + throw new Error("Baseline built-in theme catalog does not match the workspace"); + } + + for (const expected of baseline.themes.builtins) { + const actual = await measureDirectory( + join(themesRoot, ...expected.path.split("/")), + ); + if (actual.bytes !== expected.bytes || + actual.fileCount !== expected.fileCount || + actual.sha256 !== expected.sha256) { + throw new Error(`Baseline built-in theme changed: ${expected.id}`); + } + } + + const themePayloadBytes = baseline.themes.catalog.bytes + + baseline.themes.builtins.reduce((total, theme) => total + theme.bytes, 0); + for (const stage of baseline.stages) { + if (stage.composition.themes !== themePayloadBytes) { + throw new Error(`Baseline stage theme size changed: ${stage.target}`); + } + } + + return { + baselineVersion: baseline.baselineVersion, + releaseAssetCount: 7, + stageCount: 3, + themeCount: 5, + verified: true, + }; +} diff --git a/scripts/contracts/verify-baseline.ts b/scripts/contracts/verify-baseline.ts new file mode 100644 index 0000000..33166c3 --- /dev/null +++ b/scripts/contracts/verify-baseline.ts @@ -0,0 +1,9 @@ +import { verifyFrozenBaseline } from "./baseline.js"; + +try { + const result = await verifyFrozenBaseline(process.cwd()); + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); +} catch (error) { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 1; +} diff --git a/tests/contracts/baseline-metadata.test.ts b/tests/contracts/baseline-metadata.test.ts new file mode 100644 index 0000000..3840747 --- /dev/null +++ b/tests/contracts/baseline-metadata.test.ts @@ -0,0 +1,27 @@ +import { readFile } from "node:fs/promises"; +import { describe, expect, it } from "vitest"; +import { verifyFrozenBaseline } from "../../scripts/contracts/baseline.js"; + +describe("v0.2.0 Go migration baseline", () => { + it("verifies the frozen release, contract, stage, and theme evidence read-only", async () => { + const report = await verifyFrozenBaseline(process.cwd()); + + expect(report).toEqual({ + baselineVersion: "0.2.0", + releaseAssetCount: 7, + stageCount: 3, + themeCount: 5, + verified: true, + }); + }); + + it("exposes the verifier through the documented package command", async () => { + const packageJson = JSON.parse(await readFile("package.json", "utf8")) as { + readonly scripts?: Readonly>; + }; + + expect(packageJson.scripts?.["go:baseline:verify"]).toBe( + "tsx --tsconfig tsconfig.scripts.json scripts/contracts/verify-baseline.ts", + ); + }); +}); From f505111e42d2bb9d144ab17c38cbb783980fa707 Mon Sep 17 00:00:00 2001 From: u2bo <285077078@qq.com> Date: Fri, 24 Jul 2026 16:57:23 +0800 Subject: [PATCH 02/23] feat: generate cross-language contracts --- contracts/data/v1/cases/compatibility.json | 83 + contracts/data/v1/schemas/index.json | 4220 ++++++++++++++ contracts/runtime/v1/cases/semantics.json | 127 + contracts/runtime/v1/frames.json | 66 + contracts/runtime/v1/schemas/index.json | 673 +++ contracts/studio/v2/cases/http.json | 48 + contracts/studio/v2/routes.json | 253 + contracts/studio/v2/schemas/index.json | 5042 +++++++++++++++++ contracts/theme/v4/archive-cases.json | 109 + contracts/theme/v4/draft-schema.json | 902 +++ contracts/theme/v4/migrations.json | 2696 +++++++++ contracts/theme/v4/schema.json | 909 +++ contracts/theme/v4/semantic-cases.json | 64 + package-lock.json | 13 +- package.json | 7 +- packages/theme-core/src/archive.ts | 15 +- packages/theme-core/src/storage.ts | 4 +- packages/theme-schema/src/contract.ts | 40 + packages/theme-schema/src/index.ts | 1 + packages/theme-schema/src/theme.ts | 23 +- packages/theme-studio-core/src/contracts.ts | 4 + packages/theme-studio-core/src/errors.ts | 18 + .../theme-studio-core/src/http-contract.ts | 88 + packages/theme-studio-core/src/index.ts | 1 + runtime/theme-studio-service/src/http.ts | 18 +- runtime/theme-studio-service/src/server.ts | 91 +- runtime/theme-studio-service/src/workspace.ts | 4 +- .../windows/src/control/controller-lock.ts | 2 +- runtime/windows/src/control/protocol.ts | 30 +- runtime/windows/src/controller/recovery.ts | 9 + .../windows/src/discovery/trusted-cache.ts | 6 +- runtime/windows/src/session/state-machine.ts | 4 +- scripts/contracts/build.ts | 303 + scripts/contracts/verify.ts | 9 + scripts/release/payload.ts | 47 +- tests/contracts/generator.test.ts | 143 + tsconfig.scripts.json | 9 + 37 files changed, 15963 insertions(+), 118 deletions(-) create mode 100644 contracts/data/v1/cases/compatibility.json create mode 100644 contracts/data/v1/schemas/index.json create mode 100644 contracts/runtime/v1/cases/semantics.json create mode 100644 contracts/runtime/v1/frames.json create mode 100644 contracts/runtime/v1/schemas/index.json create mode 100644 contracts/studio/v2/cases/http.json create mode 100644 contracts/studio/v2/routes.json create mode 100644 contracts/studio/v2/schemas/index.json create mode 100644 contracts/theme/v4/archive-cases.json create mode 100644 contracts/theme/v4/draft-schema.json create mode 100644 contracts/theme/v4/migrations.json create mode 100644 contracts/theme/v4/schema.json create mode 100644 contracts/theme/v4/semantic-cases.json create mode 100644 packages/theme-schema/src/contract.ts create mode 100644 packages/theme-studio-core/src/http-contract.ts create mode 100644 scripts/contracts/build.ts create mode 100644 scripts/contracts/verify.ts create mode 100644 tests/contracts/generator.test.ts diff --git a/contracts/data/v1/cases/compatibility.json b/contracts/data/v1/cases/compatibility.json new file mode 100644 index 0000000..cb194c1 --- /dev/null +++ b/contracts/data/v1/cases/compatibility.json @@ -0,0 +1,83 @@ +{ + "cases": [ + { + "name": "draft v1", + "schema": "draftRecord", + "valid": true, + "version": 1 + }, + { + "name": "theme store v1", + "schema": "themeStore", + "valid": true, + "version": 1 + }, + { + "name": "runtime state v2", + "schema": "runtimeState", + "valid": true, + "version": 2 + }, + { + "name": "trusted install cache v1", + "schema": "trustedInstall", + "valid": true, + "version": 1 + }, + { + "name": "controller lock v1", + "schema": "controllerLock", + "valid": true, + "version": 1 + }, + { + "name": "release manifest v1", + "schema": "releaseManifest", + "valid": true, + "version": 1 + }, + { + "expectedErrorCode": "DATA_SCHEMA_INVALID", + "expectedPath": "/schemaVersion", + "name": "unknown draft version", + "schema": "draftRecord", + "valid": false + }, + { + "expectedErrorCode": "DATA_SCHEMA_INVALID", + "expectedPath": "/past", + "name": "dirty draft without history", + "schema": "draftRecord", + "valid": false + }, + { + "expectedErrorCode": "RUNTIME_INVALID_STATE", + "expectedPath": "/recentRequests/0/requestId", + "name": "runtime request ID mismatch", + "schema": "runtimeState", + "valid": false + }, + { + "expectedErrorCode": "CODEX_IDENTITY_INVALID", + "expectedPath": "/packagePublisher", + "name": "trusted cache identity mismatch", + "schema": "trustedInstall", + "valid": false + }, + { + "expectedErrorCode": "RUNTIME_SESSION_STALE", + "expectedPath": "/startedAt", + "name": "lock without process identity", + "schema": "controllerLock", + "valid": false + }, + { + "expectedErrorCode": "RELEASE_MANIFEST_INVALID", + "expectedPath": "/files/sha256", + "name": "release file hash malformed", + "schema": "releaseManifest", + "valid": false + } + ], + "schemaVersion": 1 +} diff --git a/contracts/data/v1/schemas/index.json b/contracts/data/v1/schemas/index.json new file mode 100644 index 0000000..bf6b753 --- /dev/null +++ b/contracts/data/v1/schemas/index.json @@ -0,0 +1,4220 @@ +{ + "schemas": { + "controllerLock": { + "$ref": "#/definitions/controllerLock", + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "controllerLock": { + "additionalProperties": false, + "properties": { + "pid": { + "exclusiveMinimum": 0, + "type": "integer" + }, + "schemaVersion": { + "const": 1, + "type": "number" + }, + "startedAt": { + "format": "date-time", + "type": "string" + } + }, + "required": [ + "schemaVersion", + "pid", + "startedAt" + ], + "type": "object" + } + } + }, + "draftRecord": { + "$ref": "#/definitions/draftRecord", + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "draftRecord": { + "additionalProperties": false, + "properties": { + "dirty": { + "type": "boolean" + }, + "draftId": { + "format": "uuid", + "type": "string" + }, + "future": { + "items": { + "additionalProperties": false, + "properties": { + "appearance": { + "default": "auto", + "enum": [ + "auto", + "light", + "dark" + ], + "type": "string" + }, + "assets": { + "additionalProperties": false, + "properties": { + "background": { + "type": "string" + }, + "decorations": { + "additionalProperties": { + "type": "string" + }, + "propertyNames": { + "maxLength": 40, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$" + }, + "type": "object" + }, + "fonts": { + "additionalProperties": { + "type": "string" + }, + "propertyNames": { + "maxLength": 40, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$" + }, + "type": "object" + }, + "portrait": { + "type": "string" + }, + "profileAvatar": { + "type": "string" + }, + "projectIcons": { + "items": { + "type": "string" + }, + "maxItems": 12, + "minItems": 1, + "type": "array" + }, + "suggestionIcons": { + "additionalProperties": false, + "properties": { + "card1": { + "type": "string" + }, + "card2": { + "type": "string" + }, + "card3": { + "type": "string" + }, + "card4": { + "type": "string" + } + }, + "type": "object" + } + }, + "type": "object" + }, + "author": { + "maxLength": 80, + "minLength": 1, + "type": "string" + }, + "background": { + "additionalProperties": false, + "properties": { + "blur": { + "maximum": 30, + "minimum": 0, + "type": "number" + }, + "brightness": { + "maximum": 1.5, + "minimum": 0.3, + "type": "number" + }, + "overlay": { + "maximum": 0.9, + "minimum": 0, + "type": "number" + }, + "positionX": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "positionY": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "safeArea": { + "default": "auto", + "enum": [ + "auto", + "left", + "center", + "right", + "none" + ], + "type": "string" + }, + "scale": { + "maximum": 3, + "minimum": 0.5, + "type": "number" + }, + "taskMode": { + "default": "full", + "enum": [ + "auto", + "full", + "ambient", + "banner", + "off" + ], + "type": "string" + }, + "taskOpacity": { + "default": 0.82, + "maximum": 1, + "minimum": 0, + "type": "number" + } + }, + "required": [ + "positionX", + "positionY", + "scale", + "blur", + "brightness", + "overlay" + ], + "type": "object" + }, + "colors": { + "additionalProperties": false, + "properties": { + "accent": { + "type": "string" + }, + "border": { + "type": "string" + }, + "codeText": { + "type": "string" + }, + "danger": { + "type": "string" + }, + "info": { + "type": "string" + }, + "inputText": { + "type": "string" + }, + "link": { + "type": "string" + }, + "muted": { + "type": "string" + }, + "panel": { + "type": "string" + }, + "placeholder": { + "type": "string" + }, + "secondary": { + "type": "string" + }, + "success": { + "type": "string" + }, + "text": { + "type": "string" + }, + "textSecondary": { + "type": "string" + }, + "warning": { + "type": "string" + } + }, + "required": [ + "accent", + "secondary", + "text", + "muted", + "panel", + "border", + "success", + "warning", + "danger", + "info", + "textSecondary", + "link", + "inputText", + "placeholder", + "codeText" + ], + "type": "object" + }, + "composition": { + "additionalProperties": false, + "properties": { + "layers": { + "items": { + "additionalProperties": false, + "properties": { + "anchor": { + "enum": [ + "top-left", + "top-center", + "top-right", + "center-left", + "center", + "center-right", + "bottom-left", + "bottom-center", + "bottom-right" + ], + "type": "string" + }, + "asset": { + "anyOf": [ + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "portrait", + "type": "string" + } + }, + "required": [ + "kind" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "assetKey": { + "maxLength": 40, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "type": "string" + }, + "kind": { + "const": "decoration", + "type": "string" + } + }, + "required": [ + "kind", + "assetKey" + ], + "type": "object" + } + ] + }, + "id": { + "maxLength": 40, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "type": "string" + }, + "opacity": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "positionX": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "positionY": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "required": { + "type": "boolean" + }, + "rotation": { + "maximum": 180, + "minimum": -180, + "type": "number" + }, + "surface": { + "enum": [ + "viewport", + "main", + "home-hero", + "suggestions" + ], + "type": "string" + }, + "width": { + "maximum": 1.5, + "minimum": 0.02, + "type": "number" + } + }, + "required": [ + "id", + "asset", + "surface", + "anchor", + "positionX", + "positionY", + "width", + "opacity", + "rotation", + "required" + ], + "type": "object" + }, + "maxItems": 24, + "type": "array" + } + }, + "required": [ + "layers" + ], + "type": "object" + }, + "decorations": { + "items": { + "additionalProperties": false, + "properties": { + "assetKey": { + "maxLength": 40, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "intensity": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "opacity": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "placement": { + "enum": [ + "background", + "corners", + "hero", + "cards" + ], + "type": "string" + }, + "scale": { + "maximum": 3, + "minimum": 0.25, + "type": "number" + }, + "type": { + "enum": [ + "particles", + "ribbon", + "butterflies", + "polaroid", + "badge", + "sparkles", + "image" + ], + "type": "string" + } + }, + "required": [ + "type", + "enabled", + "intensity" + ], + "type": "object" + }, + "maxItems": 16, + "type": "array" + }, + "description": { + "maxLength": 240, + "minLength": 1, + "type": "string" + }, + "home": { + "additionalProperties": false, + "properties": { + "welcome": { + "additionalProperties": false, + "properties": { + "layout": { + "additionalProperties": false, + "properties": { + "anchor": { + "enum": [ + "top-left", + "top-center", + "top-right", + "center-left", + "center", + "center-right", + "bottom-left", + "bottom-center", + "bottom-right" + ], + "type": "string" + }, + "hideNativeIcon": { + "default": false, + "type": "boolean" + }, + "positionX": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "positionY": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "textAlign": { + "enum": [ + "left", + "center", + "right" + ], + "type": "string" + }, + "width": { + "maximum": 1, + "minimum": 0.2, + "type": "number" + } + }, + "required": [ + "anchor", + "positionX", + "positionY", + "width", + "textAlign" + ], + "type": "object" + }, + "localized": { + "additionalProperties": { + "additionalProperties": false, + "properties": { + "lines": { + "items": { + "type": "string" + }, + "maxItems": 3, + "minItems": 1, + "type": "array" + } + }, + "required": [ + "lines" + ], + "type": "object" + }, + "propertyNames": { + "enum": [ + "zh-CN", + "en" + ] + }, + "type": "object" + } + }, + "required": [ + "localized" + ], + "type": "object" + } + }, + "required": [ + "welcome" + ], + "type": "object" + }, + "id": { + "maxLength": 80, + "minLength": 3, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "type": "string" + }, + "interfaceImages": { + "additionalProperties": false, + "properties": { + "profileAvatarSize": { + "maximum": 48, + "minimum": 16, + "type": "integer" + }, + "projectIconSize": { + "maximum": 32, + "minimum": 12, + "type": "integer" + }, + "suggestionIconSize": { + "maximum": 64, + "minimum": 16, + "type": "integer" + } + }, + "required": [ + "profileAvatarSize", + "suggestionIconSize", + "projectIconSize" + ], + "type": "object" + }, + "kind": { + "enum": [ + "theme", + "recipe" + ], + "type": "string" + }, + "layout": { + "additionalProperties": false, + "properties": { + "cardColumns": { + "maximum": 4, + "minimum": 2, + "type": "integer" + }, + "composerWidth": { + "maximum": 1, + "minimum": 0.5, + "type": "number" + }, + "heroHeight": { + "maximum": 560, + "minimum": 180, + "type": "integer" + }, + "moduleGap": { + "maximum": 48, + "minimum": 0, + "type": "integer" + }, + "modules": { + "items": { + "additionalProperties": false, + "properties": { + "align": { + "enum": [ + "start", + "center", + "end", + "stretch" + ], + "type": "string" + }, + "id": { + "enum": [ + "sidebar", + "topbar", + "hero", + "suggestions", + "project-picker", + "composer", + "task-background", + "content-layer" + ], + "type": "string" + }, + "order": { + "maximum": 7, + "minimum": 0, + "type": "integer" + }, + "size": { + "enum": [ + "compact", + "regular", + "expanded" + ], + "type": "string" + }, + "spacing": { + "maximum": 48, + "minimum": 0, + "type": "integer" + }, + "visible": { + "type": "boolean" + } + }, + "required": [ + "id", + "order", + "visible", + "size", + "align", + "spacing" + ], + "type": "object" + }, + "maxItems": 8, + "minItems": 8, + "type": "array" + }, + "sidebarDensity": { + "enum": [ + "compact", + "comfortable" + ], + "type": "string" + } + }, + "required": [ + "heroHeight", + "cardColumns", + "composerWidth", + "sidebarDensity", + "moduleGap", + "modules" + ], + "type": "object" + }, + "metadata": { + "additionalProperties": false, + "properties": { + "homepage": { + "format": "uri", + "maxLength": 500, + "type": "string" + }, + "localized": { + "additionalProperties": { + "additionalProperties": false, + "properties": { + "description": { + "maxLength": 240, + "minLength": 1, + "type": "string" + }, + "name": { + "maxLength": 80, + "minLength": 1, + "type": "string" + } + }, + "type": "object" + }, + "propertyNames": { + "enum": [ + "zh-CN", + "en" + ] + }, + "type": "object" + } + }, + "type": "object" + }, + "name": { + "maxLength": 80, + "minLength": 1, + "type": "string" + }, + "rights": { + "additionalProperties": false, + "properties": { + "attribution": { + "maxLength": 240, + "minLength": 1, + "type": "string" + }, + "licenseId": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "localOnly": { + "type": "boolean" + }, + "source": { + "format": "uri", + "maxLength": 500, + "type": "string" + } + }, + "required": [ + "licenseId", + "localOnly" + ], + "type": "object" + }, + "schemaVersion": { + "const": 4, + "type": "number" + }, + "surfaces": { + "additionalProperties": false, + "default": { + "baseOpacity": 0.68, + "blur": 0, + "elevatedOpacity": 0.92, + "terminalOpacity": 0.82 + }, + "properties": { + "baseOpacity": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "blur": { + "maximum": 30, + "minimum": 0, + "type": "number" + }, + "elevatedOpacity": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "terminalOpacity": { + "maximum": 1, + "minimum": 0, + "type": "number" + } + }, + "required": [ + "baseOpacity", + "elevatedOpacity", + "terminalOpacity", + "blur" + ], + "type": "object" + }, + "typography": { + "additionalProperties": false, + "properties": { + "codeFamily": { + "maxLength": 120, + "minLength": 1, + "type": "string" + }, + "codeFontAssetKey": { + "maxLength": 40, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "type": "string" + }, + "codeSize": { + "maximum": 22, + "minimum": 11, + "type": "number" + }, + "codeWeight": { + "enum": [ + 400, + 500, + 600, + 700 + ], + "type": "number" + }, + "displayFamily": { + "maxLength": 120, + "minLength": 1, + "type": "string" + }, + "displayFontAssetKey": { + "maxLength": 40, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "type": "string" + }, + "displayLetterSpacing": { + "maximum": 0.2, + "minimum": -0.05, + "type": "number" + }, + "displayLineHeight": { + "maximum": 1.8, + "minimum": 1.1, + "type": "number" + }, + "displaySize": { + "maximum": 72, + "minimum": 20, + "type": "number" + }, + "displayWeight": { + "enum": [ + 400, + 500, + 600, + 700 + ], + "type": "number" + }, + "lineHeight": { + "maximum": 1.8, + "minimum": 1.2, + "type": "number" + }, + "scale": { + "maximum": 1.3, + "minimum": 0.85, + "type": "number" + }, + "uiFamily": { + "maxLength": 120, + "minLength": 1, + "type": "string" + }, + "uiFontAssetKey": { + "maxLength": 40, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "type": "string" + }, + "uiSize": { + "maximum": 22, + "minimum": 12, + "type": "number" + }, + "uiWeight": { + "enum": [ + 400, + 500, + 600, + 700 + ], + "type": "number" + } + }, + "required": [ + "uiFamily", + "codeFamily", + "scale", + "uiSize", + "codeSize", + "uiWeight", + "codeWeight", + "lineHeight", + "displayFamily", + "displaySize", + "displayWeight", + "displayLineHeight", + "displayLetterSpacing" + ], + "type": "object" + }, + "version": { + "pattern": "^(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)$", + "type": "string" + } + }, + "required": [ + "schemaVersion", + "kind", + "id", + "name", + "version", + "author", + "assets", + "colors", + "typography", + "background", + "decorations", + "layout", + "rights", + "interfaceImages", + "composition" + ], + "type": "object" + }, + "maxItems": 50, + "type": "array" + }, + "past": { + "items": { + "additionalProperties": false, + "properties": { + "appearance": { + "default": "auto", + "enum": [ + "auto", + "light", + "dark" + ], + "type": "string" + }, + "assets": { + "additionalProperties": false, + "properties": { + "background": { + "type": "string" + }, + "decorations": { + "additionalProperties": { + "type": "string" + }, + "propertyNames": { + "maxLength": 40, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$" + }, + "type": "object" + }, + "fonts": { + "additionalProperties": { + "type": "string" + }, + "propertyNames": { + "maxLength": 40, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$" + }, + "type": "object" + }, + "portrait": { + "type": "string" + }, + "profileAvatar": { + "type": "string" + }, + "projectIcons": { + "items": { + "type": "string" + }, + "maxItems": 12, + "minItems": 1, + "type": "array" + }, + "suggestionIcons": { + "additionalProperties": false, + "properties": { + "card1": { + "type": "string" + }, + "card2": { + "type": "string" + }, + "card3": { + "type": "string" + }, + "card4": { + "type": "string" + } + }, + "type": "object" + } + }, + "type": "object" + }, + "author": { + "maxLength": 80, + "minLength": 1, + "type": "string" + }, + "background": { + "additionalProperties": false, + "properties": { + "blur": { + "maximum": 30, + "minimum": 0, + "type": "number" + }, + "brightness": { + "maximum": 1.5, + "minimum": 0.3, + "type": "number" + }, + "overlay": { + "maximum": 0.9, + "minimum": 0, + "type": "number" + }, + "positionX": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "positionY": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "safeArea": { + "default": "auto", + "enum": [ + "auto", + "left", + "center", + "right", + "none" + ], + "type": "string" + }, + "scale": { + "maximum": 3, + "minimum": 0.5, + "type": "number" + }, + "taskMode": { + "default": "full", + "enum": [ + "auto", + "full", + "ambient", + "banner", + "off" + ], + "type": "string" + }, + "taskOpacity": { + "default": 0.82, + "maximum": 1, + "minimum": 0, + "type": "number" + } + }, + "required": [ + "positionX", + "positionY", + "scale", + "blur", + "brightness", + "overlay" + ], + "type": "object" + }, + "colors": { + "additionalProperties": false, + "properties": { + "accent": { + "type": "string" + }, + "border": { + "type": "string" + }, + "codeText": { + "type": "string" + }, + "danger": { + "type": "string" + }, + "info": { + "type": "string" + }, + "inputText": { + "type": "string" + }, + "link": { + "type": "string" + }, + "muted": { + "type": "string" + }, + "panel": { + "type": "string" + }, + "placeholder": { + "type": "string" + }, + "secondary": { + "type": "string" + }, + "success": { + "type": "string" + }, + "text": { + "type": "string" + }, + "textSecondary": { + "type": "string" + }, + "warning": { + "type": "string" + } + }, + "required": [ + "accent", + "secondary", + "text", + "muted", + "panel", + "border", + "success", + "warning", + "danger", + "info", + "textSecondary", + "link", + "inputText", + "placeholder", + "codeText" + ], + "type": "object" + }, + "composition": { + "additionalProperties": false, + "properties": { + "layers": { + "items": { + "additionalProperties": false, + "properties": { + "anchor": { + "enum": [ + "top-left", + "top-center", + "top-right", + "center-left", + "center", + "center-right", + "bottom-left", + "bottom-center", + "bottom-right" + ], + "type": "string" + }, + "asset": { + "anyOf": [ + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "portrait", + "type": "string" + } + }, + "required": [ + "kind" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "assetKey": { + "maxLength": 40, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "type": "string" + }, + "kind": { + "const": "decoration", + "type": "string" + } + }, + "required": [ + "kind", + "assetKey" + ], + "type": "object" + } + ] + }, + "id": { + "maxLength": 40, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "type": "string" + }, + "opacity": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "positionX": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "positionY": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "required": { + "type": "boolean" + }, + "rotation": { + "maximum": 180, + "minimum": -180, + "type": "number" + }, + "surface": { + "enum": [ + "viewport", + "main", + "home-hero", + "suggestions" + ], + "type": "string" + }, + "width": { + "maximum": 1.5, + "minimum": 0.02, + "type": "number" + } + }, + "required": [ + "id", + "asset", + "surface", + "anchor", + "positionX", + "positionY", + "width", + "opacity", + "rotation", + "required" + ], + "type": "object" + }, + "maxItems": 24, + "type": "array" + } + }, + "required": [ + "layers" + ], + "type": "object" + }, + "decorations": { + "items": { + "additionalProperties": false, + "properties": { + "assetKey": { + "maxLength": 40, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "intensity": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "opacity": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "placement": { + "enum": [ + "background", + "corners", + "hero", + "cards" + ], + "type": "string" + }, + "scale": { + "maximum": 3, + "minimum": 0.25, + "type": "number" + }, + "type": { + "enum": [ + "particles", + "ribbon", + "butterflies", + "polaroid", + "badge", + "sparkles", + "image" + ], + "type": "string" + } + }, + "required": [ + "type", + "enabled", + "intensity" + ], + "type": "object" + }, + "maxItems": 16, + "type": "array" + }, + "description": { + "maxLength": 240, + "minLength": 1, + "type": "string" + }, + "home": { + "additionalProperties": false, + "properties": { + "welcome": { + "additionalProperties": false, + "properties": { + "layout": { + "additionalProperties": false, + "properties": { + "anchor": { + "enum": [ + "top-left", + "top-center", + "top-right", + "center-left", + "center", + "center-right", + "bottom-left", + "bottom-center", + "bottom-right" + ], + "type": "string" + }, + "hideNativeIcon": { + "default": false, + "type": "boolean" + }, + "positionX": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "positionY": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "textAlign": { + "enum": [ + "left", + "center", + "right" + ], + "type": "string" + }, + "width": { + "maximum": 1, + "minimum": 0.2, + "type": "number" + } + }, + "required": [ + "anchor", + "positionX", + "positionY", + "width", + "textAlign" + ], + "type": "object" + }, + "localized": { + "additionalProperties": { + "additionalProperties": false, + "properties": { + "lines": { + "items": { + "type": "string" + }, + "maxItems": 3, + "minItems": 1, + "type": "array" + } + }, + "required": [ + "lines" + ], + "type": "object" + }, + "propertyNames": { + "enum": [ + "zh-CN", + "en" + ] + }, + "type": "object" + } + }, + "required": [ + "localized" + ], + "type": "object" + } + }, + "required": [ + "welcome" + ], + "type": "object" + }, + "id": { + "maxLength": 80, + "minLength": 3, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "type": "string" + }, + "interfaceImages": { + "additionalProperties": false, + "properties": { + "profileAvatarSize": { + "maximum": 48, + "minimum": 16, + "type": "integer" + }, + "projectIconSize": { + "maximum": 32, + "minimum": 12, + "type": "integer" + }, + "suggestionIconSize": { + "maximum": 64, + "minimum": 16, + "type": "integer" + } + }, + "required": [ + "profileAvatarSize", + "suggestionIconSize", + "projectIconSize" + ], + "type": "object" + }, + "kind": { + "enum": [ + "theme", + "recipe" + ], + "type": "string" + }, + "layout": { + "additionalProperties": false, + "properties": { + "cardColumns": { + "maximum": 4, + "minimum": 2, + "type": "integer" + }, + "composerWidth": { + "maximum": 1, + "minimum": 0.5, + "type": "number" + }, + "heroHeight": { + "maximum": 560, + "minimum": 180, + "type": "integer" + }, + "moduleGap": { + "maximum": 48, + "minimum": 0, + "type": "integer" + }, + "modules": { + "items": { + "additionalProperties": false, + "properties": { + "align": { + "enum": [ + "start", + "center", + "end", + "stretch" + ], + "type": "string" + }, + "id": { + "enum": [ + "sidebar", + "topbar", + "hero", + "suggestions", + "project-picker", + "composer", + "task-background", + "content-layer" + ], + "type": "string" + }, + "order": { + "maximum": 7, + "minimum": 0, + "type": "integer" + }, + "size": { + "enum": [ + "compact", + "regular", + "expanded" + ], + "type": "string" + }, + "spacing": { + "maximum": 48, + "minimum": 0, + "type": "integer" + }, + "visible": { + "type": "boolean" + } + }, + "required": [ + "id", + "order", + "visible", + "size", + "align", + "spacing" + ], + "type": "object" + }, + "maxItems": 8, + "minItems": 8, + "type": "array" + }, + "sidebarDensity": { + "enum": [ + "compact", + "comfortable" + ], + "type": "string" + } + }, + "required": [ + "heroHeight", + "cardColumns", + "composerWidth", + "sidebarDensity", + "moduleGap", + "modules" + ], + "type": "object" + }, + "metadata": { + "additionalProperties": false, + "properties": { + "homepage": { + "format": "uri", + "maxLength": 500, + "type": "string" + }, + "localized": { + "additionalProperties": { + "additionalProperties": false, + "properties": { + "description": { + "maxLength": 240, + "minLength": 1, + "type": "string" + }, + "name": { + "maxLength": 80, + "minLength": 1, + "type": "string" + } + }, + "type": "object" + }, + "propertyNames": { + "enum": [ + "zh-CN", + "en" + ] + }, + "type": "object" + } + }, + "type": "object" + }, + "name": { + "maxLength": 80, + "minLength": 1, + "type": "string" + }, + "rights": { + "additionalProperties": false, + "properties": { + "attribution": { + "maxLength": 240, + "minLength": 1, + "type": "string" + }, + "licenseId": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "localOnly": { + "type": "boolean" + }, + "source": { + "format": "uri", + "maxLength": 500, + "type": "string" + } + }, + "required": [ + "licenseId", + "localOnly" + ], + "type": "object" + }, + "schemaVersion": { + "const": 4, + "type": "number" + }, + "surfaces": { + "additionalProperties": false, + "default": { + "baseOpacity": 0.68, + "blur": 0, + "elevatedOpacity": 0.92, + "terminalOpacity": 0.82 + }, + "properties": { + "baseOpacity": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "blur": { + "maximum": 30, + "minimum": 0, + "type": "number" + }, + "elevatedOpacity": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "terminalOpacity": { + "maximum": 1, + "minimum": 0, + "type": "number" + } + }, + "required": [ + "baseOpacity", + "elevatedOpacity", + "terminalOpacity", + "blur" + ], + "type": "object" + }, + "typography": { + "additionalProperties": false, + "properties": { + "codeFamily": { + "maxLength": 120, + "minLength": 1, + "type": "string" + }, + "codeFontAssetKey": { + "maxLength": 40, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "type": "string" + }, + "codeSize": { + "maximum": 22, + "minimum": 11, + "type": "number" + }, + "codeWeight": { + "enum": [ + 400, + 500, + 600, + 700 + ], + "type": "number" + }, + "displayFamily": { + "maxLength": 120, + "minLength": 1, + "type": "string" + }, + "displayFontAssetKey": { + "maxLength": 40, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "type": "string" + }, + "displayLetterSpacing": { + "maximum": 0.2, + "minimum": -0.05, + "type": "number" + }, + "displayLineHeight": { + "maximum": 1.8, + "minimum": 1.1, + "type": "number" + }, + "displaySize": { + "maximum": 72, + "minimum": 20, + "type": "number" + }, + "displayWeight": { + "enum": [ + 400, + 500, + 600, + 700 + ], + "type": "number" + }, + "lineHeight": { + "maximum": 1.8, + "minimum": 1.2, + "type": "number" + }, + "scale": { + "maximum": 1.3, + "minimum": 0.85, + "type": "number" + }, + "uiFamily": { + "maxLength": 120, + "minLength": 1, + "type": "string" + }, + "uiFontAssetKey": { + "maxLength": 40, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "type": "string" + }, + "uiSize": { + "maximum": 22, + "minimum": 12, + "type": "number" + }, + "uiWeight": { + "enum": [ + 400, + 500, + 600, + 700 + ], + "type": "number" + } + }, + "required": [ + "uiFamily", + "codeFamily", + "scale", + "uiSize", + "codeSize", + "uiWeight", + "codeWeight", + "lineHeight", + "displayFamily", + "displaySize", + "displayWeight", + "displayLineHeight", + "displayLetterSpacing" + ], + "type": "object" + }, + "version": { + "pattern": "^(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)$", + "type": "string" + } + }, + "required": [ + "schemaVersion", + "kind", + "id", + "name", + "version", + "author", + "assets", + "colors", + "typography", + "background", + "decorations", + "layout", + "rights", + "interfaceImages", + "composition" + ], + "type": "object" + }, + "maxItems": 50, + "type": "array" + }, + "revision": { + "minimum": 0, + "type": "integer" + }, + "savedRef": { + "anyOf": [ + { + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "version": { + "type": "string" + } + }, + "required": [ + "id", + "version" + ], + "type": "object" + }, + { + "type": "null" + } + ] + }, + "schemaVersion": { + "const": 1, + "type": "number" + }, + "theme": { + "additionalProperties": false, + "properties": { + "appearance": { + "default": "auto", + "enum": [ + "auto", + "light", + "dark" + ], + "type": "string" + }, + "assets": { + "additionalProperties": false, + "properties": { + "background": { + "type": "string" + }, + "decorations": { + "additionalProperties": { + "type": "string" + }, + "propertyNames": { + "maxLength": 40, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$" + }, + "type": "object" + }, + "fonts": { + "additionalProperties": { + "type": "string" + }, + "propertyNames": { + "maxLength": 40, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$" + }, + "type": "object" + }, + "portrait": { + "type": "string" + }, + "profileAvatar": { + "type": "string" + }, + "projectIcons": { + "items": { + "type": "string" + }, + "maxItems": 12, + "minItems": 1, + "type": "array" + }, + "suggestionIcons": { + "additionalProperties": false, + "properties": { + "card1": { + "type": "string" + }, + "card2": { + "type": "string" + }, + "card3": { + "type": "string" + }, + "card4": { + "type": "string" + } + }, + "type": "object" + } + }, + "type": "object" + }, + "author": { + "maxLength": 80, + "minLength": 1, + "type": "string" + }, + "background": { + "additionalProperties": false, + "properties": { + "blur": { + "maximum": 30, + "minimum": 0, + "type": "number" + }, + "brightness": { + "maximum": 1.5, + "minimum": 0.3, + "type": "number" + }, + "overlay": { + "maximum": 0.9, + "minimum": 0, + "type": "number" + }, + "positionX": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "positionY": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "safeArea": { + "default": "auto", + "enum": [ + "auto", + "left", + "center", + "right", + "none" + ], + "type": "string" + }, + "scale": { + "maximum": 3, + "minimum": 0.5, + "type": "number" + }, + "taskMode": { + "default": "full", + "enum": [ + "auto", + "full", + "ambient", + "banner", + "off" + ], + "type": "string" + }, + "taskOpacity": { + "default": 0.82, + "maximum": 1, + "minimum": 0, + "type": "number" + } + }, + "required": [ + "positionX", + "positionY", + "scale", + "blur", + "brightness", + "overlay" + ], + "type": "object" + }, + "colors": { + "additionalProperties": false, + "properties": { + "accent": { + "type": "string" + }, + "border": { + "type": "string" + }, + "codeText": { + "type": "string" + }, + "danger": { + "type": "string" + }, + "info": { + "type": "string" + }, + "inputText": { + "type": "string" + }, + "link": { + "type": "string" + }, + "muted": { + "type": "string" + }, + "panel": { + "type": "string" + }, + "placeholder": { + "type": "string" + }, + "secondary": { + "type": "string" + }, + "success": { + "type": "string" + }, + "text": { + "type": "string" + }, + "textSecondary": { + "type": "string" + }, + "warning": { + "type": "string" + } + }, + "required": [ + "accent", + "secondary", + "text", + "muted", + "panel", + "border", + "success", + "warning", + "danger", + "info", + "textSecondary", + "link", + "inputText", + "placeholder", + "codeText" + ], + "type": "object" + }, + "composition": { + "additionalProperties": false, + "properties": { + "layers": { + "items": { + "additionalProperties": false, + "properties": { + "anchor": { + "enum": [ + "top-left", + "top-center", + "top-right", + "center-left", + "center", + "center-right", + "bottom-left", + "bottom-center", + "bottom-right" + ], + "type": "string" + }, + "asset": { + "anyOf": [ + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "portrait", + "type": "string" + } + }, + "required": [ + "kind" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "assetKey": { + "maxLength": 40, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "type": "string" + }, + "kind": { + "const": "decoration", + "type": "string" + } + }, + "required": [ + "kind", + "assetKey" + ], + "type": "object" + } + ] + }, + "id": { + "maxLength": 40, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "type": "string" + }, + "opacity": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "positionX": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "positionY": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "required": { + "type": "boolean" + }, + "rotation": { + "maximum": 180, + "minimum": -180, + "type": "number" + }, + "surface": { + "enum": [ + "viewport", + "main", + "home-hero", + "suggestions" + ], + "type": "string" + }, + "width": { + "maximum": 1.5, + "minimum": 0.02, + "type": "number" + } + }, + "required": [ + "id", + "asset", + "surface", + "anchor", + "positionX", + "positionY", + "width", + "opacity", + "rotation", + "required" + ], + "type": "object" + }, + "maxItems": 24, + "type": "array" + } + }, + "required": [ + "layers" + ], + "type": "object" + }, + "decorations": { + "items": { + "additionalProperties": false, + "properties": { + "assetKey": { + "maxLength": 40, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "intensity": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "opacity": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "placement": { + "enum": [ + "background", + "corners", + "hero", + "cards" + ], + "type": "string" + }, + "scale": { + "maximum": 3, + "minimum": 0.25, + "type": "number" + }, + "type": { + "enum": [ + "particles", + "ribbon", + "butterflies", + "polaroid", + "badge", + "sparkles", + "image" + ], + "type": "string" + } + }, + "required": [ + "type", + "enabled", + "intensity" + ], + "type": "object" + }, + "maxItems": 16, + "type": "array" + }, + "description": { + "maxLength": 240, + "minLength": 1, + "type": "string" + }, + "home": { + "additionalProperties": false, + "properties": { + "welcome": { + "additionalProperties": false, + "properties": { + "layout": { + "additionalProperties": false, + "properties": { + "anchor": { + "enum": [ + "top-left", + "top-center", + "top-right", + "center-left", + "center", + "center-right", + "bottom-left", + "bottom-center", + "bottom-right" + ], + "type": "string" + }, + "hideNativeIcon": { + "default": false, + "type": "boolean" + }, + "positionX": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "positionY": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "textAlign": { + "enum": [ + "left", + "center", + "right" + ], + "type": "string" + }, + "width": { + "maximum": 1, + "minimum": 0.2, + "type": "number" + } + }, + "required": [ + "anchor", + "positionX", + "positionY", + "width", + "textAlign" + ], + "type": "object" + }, + "localized": { + "additionalProperties": { + "additionalProperties": false, + "properties": { + "lines": { + "items": { + "type": "string" + }, + "maxItems": 3, + "minItems": 1, + "type": "array" + } + }, + "required": [ + "lines" + ], + "type": "object" + }, + "propertyNames": { + "enum": [ + "zh-CN", + "en" + ] + }, + "type": "object" + } + }, + "required": [ + "localized" + ], + "type": "object" + } + }, + "required": [ + "welcome" + ], + "type": "object" + }, + "id": { + "maxLength": 80, + "minLength": 3, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "type": "string" + }, + "interfaceImages": { + "additionalProperties": false, + "properties": { + "profileAvatarSize": { + "maximum": 48, + "minimum": 16, + "type": "integer" + }, + "projectIconSize": { + "maximum": 32, + "minimum": 12, + "type": "integer" + }, + "suggestionIconSize": { + "maximum": 64, + "minimum": 16, + "type": "integer" + } + }, + "required": [ + "profileAvatarSize", + "suggestionIconSize", + "projectIconSize" + ], + "type": "object" + }, + "kind": { + "enum": [ + "theme", + "recipe" + ], + "type": "string" + }, + "layout": { + "additionalProperties": false, + "properties": { + "cardColumns": { + "maximum": 4, + "minimum": 2, + "type": "integer" + }, + "composerWidth": { + "maximum": 1, + "minimum": 0.5, + "type": "number" + }, + "heroHeight": { + "maximum": 560, + "minimum": 180, + "type": "integer" + }, + "moduleGap": { + "maximum": 48, + "minimum": 0, + "type": "integer" + }, + "modules": { + "items": { + "additionalProperties": false, + "properties": { + "align": { + "enum": [ + "start", + "center", + "end", + "stretch" + ], + "type": "string" + }, + "id": { + "enum": [ + "sidebar", + "topbar", + "hero", + "suggestions", + "project-picker", + "composer", + "task-background", + "content-layer" + ], + "type": "string" + }, + "order": { + "maximum": 7, + "minimum": 0, + "type": "integer" + }, + "size": { + "enum": [ + "compact", + "regular", + "expanded" + ], + "type": "string" + }, + "spacing": { + "maximum": 48, + "minimum": 0, + "type": "integer" + }, + "visible": { + "type": "boolean" + } + }, + "required": [ + "id", + "order", + "visible", + "size", + "align", + "spacing" + ], + "type": "object" + }, + "maxItems": 8, + "minItems": 8, + "type": "array" + }, + "sidebarDensity": { + "enum": [ + "compact", + "comfortable" + ], + "type": "string" + } + }, + "required": [ + "heroHeight", + "cardColumns", + "composerWidth", + "sidebarDensity", + "moduleGap", + "modules" + ], + "type": "object" + }, + "metadata": { + "additionalProperties": false, + "properties": { + "homepage": { + "format": "uri", + "maxLength": 500, + "type": "string" + }, + "localized": { + "additionalProperties": { + "additionalProperties": false, + "properties": { + "description": { + "maxLength": 240, + "minLength": 1, + "type": "string" + }, + "name": { + "maxLength": 80, + "minLength": 1, + "type": "string" + } + }, + "type": "object" + }, + "propertyNames": { + "enum": [ + "zh-CN", + "en" + ] + }, + "type": "object" + } + }, + "type": "object" + }, + "name": { + "maxLength": 80, + "minLength": 1, + "type": "string" + }, + "rights": { + "additionalProperties": false, + "properties": { + "attribution": { + "maxLength": 240, + "minLength": 1, + "type": "string" + }, + "licenseId": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "localOnly": { + "type": "boolean" + }, + "source": { + "format": "uri", + "maxLength": 500, + "type": "string" + } + }, + "required": [ + "licenseId", + "localOnly" + ], + "type": "object" + }, + "schemaVersion": { + "const": 4, + "type": "number" + }, + "surfaces": { + "additionalProperties": false, + "default": { + "baseOpacity": 0.68, + "blur": 0, + "elevatedOpacity": 0.92, + "terminalOpacity": 0.82 + }, + "properties": { + "baseOpacity": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "blur": { + "maximum": 30, + "minimum": 0, + "type": "number" + }, + "elevatedOpacity": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "terminalOpacity": { + "maximum": 1, + "minimum": 0, + "type": "number" + } + }, + "required": [ + "baseOpacity", + "elevatedOpacity", + "terminalOpacity", + "blur" + ], + "type": "object" + }, + "typography": { + "additionalProperties": false, + "properties": { + "codeFamily": { + "maxLength": 120, + "minLength": 1, + "type": "string" + }, + "codeFontAssetKey": { + "maxLength": 40, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "type": "string" + }, + "codeSize": { + "maximum": 22, + "minimum": 11, + "type": "number" + }, + "codeWeight": { + "enum": [ + 400, + 500, + 600, + 700 + ], + "type": "number" + }, + "displayFamily": { + "maxLength": 120, + "minLength": 1, + "type": "string" + }, + "displayFontAssetKey": { + "maxLength": 40, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "type": "string" + }, + "displayLetterSpacing": { + "maximum": 0.2, + "minimum": -0.05, + "type": "number" + }, + "displayLineHeight": { + "maximum": 1.8, + "minimum": 1.1, + "type": "number" + }, + "displaySize": { + "maximum": 72, + "minimum": 20, + "type": "number" + }, + "displayWeight": { + "enum": [ + 400, + 500, + 600, + 700 + ], + "type": "number" + }, + "lineHeight": { + "maximum": 1.8, + "minimum": 1.2, + "type": "number" + }, + "scale": { + "maximum": 1.3, + "minimum": 0.85, + "type": "number" + }, + "uiFamily": { + "maxLength": 120, + "minLength": 1, + "type": "string" + }, + "uiFontAssetKey": { + "maxLength": 40, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "type": "string" + }, + "uiSize": { + "maximum": 22, + "minimum": 12, + "type": "number" + }, + "uiWeight": { + "enum": [ + 400, + 500, + 600, + 700 + ], + "type": "number" + } + }, + "required": [ + "uiFamily", + "codeFamily", + "scale", + "uiSize", + "codeSize", + "uiWeight", + "codeWeight", + "lineHeight", + "displayFamily", + "displaySize", + "displayWeight", + "displayLineHeight", + "displayLetterSpacing" + ], + "type": "object" + }, + "version": { + "pattern": "^(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)$", + "type": "string" + } + }, + "required": [ + "schemaVersion", + "kind", + "id", + "name", + "version", + "author", + "assets", + "colors", + "typography", + "background", + "decorations", + "layout", + "rights", + "interfaceImages", + "composition" + ], + "type": "object" + }, + "updatedAt": { + "format": "date-time", + "type": "string" + } + }, + "required": [ + "schemaVersion", + "draftId", + "theme", + "revision", + "updatedAt", + "savedRef", + "dirty", + "past", + "future" + ], + "type": "object" + } + } + }, + "persistedDraftRecord": { + "$ref": "#/definitions/persistedDraftRecord", + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "persistedDraftRecord": { + "additionalProperties": false, + "properties": { + "dirty": { + "type": "boolean" + }, + "draftId": { + "format": "uuid", + "type": "string" + }, + "future": { + "items": {}, + "maxItems": 50, + "type": "array" + }, + "past": { + "items": {}, + "maxItems": 50, + "type": "array" + }, + "revision": { + "minimum": 0, + "type": "integer" + }, + "savedRef": { + "anyOf": [ + { + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "version": { + "type": "string" + } + }, + "required": [ + "id", + "version" + ], + "type": "object" + }, + { + "type": "null" + } + ] + }, + "schemaVersion": { + "const": 1, + "type": "number" + }, + "theme": {}, + "updatedAt": { + "format": "date-time", + "type": "string" + } + }, + "required": [ + "schemaVersion", + "draftId", + "revision", + "updatedAt", + "savedRef", + "dirty", + "past", + "future" + ], + "type": "object" + } + } + }, + "recentRuntimeRequest": { + "$ref": "#/definitions/recentRuntimeRequest", + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "recentRuntimeRequest": { + "additionalProperties": false, + "properties": { + "command": { + "enum": [ + "launch", + "status", + "switch", + "pause", + "resume", + "restore" + ], + "type": "string" + }, + "completedAt": { + "format": "date-time", + "type": "string" + }, + "requestId": { + "format": "uuid", + "type": "string" + }, + "response": { + "anyOf": [ + { + "additionalProperties": false, + "properties": { + "ok": { + "const": true, + "type": "boolean" + }, + "protocolVersion": { + "const": 1, + "type": "number" + }, + "requestId": { + "format": "uuid", + "type": "string" + }, + "result": { + "additionalProperties": false, + "properties": { + "appliedTheme": { + "anyOf": [ + { + "additionalProperties": false, + "properties": { + "id": { + "maxLength": 80, + "minLength": 3, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "type": "string" + }, + "version": { + "pattern": "^(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)$", + "type": "string" + } + }, + "required": [ + "id", + "version" + ], + "type": "object" + }, + { + "type": "null" + } + ] + }, + "controllerAvailable": { + "type": "boolean" + }, + "nextAction": { + "maxLength": 500, + "type": "string" + }, + "operation": { + "anyOf": [ + { + "enum": [ + "launch", + "switch", + "pause", + "resume", + "restore" + ], + "type": "string" + }, + { + "type": "null" + } + ] + }, + "packageVersion": { + "anyOf": [ + { + "maxLength": 40, + "type": "string" + }, + { + "type": "null" + } + ] + }, + "selectedTheme": { + "anyOf": [ + { + "additionalProperties": false, + "properties": { + "id": { + "maxLength": 80, + "minLength": 3, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "type": "string" + }, + "version": { + "pattern": "^(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)$", + "type": "string" + } + }, + "required": [ + "id", + "version" + ], + "type": "object" + }, + { + "type": "null" + } + ] + }, + "skinApplied": { + "type": [ + "boolean", + "null" + ] + }, + "status": { + "anyOf": [ + { + "enum": [ + "launching", + "active", + "paused", + "paused-incompatible", + "recovery-required", + "restoring", + "restored-awaiting-exit", + "restored-cleanup-required" + ], + "type": "string" + }, + { + "const": "stopped", + "type": "string" + } + ] + } + }, + "required": [ + "status", + "controllerAvailable", + "selectedTheme", + "appliedTheme", + "skinApplied", + "packageVersion", + "operation", + "nextAction" + ], + "type": "object" + } + }, + "required": [ + "protocolVersion", + "requestId", + "ok", + "result" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "error": { + "additionalProperties": false, + "properties": { + "code": { + "enum": [ + "CODEX_NOT_INSTALLED", + "CODEX_DISCOVERY_REQUIRES_BOOTSTRAP", + "CODEX_IDENTITY_INVALID", + "CODEX_ALREADY_RUNNING_UNMANAGED", + "CODEX_LAUNCH_FAILED", + "CODEX_WINDOW_ACTIVATION_FAILED", + "PROCESS_INSPECTION_DENIED", + "CDP_NOT_READY", + "CDP_ENDPOINT_UNSAFE", + "CDP_PROCESS_MISMATCH", + "CDP_TARGET_NOT_FOUND", + "CDP_TARGET_AMBIGUOUS", + "ADAPTER_INCOMPATIBLE", + "THEME_APPLY_FAILED", + "THEME_VERIFY_FAILED", + "THEME_CLEANUP_FAILED", + "THEME_SWITCH_FAILED", + "THEME_ROLLBACK_FAILED", + "THEME_RUNTIME_TOO_LARGE", + "THEME_SCHEMA_VERSION_UNSUPPORTED", + "THEME_WELCOME_INVALID", + "THEME_DISPLAY_FONT_MISSING", + "THEME_COMPOSITION_INVALID", + "THEME_HOME_WELCOME_UNSUPPORTED", + "THEME_REQUIRED_LAYER_UNRESOLVED", + "THEME_NOT_FOUND", + "THEME_NOT_READY", + "RUNTIME_SESSION_STALE", + "RUNTIME_INVALID_STATE", + "RUNTIME_BUSY", + "RUNTIME_ENVIRONMENT_INVALID", + "RUNTIME_CONTROL_UNAVAILABLE", + "RESTORE_AWAITING_EXIT", + "PROBE_EXIT_PENDING", + "PROBE_FINALIZE_REQUIRED", + "PROBE_PENDING_SESSION_INVALID", + "INTERNAL" + ], + "type": "string" + }, + "message": { + "maxLength": 500, + "minLength": 1, + "type": "string" + }, + "nextAction": { + "maxLength": 500, + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "ok": { + "const": false, + "type": "boolean" + }, + "protocolVersion": { + "const": 1, + "type": "number" + }, + "requestId": { + "format": "uuid", + "type": "string" + } + }, + "required": [ + "protocolVersion", + "requestId", + "ok", + "error" + ], + "type": "object" + } + ] + } + }, + "required": [ + "requestId", + "command", + "response", + "completedAt" + ], + "type": "object" + } + } + }, + "releaseManifest": { + "$ref": "#/definitions/releaseManifest", + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "releaseManifest": { + "additionalProperties": false, + "properties": { + "build": { + "additionalProperties": false, + "properties": { + "commit": { + "pattern": "^[0-9a-f]{40}$", + "type": "string" + } + }, + "required": [ + "commit" + ], + "type": "object" + }, + "entry": { + "enum": [ + "OpenChatGPTSkin.cmd", + "OpenChatGPTSkin" + ], + "type": "string" + }, + "files": { + "additionalProperties": { + "additionalProperties": false, + "properties": { + "bytes": { + "minimum": 0, + "type": "integer" + }, + "sha256": { + "pattern": "^[0-9a-f]{64}$", + "type": "string" + } + }, + "required": [ + "bytes", + "sha256" + ], + "type": "object" + }, + "type": "object" + }, + "product": { + "const": "OpenChatGPTSkin", + "type": "string" + }, + "runtime": { + "additionalProperties": false, + "properties": { + "nodeVersion": { + "pattern": "^\\d+\\.\\d+\\.\\d+$", + "type": "string" + } + }, + "required": [ + "nodeVersion" + ], + "type": "object" + }, + "schemaVersion": { + "const": 1, + "type": "number" + }, + "target": { + "additionalProperties": false, + "properties": { + "arch": { + "enum": [ + "x64", + "arm64" + ], + "type": "string" + }, + "platform": { + "enum": [ + "win32", + "darwin" + ], + "type": "string" + } + }, + "required": [ + "platform", + "arch" + ], + "type": "object" + }, + "themeCatalog": { + "additionalProperties": false, + "properties": { + "schemaVersion": { + "exclusiveMinimum": 0, + "type": "integer" + } + }, + "required": [ + "schemaVersion" + ], + "type": "object" + }, + "themes": { + "items": { + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "type": "string" + }, + "minItems": 1, + "type": "array" + }, + "version": { + "pattern": "^\\d+\\.\\d+\\.\\d+(?:-[0-9A-Za-z.-]+)?$", + "type": "string" + } + }, + "required": [ + "schemaVersion", + "product", + "version", + "target", + "runtime", + "build", + "themeCatalog", + "entry", + "themes", + "files" + ], + "type": "object" + } + } + }, + "runtimeState": { + "$ref": "#/definitions/runtimeState", + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "runtimeState": { + "additionalProperties": false, + "properties": { + "adapter": { + "anyOf": [ + { + "additionalProperties": false, + "properties": { + "id": { + "minLength": 1, + "type": "string" + }, + "version": { + "const": 1, + "type": "number" + } + }, + "required": [ + "id", + "version" + ], + "type": "object" + }, + { + "type": "null" + } + ] + }, + "appliedTheme": { + "anyOf": [ + { + "additionalProperties": false, + "properties": { + "id": { + "maxLength": 80, + "minLength": 3, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "type": "string" + }, + "version": { + "pattern": "^(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)$", + "type": "string" + } + }, + "required": [ + "id", + "version" + ], + "type": "object" + }, + { + "type": "null" + } + ] + }, + "cdp": { + "anyOf": [ + { + "additionalProperties": false, + "properties": { + "host": { + "const": "127.0.0.1", + "type": "string" + }, + "port": { + "maximum": 65535, + "minimum": 1, + "type": "integer" + } + }, + "required": [ + "host", + "port" + ], + "type": "object" + }, + { + "type": "null" + } + ] + }, + "codex": { + "anyOf": [ + { + "additionalProperties": false, + "properties": { + "executablePath": { + "minLength": 1, + "type": "string" + }, + "packageRoot": { + "minLength": 1, + "type": "string" + }, + "packageVersion": { + "pattern": "^\\d+\\.\\d+\\.\\d+\\.\\d+$", + "type": "string" + }, + "rootPid": { + "exclusiveMinimum": 0, + "type": "integer" + }, + "startedAt": { + "format": "date-time", + "type": "string" + } + }, + "required": [ + "rootPid", + "startedAt", + "executablePath", + "packageRoot", + "packageVersion" + ], + "type": "object" + }, + { + "type": "null" + } + ] + }, + "createdAt": { + "format": "date-time", + "type": "string" + }, + "pendingOperation": { + "anyOf": [ + { + "additionalProperties": false, + "properties": { + "candidateTheme": { + "anyOf": [ + { + "additionalProperties": false, + "properties": { + "id": { + "maxLength": 80, + "minLength": 3, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "type": "string" + }, + "version": { + "pattern": "^(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)$", + "type": "string" + } + }, + "required": [ + "id", + "version" + ], + "type": "object" + }, + { + "type": "null" + } + ] + }, + "kind": { + "enum": [ + "launch", + "switch", + "pause", + "resume", + "restore" + ], + "type": "string" + }, + "previousAppliedTheme": { + "anyOf": [ + { + "additionalProperties": false, + "properties": { + "id": { + "maxLength": 80, + "minLength": 3, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "type": "string" + }, + "version": { + "pattern": "^(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)$", + "type": "string" + } + }, + "required": [ + "id", + "version" + ], + "type": "object" + }, + { + "type": "null" + } + ] + }, + "previousSelectedTheme": { + "anyOf": [ + { + "additionalProperties": false, + "properties": { + "id": { + "maxLength": 80, + "minLength": 3, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "type": "string" + }, + "version": { + "pattern": "^(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)$", + "type": "string" + } + }, + "required": [ + "id", + "version" + ], + "type": "object" + }, + { + "type": "null" + } + ] + }, + "previousStatus": { + "anyOf": [ + { + "enum": [ + "launching", + "active", + "paused", + "paused-incompatible", + "recovery-required", + "restoring", + "restored-awaiting-exit", + "restored-cleanup-required" + ], + "type": "string" + }, + { + "type": "null" + } + ] + }, + "requestId": { + "format": "uuid", + "type": "string" + }, + "startedAt": { + "format": "date-time", + "type": "string" + } + }, + "required": [ + "kind", + "requestId", + "startedAt", + "previousStatus", + "previousSelectedTheme", + "previousAppliedTheme", + "candidateTheme" + ], + "type": "object" + }, + { + "type": "null" + } + ] + }, + "recentRequests": { + "items": { + "additionalProperties": false, + "properties": { + "command": { + "enum": [ + "launch", + "status", + "switch", + "pause", + "resume", + "restore" + ], + "type": "string" + }, + "completedAt": { + "format": "date-time", + "type": "string" + }, + "requestId": { + "format": "uuid", + "type": "string" + }, + "response": { + "anyOf": [ + { + "additionalProperties": false, + "properties": { + "ok": { + "const": true, + "type": "boolean" + }, + "protocolVersion": { + "const": 1, + "type": "number" + }, + "requestId": { + "format": "uuid", + "type": "string" + }, + "result": { + "additionalProperties": false, + "properties": { + "appliedTheme": { + "anyOf": [ + { + "additionalProperties": false, + "properties": { + "id": { + "maxLength": 80, + "minLength": 3, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "type": "string" + }, + "version": { + "pattern": "^(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)$", + "type": "string" + } + }, + "required": [ + "id", + "version" + ], + "type": "object" + }, + { + "type": "null" + } + ] + }, + "controllerAvailable": { + "type": "boolean" + }, + "nextAction": { + "maxLength": 500, + "type": "string" + }, + "operation": { + "anyOf": [ + { + "enum": [ + "launch", + "switch", + "pause", + "resume", + "restore" + ], + "type": "string" + }, + { + "type": "null" + } + ] + }, + "packageVersion": { + "anyOf": [ + { + "maxLength": 40, + "type": "string" + }, + { + "type": "null" + } + ] + }, + "selectedTheme": { + "anyOf": [ + { + "additionalProperties": false, + "properties": { + "id": { + "maxLength": 80, + "minLength": 3, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "type": "string" + }, + "version": { + "pattern": "^(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)$", + "type": "string" + } + }, + "required": [ + "id", + "version" + ], + "type": "object" + }, + { + "type": "null" + } + ] + }, + "skinApplied": { + "type": [ + "boolean", + "null" + ] + }, + "status": { + "anyOf": [ + { + "enum": [ + "launching", + "active", + "paused", + "paused-incompatible", + "recovery-required", + "restoring", + "restored-awaiting-exit", + "restored-cleanup-required" + ], + "type": "string" + }, + { + "const": "stopped", + "type": "string" + } + ] + } + }, + "required": [ + "status", + "controllerAvailable", + "selectedTheme", + "appliedTheme", + "skinApplied", + "packageVersion", + "operation", + "nextAction" + ], + "type": "object" + } + }, + "required": [ + "protocolVersion", + "requestId", + "ok", + "result" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "error": { + "additionalProperties": false, + "properties": { + "code": { + "enum": [ + "CODEX_NOT_INSTALLED", + "CODEX_DISCOVERY_REQUIRES_BOOTSTRAP", + "CODEX_IDENTITY_INVALID", + "CODEX_ALREADY_RUNNING_UNMANAGED", + "CODEX_LAUNCH_FAILED", + "CODEX_WINDOW_ACTIVATION_FAILED", + "PROCESS_INSPECTION_DENIED", + "CDP_NOT_READY", + "CDP_ENDPOINT_UNSAFE", + "CDP_PROCESS_MISMATCH", + "CDP_TARGET_NOT_FOUND", + "CDP_TARGET_AMBIGUOUS", + "ADAPTER_INCOMPATIBLE", + "THEME_APPLY_FAILED", + "THEME_VERIFY_FAILED", + "THEME_CLEANUP_FAILED", + "THEME_SWITCH_FAILED", + "THEME_ROLLBACK_FAILED", + "THEME_RUNTIME_TOO_LARGE", + "THEME_SCHEMA_VERSION_UNSUPPORTED", + "THEME_WELCOME_INVALID", + "THEME_DISPLAY_FONT_MISSING", + "THEME_COMPOSITION_INVALID", + "THEME_HOME_WELCOME_UNSUPPORTED", + "THEME_REQUIRED_LAYER_UNRESOLVED", + "THEME_NOT_FOUND", + "THEME_NOT_READY", + "RUNTIME_SESSION_STALE", + "RUNTIME_INVALID_STATE", + "RUNTIME_BUSY", + "RUNTIME_ENVIRONMENT_INVALID", + "RUNTIME_CONTROL_UNAVAILABLE", + "RESTORE_AWAITING_EXIT", + "PROBE_EXIT_PENDING", + "PROBE_FINALIZE_REQUIRED", + "PROBE_PENDING_SESSION_INVALID", + "INTERNAL" + ], + "type": "string" + }, + "message": { + "maxLength": 500, + "minLength": 1, + "type": "string" + }, + "nextAction": { + "maxLength": 500, + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "ok": { + "const": false, + "type": "boolean" + }, + "protocolVersion": { + "const": 1, + "type": "number" + }, + "requestId": { + "format": "uuid", + "type": "string" + } + }, + "required": [ + "protocolVersion", + "requestId", + "ok", + "error" + ], + "type": "object" + } + ] + } + }, + "required": [ + "requestId", + "command", + "response", + "completedAt" + ], + "type": "object" + }, + "maxItems": 32, + "type": "array" + }, + "runtime": { + "additionalProperties": false, + "properties": { + "pid": { + "exclusiveMinimum": 0, + "type": "integer" + }, + "startedAt": { + "format": "date-time", + "type": "string" + } + }, + "required": [ + "pid", + "startedAt" + ], + "type": "object" + }, + "schemaVersion": { + "const": 2, + "type": "number" + }, + "selectedTheme": { + "additionalProperties": false, + "properties": { + "id": { + "maxLength": 80, + "minLength": 3, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "type": "string" + }, + "version": { + "pattern": "^(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)$", + "type": "string" + } + }, + "required": [ + "id", + "version" + ], + "type": "object" + }, + "sessionId": { + "format": "uuid", + "type": "string" + }, + "skinApplied": { + "type": [ + "boolean", + "null" + ] + }, + "status": { + "enum": [ + "launching", + "active", + "paused", + "paused-incompatible", + "recovery-required", + "restoring", + "restored-awaiting-exit", + "restored-cleanup-required" + ], + "type": "string" + }, + "updatedAt": { + "format": "date-time", + "type": "string" + } + }, + "required": [ + "schemaVersion", + "sessionId", + "status", + "runtime", + "codex", + "cdp", + "adapter", + "selectedTheme", + "appliedTheme", + "skinApplied", + "pendingOperation", + "recentRequests", + "createdAt", + "updatedAt" + ], + "type": "object" + } + } + }, + "themeRef": { + "$ref": "#/definitions/themeRef", + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "themeRef": { + "additionalProperties": false, + "properties": { + "id": { + "maxLength": 80, + "minLength": 3, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "type": "string" + }, + "version": { + "pattern": "^(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)$", + "type": "string" + } + }, + "required": [ + "id", + "version" + ], + "type": "object" + } + } + }, + "themeStore": { + "$ref": "#/definitions/themeStore", + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "themeStore": { + "additionalProperties": false, + "properties": { + "active": { + "anyOf": [ + { + "additionalProperties": false, + "properties": { + "id": { + "maxLength": 80, + "minLength": 3, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "type": "string" + }, + "version": { + "pattern": "^(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)$", + "type": "string" + } + }, + "required": [ + "id", + "version" + ], + "type": "object" + }, + { + "type": "null" + } + ] + }, + "previous": { + "anyOf": [ + { + "additionalProperties": false, + "properties": { + "id": { + "maxLength": 80, + "minLength": 3, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "type": "string" + }, + "version": { + "pattern": "^(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)$", + "type": "string" + } + }, + "required": [ + "id", + "version" + ], + "type": "object" + }, + { + "type": "null" + } + ] + }, + "schemaVersion": { + "const": 1, + "type": "number" + }, + "updatedAt": { + "format": "date-time", + "type": "string" + } + }, + "required": [ + "schemaVersion", + "active", + "previous", + "updatedAt" + ], + "type": "object" + } + } + }, + "trustedInstall": { + "$ref": "#/definitions/trustedInstall", + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "trustedInstall": { + "anyOf": [ + { + "additionalProperties": false, + "properties": { + "appId": { + "const": "App", + "type": "string" + }, + "catalogSignatureStatus": { + "const": "Valid", + "type": "string" + }, + "catalogSignerCommonName": { + "const": "50BDFD77-8903-4850-9FFE-6E8522F64D5B", + "type": "string" + }, + "entryBlockMapValid": { + "const": true, + "type": "boolean" + }, + "entryPath": { + "minLength": 1, + "type": "string" + }, + "entryPoint": { + "const": "Windows.FullTrustApplication", + "type": "string" + }, + "entryRelativePath": { + "const": "app/ChatGPT.exe", + "type": "string" + }, + "identityName": { + "const": "OpenAI.Codex", + "type": "string" + }, + "packagePublisher": { + "minLength": 1, + "type": "string" + }, + "packageRoot": { + "minLength": 1, + "type": "string" + }, + "packageSignatureStatus": { + "const": "Valid", + "type": "string" + }, + "packageSignerCommonName": { + "const": "50BDFD77-8903-4850-9FFE-6E8522F64D5B", + "type": "string" + }, + "packageVersion": { + "pattern": "^\\d+\\.\\d+\\.\\d+\\.\\d+$", + "type": "string" + }, + "resourceSignatureStatus": { + "const": "Valid", + "type": "string" + }, + "resourceSignerCommonName": { + "const": "OpenAI OpCo, LLC", + "type": "string" + }, + "schemaVersion": { + "const": 1, + "type": "number" + }, + "verifiedAt": { + "format": "date-time", + "type": "string" + } + }, + "required": [ + "schemaVersion", + "packageRoot", + "entryPath", + "identityName", + "packageVersion", + "packagePublisher", + "appId", + "entryRelativePath", + "entryPoint", + "packageSignatureStatus", + "packageSignerCommonName", + "catalogSignatureStatus", + "catalogSignerCommonName", + "entryBlockMapValid", + "resourceSignatureStatus", + "resourceSignerCommonName", + "verifiedAt" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "appId": { + "const": "com.openai.codex", + "type": "string" + }, + "catalogSignatureStatus": { + "const": "Valid", + "type": "string" + }, + "catalogSignerCommonName": { + "const": "Notarized Developer ID", + "type": "string" + }, + "entryBlockMapValid": { + "const": true, + "type": "boolean" + }, + "entryPath": { + "minLength": 1, + "type": "string" + }, + "entryPoint": { + "const": "macOS.Application", + "type": "string" + }, + "entryRelativePath": { + "pattern": "^Contents\\/MacOS\\/[^/]+$", + "type": "string" + }, + "identityName": { + "const": "OpenAI.Codex", + "type": "string" + }, + "packagePublisher": { + "const": "2DC432GLL2", + "type": "string" + }, + "packageRoot": { + "pattern": "\\/Codex\\.app$", + "type": "string" + }, + "packageSignatureStatus": { + "const": "Valid", + "type": "string" + }, + "packageSignerCommonName": { + "const": "2DC432GLL2", + "type": "string" + }, + "packageVersion": { + "pattern": "^\\d+\\.\\d+\\.\\d+\\.\\d+$", + "type": "string" + }, + "resourceSignatureStatus": { + "const": "Valid", + "type": "string" + }, + "resourceSignerCommonName": { + "const": "OpenAI, L.L.C.", + "type": "string" + }, + "schemaVersion": { + "const": 1, + "type": "number" + }, + "verifiedAt": { + "format": "date-time", + "type": "string" + } + }, + "required": [ + "schemaVersion", + "packageRoot", + "entryPath", + "identityName", + "packageVersion", + "packagePublisher", + "appId", + "entryRelativePath", + "entryPoint", + "packageSignatureStatus", + "packageSignerCommonName", + "catalogSignatureStatus", + "catalogSignerCommonName", + "entryBlockMapValid", + "resourceSignatureStatus", + "resourceSignerCommonName", + "verifiedAt" + ], + "type": "object" + } + ] + } + } + } + }, + "schemaVersion": 1 +} diff --git a/contracts/runtime/v1/cases/semantics.json b/contracts/runtime/v1/cases/semantics.json new file mode 100644 index 0000000..ad4324d --- /dev/null +++ b/contracts/runtime/v1/cases/semantics.json @@ -0,0 +1,127 @@ +{ + "cases": [ + { + "command": "status", + "expectedConcurrency": "parallel-with-mutation", + "name": "status during mutation", + "valid": true + }, + { + "command": "switch", + "expectedBehavior": "replay-stored-response", + "name": "duplicate request replay", + "valid": true + }, + { + "command": "pause", + "expectedErrorCode": "RUNTIME_INVALID_STATE", + "expectedPath": "/requestId", + "name": "request ID reused for another command", + "valid": false + }, + { + "expectedErrorCode": "RUNTIME_CONTROL_UNAVAILABLE", + "expectedPath": "/frame/length", + "name": "oversized frame", + "valid": false + }, + { + "command": "restore", + "expectedBehavior": "after-response", + "name": "restore callback after response", + "valid": true + }, + { + "command": "launch", + "expectedErrorCode": "THEME_NOT_FOUND", + "expectedPath": "/params/themeVersion", + "name": "personal theme without version", + "valid": false + } + ], + "protocolVersion": 1, + "recoveryCases": [ + { + "expectedStatus": "active", + "name": "stable active appearance", + "sourceStatus": "active", + "valid": true + }, + { + "expectedStatus": "paused", + "name": "stable paused appearance", + "sourceStatus": "paused", + "valid": true + }, + { + "expectedStatus": "restored-awaiting-exit", + "name": "interrupted launch restores official appearance", + "operation": "launch", + "valid": true + }, + { + "expectedStatus": "restored-awaiting-exit", + "name": "interrupted restore retries official appearance", + "operation": "restore", + "valid": true + }, + { + "cleanupSucceeded": false, + "expectedStatus": "recovery-required", + "name": "failed cleanup marks appearance unknown", + "valid": true + }, + { + "expectedErrorCode": "RUNTIME_SESSION_STALE", + "expectedPath": "/codex/startedAt", + "name": "managed process identity changed", + "valid": false + } + ], + "stateTransitions": { + "active": [ + "active", + "paused", + "paused-incompatible", + "recovery-required", + "restoring" + ], + "launching": [ + "launching", + "active", + "recovery-required", + "restored-awaiting-exit" + ], + "paused": [ + "paused", + "active", + "paused-incompatible", + "recovery-required", + "restoring" + ], + "paused-incompatible": [ + "paused-incompatible", + "paused", + "active", + "recovery-required", + "restoring" + ], + "recovery-required": [ + "recovery-required", + "restoring" + ], + "restored-awaiting-exit": [ + "restored-awaiting-exit", + "restored-cleanup-required" + ], + "restored-cleanup-required": [ + "restored-cleanup-required" + ], + "restoring": [ + "restoring", + "active", + "recovery-required", + "restored-awaiting-exit" + ] + } +} diff --git a/contracts/runtime/v1/frames.json b/contracts/runtime/v1/frames.json new file mode 100644 index 0000000..3c5c6b9 --- /dev/null +++ b/contracts/runtime/v1/frames.json @@ -0,0 +1,66 @@ +{ + "afterResponseCommands": [ + "launch", + "restore" + ], + "commands": [ + "launch", + "status", + "switch", + "pause", + "resume", + "restore" + ], + "errors": [ + "CODEX_NOT_INSTALLED", + "CODEX_DISCOVERY_REQUIRES_BOOTSTRAP", + "CODEX_IDENTITY_INVALID", + "CODEX_ALREADY_RUNNING_UNMANAGED", + "CODEX_LAUNCH_FAILED", + "CODEX_WINDOW_ACTIVATION_FAILED", + "PROCESS_INSPECTION_DENIED", + "CDP_NOT_READY", + "CDP_ENDPOINT_UNSAFE", + "CDP_PROCESS_MISMATCH", + "CDP_TARGET_NOT_FOUND", + "CDP_TARGET_AMBIGUOUS", + "ADAPTER_INCOMPATIBLE", + "THEME_APPLY_FAILED", + "THEME_VERIFY_FAILED", + "THEME_CLEANUP_FAILED", + "THEME_SWITCH_FAILED", + "THEME_ROLLBACK_FAILED", + "THEME_RUNTIME_TOO_LARGE", + "THEME_SCHEMA_VERSION_UNSUPPORTED", + "THEME_WELCOME_INVALID", + "THEME_DISPLAY_FONT_MISSING", + "THEME_COMPOSITION_INVALID", + "THEME_HOME_WELCOME_UNSUPPORTED", + "THEME_REQUIRED_LAYER_UNRESOLVED", + "THEME_NOT_FOUND", + "THEME_NOT_READY", + "RUNTIME_SESSION_STALE", + "RUNTIME_INVALID_STATE", + "RUNTIME_BUSY", + "RUNTIME_ENVIRONMENT_INVALID", + "RUNTIME_CONTROL_UNAVAILABLE", + "RESTORE_AWAITING_EXIT", + "PROBE_EXIT_PENDING", + "PROBE_FINALIZE_REQUIRED", + "PROBE_PENDING_SESSION_INVALID", + "INTERNAL" + ], + "frame": { + "byteOrder": "little-endian", + "encoding": "json", + "lengthPrefixBytes": 4, + "maxPayloadBytes": 65536 + }, + "mutationConcurrency": "serialized", + "protocolVersion": 1, + "readOnlyCommands": [ + "status" + ], + "requestIdSemantics": "same-id-same-command-replays; same-id-different-command-rejected", + "statusConcurrency": "parallel-with-mutation" +} diff --git a/contracts/runtime/v1/schemas/index.json b/contracts/runtime/v1/schemas/index.json new file mode 100644 index 0000000..36576c1 --- /dev/null +++ b/contracts/runtime/v1/schemas/index.json @@ -0,0 +1,673 @@ +{ + "schemas": { + "error": { + "$ref": "#/definitions/error", + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "error": { + "additionalProperties": false, + "properties": { + "code": { + "enum": [ + "CODEX_NOT_INSTALLED", + "CODEX_DISCOVERY_REQUIRES_BOOTSTRAP", + "CODEX_IDENTITY_INVALID", + "CODEX_ALREADY_RUNNING_UNMANAGED", + "CODEX_LAUNCH_FAILED", + "CODEX_WINDOW_ACTIVATION_FAILED", + "PROCESS_INSPECTION_DENIED", + "CDP_NOT_READY", + "CDP_ENDPOINT_UNSAFE", + "CDP_PROCESS_MISMATCH", + "CDP_TARGET_NOT_FOUND", + "CDP_TARGET_AMBIGUOUS", + "ADAPTER_INCOMPATIBLE", + "THEME_APPLY_FAILED", + "THEME_VERIFY_FAILED", + "THEME_CLEANUP_FAILED", + "THEME_SWITCH_FAILED", + "THEME_ROLLBACK_FAILED", + "THEME_RUNTIME_TOO_LARGE", + "THEME_SCHEMA_VERSION_UNSUPPORTED", + "THEME_WELCOME_INVALID", + "THEME_DISPLAY_FONT_MISSING", + "THEME_COMPOSITION_INVALID", + "THEME_HOME_WELCOME_UNSUPPORTED", + "THEME_REQUIRED_LAYER_UNRESOLVED", + "THEME_NOT_FOUND", + "THEME_NOT_READY", + "RUNTIME_SESSION_STALE", + "RUNTIME_INVALID_STATE", + "RUNTIME_BUSY", + "RUNTIME_ENVIRONMENT_INVALID", + "RUNTIME_CONTROL_UNAVAILABLE", + "RESTORE_AWAITING_EXIT", + "PROBE_EXIT_PENDING", + "PROBE_FINALIZE_REQUIRED", + "PROBE_PENDING_SESSION_INVALID", + "INTERNAL" + ], + "type": "string" + }, + "message": { + "maxLength": 500, + "minLength": 1, + "type": "string" + }, + "nextAction": { + "maxLength": 500, + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + } + } + }, + "request": { + "$ref": "#/definitions/request", + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "request": { + "anyOf": [ + { + "additionalProperties": false, + "properties": { + "command": { + "const": "launch", + "type": "string" + }, + "params": { + "additionalProperties": false, + "properties": { + "themeId": { + "maxLength": 80, + "minLength": 3, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "type": "string" + }, + "themeVersion": { + "pattern": "^(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)$", + "type": "string" + } + }, + "required": [ + "themeId" + ], + "type": "object" + }, + "protocolVersion": { + "const": 1, + "type": "number" + }, + "requestId": { + "format": "uuid", + "type": "string" + } + }, + "required": [ + "protocolVersion", + "requestId", + "command", + "params" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "command": { + "const": "status", + "type": "string" + }, + "params": { + "additionalProperties": false, + "properties": {}, + "type": "object" + }, + "protocolVersion": { + "const": 1, + "type": "number" + }, + "requestId": { + "format": "uuid", + "type": "string" + } + }, + "required": [ + "protocolVersion", + "requestId", + "command", + "params" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "command": { + "const": "switch", + "type": "string" + }, + "params": { + "additionalProperties": false, + "properties": { + "themeId": { + "maxLength": 80, + "minLength": 3, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "type": "string" + }, + "themeVersion": { + "pattern": "^(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)$", + "type": "string" + } + }, + "required": [ + "themeId" + ], + "type": "object" + }, + "protocolVersion": { + "const": 1, + "type": "number" + }, + "requestId": { + "format": "uuid", + "type": "string" + } + }, + "required": [ + "protocolVersion", + "requestId", + "command", + "params" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "command": { + "const": "pause", + "type": "string" + }, + "params": { + "additionalProperties": false, + "properties": {}, + "type": "object" + }, + "protocolVersion": { + "const": 1, + "type": "number" + }, + "requestId": { + "format": "uuid", + "type": "string" + } + }, + "required": [ + "protocolVersion", + "requestId", + "command", + "params" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "command": { + "const": "resume", + "type": "string" + }, + "params": { + "additionalProperties": false, + "properties": {}, + "type": "object" + }, + "protocolVersion": { + "const": 1, + "type": "number" + }, + "requestId": { + "format": "uuid", + "type": "string" + } + }, + "required": [ + "protocolVersion", + "requestId", + "command", + "params" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "command": { + "const": "restore", + "type": "string" + }, + "params": { + "additionalProperties": false, + "properties": {}, + "type": "object" + }, + "protocolVersion": { + "const": 1, + "type": "number" + }, + "requestId": { + "format": "uuid", + "type": "string" + } + }, + "required": [ + "protocolVersion", + "requestId", + "command", + "params" + ], + "type": "object" + } + ] + } + } + }, + "response": { + "$ref": "#/definitions/response", + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "response": { + "anyOf": [ + { + "additionalProperties": false, + "properties": { + "ok": { + "const": true, + "type": "boolean" + }, + "protocolVersion": { + "const": 1, + "type": "number" + }, + "requestId": { + "format": "uuid", + "type": "string" + }, + "result": { + "additionalProperties": false, + "properties": { + "appliedTheme": { + "anyOf": [ + { + "additionalProperties": false, + "properties": { + "id": { + "maxLength": 80, + "minLength": 3, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "type": "string" + }, + "version": { + "pattern": "^(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)$", + "type": "string" + } + }, + "required": [ + "id", + "version" + ], + "type": "object" + }, + { + "type": "null" + } + ] + }, + "controllerAvailable": { + "type": "boolean" + }, + "nextAction": { + "maxLength": 500, + "type": "string" + }, + "operation": { + "anyOf": [ + { + "enum": [ + "launch", + "switch", + "pause", + "resume", + "restore" + ], + "type": "string" + }, + { + "type": "null" + } + ] + }, + "packageVersion": { + "anyOf": [ + { + "maxLength": 40, + "type": "string" + }, + { + "type": "null" + } + ] + }, + "selectedTheme": { + "anyOf": [ + { + "additionalProperties": false, + "properties": { + "id": { + "maxLength": 80, + "minLength": 3, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "type": "string" + }, + "version": { + "pattern": "^(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)$", + "type": "string" + } + }, + "required": [ + "id", + "version" + ], + "type": "object" + }, + { + "type": "null" + } + ] + }, + "skinApplied": { + "type": [ + "boolean", + "null" + ] + }, + "status": { + "anyOf": [ + { + "enum": [ + "launching", + "active", + "paused", + "paused-incompatible", + "recovery-required", + "restoring", + "restored-awaiting-exit", + "restored-cleanup-required" + ], + "type": "string" + }, + { + "const": "stopped", + "type": "string" + } + ] + } + }, + "required": [ + "status", + "controllerAvailable", + "selectedTheme", + "appliedTheme", + "skinApplied", + "packageVersion", + "operation", + "nextAction" + ], + "type": "object" + } + }, + "required": [ + "protocolVersion", + "requestId", + "ok", + "result" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "error": { + "additionalProperties": false, + "properties": { + "code": { + "enum": [ + "CODEX_NOT_INSTALLED", + "CODEX_DISCOVERY_REQUIRES_BOOTSTRAP", + "CODEX_IDENTITY_INVALID", + "CODEX_ALREADY_RUNNING_UNMANAGED", + "CODEX_LAUNCH_FAILED", + "CODEX_WINDOW_ACTIVATION_FAILED", + "PROCESS_INSPECTION_DENIED", + "CDP_NOT_READY", + "CDP_ENDPOINT_UNSAFE", + "CDP_PROCESS_MISMATCH", + "CDP_TARGET_NOT_FOUND", + "CDP_TARGET_AMBIGUOUS", + "ADAPTER_INCOMPATIBLE", + "THEME_APPLY_FAILED", + "THEME_VERIFY_FAILED", + "THEME_CLEANUP_FAILED", + "THEME_SWITCH_FAILED", + "THEME_ROLLBACK_FAILED", + "THEME_RUNTIME_TOO_LARGE", + "THEME_SCHEMA_VERSION_UNSUPPORTED", + "THEME_WELCOME_INVALID", + "THEME_DISPLAY_FONT_MISSING", + "THEME_COMPOSITION_INVALID", + "THEME_HOME_WELCOME_UNSUPPORTED", + "THEME_REQUIRED_LAYER_UNRESOLVED", + "THEME_NOT_FOUND", + "THEME_NOT_READY", + "RUNTIME_SESSION_STALE", + "RUNTIME_INVALID_STATE", + "RUNTIME_BUSY", + "RUNTIME_ENVIRONMENT_INVALID", + "RUNTIME_CONTROL_UNAVAILABLE", + "RESTORE_AWAITING_EXIT", + "PROBE_EXIT_PENDING", + "PROBE_FINALIZE_REQUIRED", + "PROBE_PENDING_SESSION_INVALID", + "INTERNAL" + ], + "type": "string" + }, + "message": { + "maxLength": 500, + "minLength": 1, + "type": "string" + }, + "nextAction": { + "maxLength": 500, + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "ok": { + "const": false, + "type": "boolean" + }, + "protocolVersion": { + "const": 1, + "type": "number" + }, + "requestId": { + "format": "uuid", + "type": "string" + } + }, + "required": [ + "protocolVersion", + "requestId", + "ok", + "error" + ], + "type": "object" + } + ] + } + } + }, + "status": { + "$ref": "#/definitions/status", + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "status": { + "additionalProperties": false, + "properties": { + "appliedTheme": { + "anyOf": [ + { + "additionalProperties": false, + "properties": { + "id": { + "maxLength": 80, + "minLength": 3, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "type": "string" + }, + "version": { + "pattern": "^(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)$", + "type": "string" + } + }, + "required": [ + "id", + "version" + ], + "type": "object" + }, + { + "type": "null" + } + ] + }, + "controllerAvailable": { + "type": "boolean" + }, + "nextAction": { + "maxLength": 500, + "type": "string" + }, + "operation": { + "anyOf": [ + { + "enum": [ + "launch", + "switch", + "pause", + "resume", + "restore" + ], + "type": "string" + }, + { + "type": "null" + } + ] + }, + "packageVersion": { + "anyOf": [ + { + "maxLength": 40, + "type": "string" + }, + { + "type": "null" + } + ] + }, + "selectedTheme": { + "anyOf": [ + { + "additionalProperties": false, + "properties": { + "id": { + "maxLength": 80, + "minLength": 3, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "type": "string" + }, + "version": { + "pattern": "^(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)$", + "type": "string" + } + }, + "required": [ + "id", + "version" + ], + "type": "object" + }, + { + "type": "null" + } + ] + }, + "skinApplied": { + "type": [ + "boolean", + "null" + ] + }, + "status": { + "anyOf": [ + { + "enum": [ + "launching", + "active", + "paused", + "paused-incompatible", + "recovery-required", + "restoring", + "restored-awaiting-exit", + "restored-cleanup-required" + ], + "type": "string" + }, + { + "const": "stopped", + "type": "string" + } + ] + } + }, + "required": [ + "status", + "controllerAvailable", + "selectedTheme", + "appliedTheme", + "skinApplied", + "packageVersion", + "operation", + "nextAction" + ], + "type": "object" + } + } + } + }, + "schemaVersion": 1 +} diff --git a/contracts/studio/v2/cases/http.json b/contracts/studio/v2/cases/http.json new file mode 100644 index 0000000..8a29dfb --- /dev/null +++ b/contracts/studio/v2/cases/http.json @@ -0,0 +1,48 @@ +{ + "cases": [ + { + "expectedStatus": 200, + "name": "authenticated bootstrap", + "valid": true + }, + { + "expectedStatus": 204, + "name": "one-time session exchange", + "valid": true + }, + { + "expectedErrorCode": "STUDIO_SESSION_INVALID", + "expectedPath": "/api/session", + "expectedStatus": 401, + "name": "reused bootstrap token", + "valid": false + }, + { + "expectedErrorCode": "STUDIO_ORIGIN_REJECTED", + "expectedPath": "/headers/origin", + "expectedStatus": 403, + "name": "cross-origin mutation", + "valid": false + }, + { + "expectedErrorCode": "STUDIO_REQUEST_TOO_LARGE", + "expectedPath": "/body", + "expectedStatus": 413, + "name": "oversized binary upload", + "valid": false + }, + { + "expectedStatus": 200, + "name": "binary export", + "responseKind": "binary", + "valid": true + }, + { + "expectedStatus": 200, + "name": "runtime status stream", + "responseKind": "sse", + "valid": true + } + ], + "protocolVersion": 2 +} diff --git a/contracts/studio/v2/routes.json b/contracts/studio/v2/routes.json new file mode 100644 index 0000000..da268d7 --- /dev/null +++ b/contracts/studio/v2/routes.json @@ -0,0 +1,253 @@ +{ + "bodyLimits": { + "archiveBytes": 33554432, + "imageBytes": 52428800, + "jsonBytes": 262144, + "sessionJsonBytes": 16384 + }, + "errorStatus": { + "INTERNAL": 500, + "RUNTIME_STATUS_UNAVAILABLE": 503, + "STUDIO_APPLY_FAILED": 409, + "STUDIO_ASSET_INVALID": 422, + "STUDIO_DELETE_FAILED": 422, + "STUDIO_DRAFT_CONFLICT": 409, + "STUDIO_DRAFT_INVALID": 422, + "STUDIO_DRAFT_NOT_FOUND": 404, + "STUDIO_EXPORT_INVALID": 422, + "STUDIO_IMPORT_INVALID": 422, + "STUDIO_ORIGIN_REJECTED": 403, + "STUDIO_REQUEST_INVALID": 400, + "STUDIO_REQUEST_TOO_LARGE": 413, + "STUDIO_SAVE_FAILED": 422, + "STUDIO_SESSION_INVALID": 401 + }, + "protocolVersion": 2, + "responsePolicies": { + "binary": { + "cacheControl": "no-store", + "contentType": "route-defined", + "contentTypeOptions": "nosniff" + }, + "html": { + "cacheControl": "no-store", + "contentType": "text/html; charset=utf-8", + "contentTypeOptions": "nosniff" + }, + "json": { + "cacheControl": "no-store", + "contentType": "application/json; charset=utf-8", + "contentTypeOptions": "nosniff" + }, + "sse": { + "cacheControl": "no-store", + "contentType": "text/event-stream; charset=utf-8", + "contentTypeOptions": "nosniff" + } + }, + "routes": [ + { + "id": "home", + "method": "GET", + "originRequired": false, + "path": "/", + "response": "html", + "successStatus": 200 + }, + { + "id": "session", + "method": "POST", + "originRequired": true, + "path": "/api/session", + "request": "sessionExchange", + "response": "none", + "successStatus": 204 + }, + { + "id": "bootstrap", + "method": "GET", + "originRequired": false, + "path": "/api/bootstrap", + "response": "bootstrap", + "successStatus": 200 + }, + { + "id": "themes", + "method": "GET", + "originRequired": false, + "path": "/api/themes", + "response": "themeLibrary", + "successStatus": 200 + }, + { + "id": "applySavedTheme", + "method": "POST", + "originRequired": true, + "path": "/api/themes/apply", + "request": "themeRef", + "response": "applyResult", + "successStatus": 200 + }, + { + "id": "deleteTheme", + "method": "DELETE", + "originRequired": true, + "path": "/api/themes/{id}", + "request": "deleteTheme", + "response": "themeLibrary", + "successStatus": 200 + }, + { + "id": "createDraft", + "method": "POST", + "originRequired": true, + "path": "/api/drafts", + "request": "createDraft", + "response": "draft", + "successStatus": 201 + }, + { + "id": "latestDraft", + "method": "GET", + "originRequired": false, + "path": "/api/drafts/latest", + "response": "nullableDraft", + "successStatus": 200 + }, + { + "id": "draft", + "method": "GET", + "originRequired": false, + "path": "/api/drafts/{draftId}", + "response": "draft", + "successStatus": 200 + }, + { + "id": "updateDraft", + "method": "PUT", + "originRequired": true, + "path": "/api/drafts/{draftId}", + "request": "updateDraft", + "response": "draft", + "successStatus": 200 + }, + { + "id": "undoDraft", + "method": "POST", + "originRequired": true, + "path": "/api/drafts/{draftId}/undo", + "request": "draftCommand", + "response": "draft", + "successStatus": 200 + }, + { + "id": "redoDraft", + "method": "POST", + "originRequired": true, + "path": "/api/drafts/{draftId}/redo", + "request": "draftCommand", + "response": "draft", + "successStatus": 200 + }, + { + "id": "validateDraft", + "method": "POST", + "originRequired": true, + "path": "/api/drafts/{draftId}/validate", + "response": "draft", + "successStatus": 200 + }, + { + "id": "saveDraft", + "method": "POST", + "originRequired": true, + "path": "/api/drafts/{draftId}/save", + "request": "draftCommand", + "response": "saveResult", + "successStatus": 200 + }, + { + "id": "applyDraft", + "method": "POST", + "originRequired": true, + "path": "/api/drafts/{draftId}/apply", + "request": "draftCommand", + "response": "applyResult", + "successStatus": 200 + }, + { + "id": "uploadAsset", + "method": "POST", + "originRequired": true, + "path": "/api/drafts/{draftId}/assets", + "request": "binaryAsset", + "response": "draft", + "successStatus": 200 + }, + { + "id": "importTheme", + "method": "POST", + "originRequired": true, + "path": "/api/import", + "request": "binaryArchive", + "response": "draft", + "successStatus": 201 + }, + { + "id": "exportTheme", + "method": "GET", + "originRequired": false, + "path": "/api/export", + "request": "themeRefQuery", + "response": "binaryArchive", + "successStatus": 200 + }, + { + "id": "runtime", + "method": "GET", + "originRequired": false, + "path": "/api/runtime", + "response": "runtimeStatus", + "successStatus": 200 + }, + { + "id": "restoreRuntime", + "method": "POST", + "originRequired": true, + "path": "/api/runtime/restore", + "response": "runtimeStatus", + "successStatus": 200 + }, + { + "id": "draftAsset", + "method": "GET", + "originRequired": false, + "path": "/api/draft-asset", + "response": "binaryAsset", + "successStatus": 200 + }, + { + "id": "themePreview", + "method": "GET", + "originRequired": false, + "path": "/api/theme-preview", + "response": "binaryImage", + "successStatus": 200 + }, + { + "id": "events", + "method": "GET", + "originRequired": false, + "path": "/api/events", + "response": "sseRuntimeStatus", + "successStatus": 200 + } + ], + "securityHeaders": [ + "Content-Security-Policy", + "Cross-Origin-Opener-Policy", + "Referrer-Policy", + "X-Content-Type-Options", + "X-Frame-Options" + ] +} diff --git a/contracts/studio/v2/schemas/index.json b/contracts/studio/v2/schemas/index.json new file mode 100644 index 0000000..1b89849 --- /dev/null +++ b/contracts/studio/v2/schemas/index.json @@ -0,0 +1,5042 @@ +{ + "schemas": { + "applyResult": { + "$ref": "#/definitions/applyResult", + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "applyResult": { + "additionalProperties": false, + "properties": { + "draft": { + "additionalProperties": false, + "properties": { + "assetUrls": { + "additionalProperties": { + "pattern": "^\\/api\\/", + "type": "string" + }, + "type": "object" + }, + "dirty": { + "type": "boolean" + }, + "draftId": { + "format": "uuid", + "type": "string" + }, + "issues": { + "items": { + "additionalProperties": false, + "properties": { + "code": { + "pattern": "^[A-Z0-9_]+$", + "type": "string" + }, + "message": { + "maxLength": 500, + "minLength": 1, + "type": "string" + }, + "path": { + "maxLength": 240, + "type": "string" + }, + "severity": { + "enum": [ + "error", + "warning" + ], + "type": "string" + } + }, + "required": [ + "code", + "path", + "message", + "severity" + ], + "type": "object" + }, + "type": "array" + }, + "redoAvailable": { + "type": "boolean" + }, + "revision": { + "minimum": 0, + "type": "integer" + }, + "savedRef": { + "anyOf": [ + { + "additionalProperties": false, + "properties": { + "id": { + "maxLength": 80, + "minLength": 3, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "type": "string" + }, + "version": { + "pattern": "^(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)$", + "type": "string" + } + }, + "required": [ + "id", + "version" + ], + "type": "object" + }, + { + "type": "null" + } + ] + }, + "theme": { + "additionalProperties": false, + "properties": { + "appearance": { + "default": "auto", + "enum": [ + "auto", + "light", + "dark" + ], + "type": "string" + }, + "assets": { + "additionalProperties": false, + "properties": { + "background": { + "type": "string" + }, + "decorations": { + "additionalProperties": { + "type": "string" + }, + "propertyNames": { + "maxLength": 40, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$" + }, + "type": "object" + }, + "fonts": { + "additionalProperties": { + "type": "string" + }, + "propertyNames": { + "maxLength": 40, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$" + }, + "type": "object" + }, + "portrait": { + "type": "string" + }, + "profileAvatar": { + "type": "string" + }, + "projectIcons": { + "items": { + "type": "string" + }, + "maxItems": 12, + "minItems": 1, + "type": "array" + }, + "suggestionIcons": { + "additionalProperties": false, + "properties": { + "card1": { + "type": "string" + }, + "card2": { + "type": "string" + }, + "card3": { + "type": "string" + }, + "card4": { + "type": "string" + } + }, + "type": "object" + } + }, + "type": "object" + }, + "author": { + "maxLength": 80, + "minLength": 1, + "type": "string" + }, + "background": { + "additionalProperties": false, + "properties": { + "blur": { + "maximum": 30, + "minimum": 0, + "type": "number" + }, + "brightness": { + "maximum": 1.5, + "minimum": 0.3, + "type": "number" + }, + "overlay": { + "maximum": 0.9, + "minimum": 0, + "type": "number" + }, + "positionX": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "positionY": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "safeArea": { + "default": "auto", + "enum": [ + "auto", + "left", + "center", + "right", + "none" + ], + "type": "string" + }, + "scale": { + "maximum": 3, + "minimum": 0.5, + "type": "number" + }, + "taskMode": { + "default": "full", + "enum": [ + "auto", + "full", + "ambient", + "banner", + "off" + ], + "type": "string" + }, + "taskOpacity": { + "default": 0.82, + "maximum": 1, + "minimum": 0, + "type": "number" + } + }, + "required": [ + "positionX", + "positionY", + "scale", + "blur", + "brightness", + "overlay" + ], + "type": "object" + }, + "colors": { + "additionalProperties": false, + "properties": { + "accent": { + "type": "string" + }, + "border": { + "type": "string" + }, + "codeText": { + "type": "string" + }, + "danger": { + "type": "string" + }, + "info": { + "type": "string" + }, + "inputText": { + "type": "string" + }, + "link": { + "type": "string" + }, + "muted": { + "type": "string" + }, + "panel": { + "type": "string" + }, + "placeholder": { + "type": "string" + }, + "secondary": { + "type": "string" + }, + "success": { + "type": "string" + }, + "text": { + "type": "string" + }, + "textSecondary": { + "type": "string" + }, + "warning": { + "type": "string" + } + }, + "required": [ + "accent", + "secondary", + "text", + "muted", + "panel", + "border", + "success", + "warning", + "danger", + "info", + "textSecondary", + "link", + "inputText", + "placeholder", + "codeText" + ], + "type": "object" + }, + "composition": { + "additionalProperties": false, + "properties": { + "layers": { + "items": { + "additionalProperties": false, + "properties": { + "anchor": { + "enum": [ + "top-left", + "top-center", + "top-right", + "center-left", + "center", + "center-right", + "bottom-left", + "bottom-center", + "bottom-right" + ], + "type": "string" + }, + "asset": { + "anyOf": [ + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "portrait", + "type": "string" + } + }, + "required": [ + "kind" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "assetKey": { + "maxLength": 40, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "type": "string" + }, + "kind": { + "const": "decoration", + "type": "string" + } + }, + "required": [ + "kind", + "assetKey" + ], + "type": "object" + } + ] + }, + "id": { + "maxLength": 40, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "type": "string" + }, + "opacity": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "positionX": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "positionY": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "required": { + "type": "boolean" + }, + "rotation": { + "maximum": 180, + "minimum": -180, + "type": "number" + }, + "surface": { + "enum": [ + "viewport", + "main", + "home-hero", + "suggestions" + ], + "type": "string" + }, + "width": { + "maximum": 1.5, + "minimum": 0.02, + "type": "number" + } + }, + "required": [ + "id", + "asset", + "surface", + "anchor", + "positionX", + "positionY", + "width", + "opacity", + "rotation", + "required" + ], + "type": "object" + }, + "maxItems": 24, + "type": "array" + } + }, + "required": [ + "layers" + ], + "type": "object" + }, + "decorations": { + "items": { + "additionalProperties": false, + "properties": { + "assetKey": { + "maxLength": 40, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "intensity": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "opacity": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "placement": { + "enum": [ + "background", + "corners", + "hero", + "cards" + ], + "type": "string" + }, + "scale": { + "maximum": 3, + "minimum": 0.25, + "type": "number" + }, + "type": { + "enum": [ + "particles", + "ribbon", + "butterflies", + "polaroid", + "badge", + "sparkles", + "image" + ], + "type": "string" + } + }, + "required": [ + "type", + "enabled", + "intensity" + ], + "type": "object" + }, + "maxItems": 16, + "type": "array" + }, + "description": { + "maxLength": 240, + "minLength": 1, + "type": "string" + }, + "home": { + "additionalProperties": false, + "properties": { + "welcome": { + "additionalProperties": false, + "properties": { + "layout": { + "additionalProperties": false, + "properties": { + "anchor": { + "enum": [ + "top-left", + "top-center", + "top-right", + "center-left", + "center", + "center-right", + "bottom-left", + "bottom-center", + "bottom-right" + ], + "type": "string" + }, + "hideNativeIcon": { + "default": false, + "type": "boolean" + }, + "positionX": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "positionY": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "textAlign": { + "enum": [ + "left", + "center", + "right" + ], + "type": "string" + }, + "width": { + "maximum": 1, + "minimum": 0.2, + "type": "number" + } + }, + "required": [ + "anchor", + "positionX", + "positionY", + "width", + "textAlign" + ], + "type": "object" + }, + "localized": { + "additionalProperties": { + "additionalProperties": false, + "properties": { + "lines": { + "items": { + "type": "string" + }, + "maxItems": 3, + "minItems": 1, + "type": "array" + } + }, + "required": [ + "lines" + ], + "type": "object" + }, + "propertyNames": { + "enum": [ + "zh-CN", + "en" + ] + }, + "type": "object" + } + }, + "required": [ + "localized" + ], + "type": "object" + } + }, + "required": [ + "welcome" + ], + "type": "object" + }, + "id": { + "maxLength": 80, + "minLength": 3, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "type": "string" + }, + "interfaceImages": { + "additionalProperties": false, + "properties": { + "profileAvatarSize": { + "maximum": 48, + "minimum": 16, + "type": "integer" + }, + "projectIconSize": { + "maximum": 32, + "minimum": 12, + "type": "integer" + }, + "suggestionIconSize": { + "maximum": 64, + "minimum": 16, + "type": "integer" + } + }, + "required": [ + "profileAvatarSize", + "suggestionIconSize", + "projectIconSize" + ], + "type": "object" + }, + "kind": { + "enum": [ + "theme", + "recipe" + ], + "type": "string" + }, + "layout": { + "additionalProperties": false, + "properties": { + "cardColumns": { + "maximum": 4, + "minimum": 2, + "type": "integer" + }, + "composerWidth": { + "maximum": 1, + "minimum": 0.5, + "type": "number" + }, + "heroHeight": { + "maximum": 560, + "minimum": 180, + "type": "integer" + }, + "moduleGap": { + "maximum": 48, + "minimum": 0, + "type": "integer" + }, + "modules": { + "items": { + "additionalProperties": false, + "properties": { + "align": { + "enum": [ + "start", + "center", + "end", + "stretch" + ], + "type": "string" + }, + "id": { + "enum": [ + "sidebar", + "topbar", + "hero", + "suggestions", + "project-picker", + "composer", + "task-background", + "content-layer" + ], + "type": "string" + }, + "order": { + "maximum": 7, + "minimum": 0, + "type": "integer" + }, + "size": { + "enum": [ + "compact", + "regular", + "expanded" + ], + "type": "string" + }, + "spacing": { + "maximum": 48, + "minimum": 0, + "type": "integer" + }, + "visible": { + "type": "boolean" + } + }, + "required": [ + "id", + "order", + "visible", + "size", + "align", + "spacing" + ], + "type": "object" + }, + "maxItems": 8, + "minItems": 8, + "type": "array" + }, + "sidebarDensity": { + "enum": [ + "compact", + "comfortable" + ], + "type": "string" + } + }, + "required": [ + "heroHeight", + "cardColumns", + "composerWidth", + "sidebarDensity", + "moduleGap", + "modules" + ], + "type": "object" + }, + "metadata": { + "additionalProperties": false, + "properties": { + "homepage": { + "format": "uri", + "maxLength": 500, + "type": "string" + }, + "localized": { + "additionalProperties": { + "additionalProperties": false, + "properties": { + "description": { + "maxLength": 240, + "minLength": 1, + "type": "string" + }, + "name": { + "maxLength": 80, + "minLength": 1, + "type": "string" + } + }, + "type": "object" + }, + "propertyNames": { + "enum": [ + "zh-CN", + "en" + ] + }, + "type": "object" + } + }, + "type": "object" + }, + "name": { + "maxLength": 80, + "minLength": 1, + "type": "string" + }, + "rights": { + "additionalProperties": false, + "properties": { + "attribution": { + "maxLength": 240, + "minLength": 1, + "type": "string" + }, + "licenseId": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "localOnly": { + "type": "boolean" + }, + "source": { + "format": "uri", + "maxLength": 500, + "type": "string" + } + }, + "required": [ + "licenseId", + "localOnly" + ], + "type": "object" + }, + "schemaVersion": { + "const": 4, + "type": "number" + }, + "surfaces": { + "additionalProperties": false, + "default": { + "baseOpacity": 0.68, + "blur": 0, + "elevatedOpacity": 0.92, + "terminalOpacity": 0.82 + }, + "properties": { + "baseOpacity": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "blur": { + "maximum": 30, + "minimum": 0, + "type": "number" + }, + "elevatedOpacity": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "terminalOpacity": { + "maximum": 1, + "minimum": 0, + "type": "number" + } + }, + "required": [ + "baseOpacity", + "elevatedOpacity", + "terminalOpacity", + "blur" + ], + "type": "object" + }, + "typography": { + "additionalProperties": false, + "properties": { + "codeFamily": { + "maxLength": 120, + "minLength": 1, + "type": "string" + }, + "codeFontAssetKey": { + "maxLength": 40, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "type": "string" + }, + "codeSize": { + "maximum": 22, + "minimum": 11, + "type": "number" + }, + "codeWeight": { + "enum": [ + 400, + 500, + 600, + 700 + ], + "type": "number" + }, + "displayFamily": { + "maxLength": 120, + "minLength": 1, + "type": "string" + }, + "displayFontAssetKey": { + "maxLength": 40, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "type": "string" + }, + "displayLetterSpacing": { + "maximum": 0.2, + "minimum": -0.05, + "type": "number" + }, + "displayLineHeight": { + "maximum": 1.8, + "minimum": 1.1, + "type": "number" + }, + "displaySize": { + "maximum": 72, + "minimum": 20, + "type": "number" + }, + "displayWeight": { + "enum": [ + 400, + 500, + 600, + 700 + ], + "type": "number" + }, + "lineHeight": { + "maximum": 1.8, + "minimum": 1.2, + "type": "number" + }, + "scale": { + "maximum": 1.3, + "minimum": 0.85, + "type": "number" + }, + "uiFamily": { + "maxLength": 120, + "minLength": 1, + "type": "string" + }, + "uiFontAssetKey": { + "maxLength": 40, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "type": "string" + }, + "uiSize": { + "maximum": 22, + "minimum": 12, + "type": "number" + }, + "uiWeight": { + "enum": [ + 400, + 500, + 600, + 700 + ], + "type": "number" + } + }, + "required": [ + "uiFamily", + "codeFamily", + "scale", + "uiSize", + "codeSize", + "uiWeight", + "codeWeight", + "lineHeight", + "displayFamily", + "displaySize", + "displayWeight", + "displayLineHeight", + "displayLetterSpacing" + ], + "type": "object" + }, + "version": { + "pattern": "^(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)$", + "type": "string" + } + }, + "required": [ + "schemaVersion", + "kind", + "id", + "name", + "version", + "author", + "assets", + "colors", + "typography", + "background", + "decorations", + "layout", + "rights", + "interfaceImages", + "composition" + ], + "type": "object" + }, + "undoAvailable": { + "type": "boolean" + }, + "updatedAt": { + "format": "date-time", + "type": "string" + } + }, + "required": [ + "draftId", + "theme", + "revision", + "updatedAt", + "savedRef", + "dirty", + "undoAvailable", + "redoAvailable", + "issues", + "assetUrls" + ], + "type": "object" + }, + "ref": { + "additionalProperties": false, + "properties": { + "id": { + "maxLength": 80, + "minLength": 3, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "type": "string" + }, + "version": { + "pattern": "^(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)$", + "type": "string" + } + }, + "required": [ + "id", + "version" + ], + "type": "object" + }, + "runtime": { + "additionalProperties": false, + "properties": { + "appliedTheme": { + "anyOf": [ + { + "additionalProperties": false, + "properties": { + "id": { + "maxLength": 80, + "minLength": 3, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "type": "string" + }, + "version": { + "pattern": "^(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)$", + "type": "string" + } + }, + "required": [ + "id", + "version" + ], + "type": "object" + }, + { + "type": "null" + } + ] + }, + "controllerAvailable": { + "type": "boolean" + }, + "nextAction": { + "maxLength": 500, + "type": "string" + }, + "operation": { + "anyOf": [ + { + "enum": [ + "launch", + "switch", + "pause", + "resume", + "restore" + ], + "type": "string" + }, + { + "type": "null" + } + ] + }, + "packageVersion": { + "anyOf": [ + { + "maxLength": 40, + "type": "string" + }, + { + "type": "null" + } + ] + }, + "selectedTheme": { + "anyOf": [ + { + "additionalProperties": false, + "properties": { + "id": { + "maxLength": 80, + "minLength": 3, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "type": "string" + }, + "version": { + "pattern": "^(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)$", + "type": "string" + } + }, + "required": [ + "id", + "version" + ], + "type": "object" + }, + { + "type": "null" + } + ] + }, + "skinApplied": { + "type": [ + "boolean", + "null" + ] + }, + "status": { + "enum": [ + "stopped", + "launching", + "active", + "paused", + "paused-incompatible", + "recovery-required", + "restoring", + "restored-awaiting-exit", + "restored-cleanup-required" + ], + "type": "string" + } + }, + "required": [ + "status", + "controllerAvailable", + "selectedTheme", + "appliedTheme", + "skinApplied", + "packageVersion", + "operation", + "nextAction" + ], + "type": "object" + } + }, + "required": [ + "draft", + "ref", + "runtime" + ], + "type": "object" + } + } + }, + "bootstrap": { + "$ref": "#/definitions/bootstrap", + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "bootstrap": { + "additionalProperties": false, + "properties": { + "capabilities": { + "items": { + "enum": [ + "studio-shell", + "theme-library", + "draft-editing", + "asset-upload", + "version-save", + "theme-import-export", + "theme-delete", + "runtime-apply", + "runtime-restore" + ], + "type": "string" + }, + "type": "array" + }, + "protocolVersion": { + "const": 2, + "type": "number" + }, + "repositoryUrl": { + "anyOf": [ + { + "format": "uri", + "pattern": "^https\\:\\/\\/github\\.com\\/", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null + }, + "runtime": { + "additionalProperties": false, + "properties": { + "appliedTheme": { + "anyOf": [ + { + "additionalProperties": false, + "properties": { + "id": { + "maxLength": 80, + "minLength": 3, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "type": "string" + }, + "version": { + "pattern": "^(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)$", + "type": "string" + } + }, + "required": [ + "id", + "version" + ], + "type": "object" + }, + { + "type": "null" + } + ] + }, + "controllerAvailable": { + "type": "boolean" + }, + "nextAction": { + "maxLength": 500, + "type": "string" + }, + "operation": { + "anyOf": [ + { + "enum": [ + "launch", + "switch", + "pause", + "resume", + "restore" + ], + "type": "string" + }, + { + "type": "null" + } + ] + }, + "packageVersion": { + "anyOf": [ + { + "maxLength": 40, + "type": "string" + }, + { + "type": "null" + } + ] + }, + "selectedTheme": { + "anyOf": [ + { + "additionalProperties": false, + "properties": { + "id": { + "maxLength": 80, + "minLength": 3, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "type": "string" + }, + "version": { + "pattern": "^(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)$", + "type": "string" + } + }, + "required": [ + "id", + "version" + ], + "type": "object" + }, + { + "type": "null" + } + ] + }, + "skinApplied": { + "type": [ + "boolean", + "null" + ] + }, + "status": { + "enum": [ + "stopped", + "launching", + "active", + "paused", + "paused-incompatible", + "recovery-required", + "restoring", + "restored-awaiting-exit", + "restored-cleanup-required" + ], + "type": "string" + } + }, + "required": [ + "status", + "controllerAvailable", + "selectedTheme", + "appliedTheme", + "skinApplied", + "packageVersion", + "operation", + "nextAction" + ], + "type": "object" + }, + "studioVersion": { + "pattern": "^\\d+\\.\\d+\\.\\d+(?:-(?:alpha|beta|rc)\\.\\d+)?$", + "type": "string" + } + }, + "required": [ + "protocolVersion", + "studioVersion", + "capabilities", + "runtime" + ], + "type": "object" + } + } + }, + "createDraft": { + "$ref": "#/definitions/createDraft", + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "createDraft": { + "additionalProperties": false, + "properties": { + "conflictResolution": { + "enum": [ + "load-existing", + "overwrite-existing" + ], + "type": "string" + }, + "name": { + "maxLength": 80, + "minLength": 1, + "type": "string" + }, + "source": { + "additionalProperties": false, + "properties": { + "ref": { + "additionalProperties": false, + "properties": { + "id": { + "maxLength": 80, + "minLength": 3, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "type": "string" + }, + "version": { + "pattern": "^(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)$", + "type": "string" + } + }, + "required": [ + "id", + "version" + ], + "type": "object" + }, + "source": { + "enum": [ + "builtin", + "personal", + "recipe" + ], + "type": "string" + } + }, + "required": [ + "source", + "ref" + ], + "type": "object" + }, + "themeId": { + "maxLength": 80, + "minLength": 3, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "type": "string" + } + }, + "required": [ + "source" + ], + "type": "object" + } + } + }, + "deleteTheme": { + "$ref": "#/definitions/deleteTheme", + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "deleteTheme": { + "additionalProperties": false, + "properties": { + "id": { + "maxLength": 80, + "minLength": 3, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "type": "string" + }, + "version": { + "pattern": "^(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)$", + "type": "string" + } + }, + "required": [ + "id" + ], + "type": "object" + } + } + }, + "draft": { + "$ref": "#/definitions/draft", + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "draft": { + "additionalProperties": false, + "properties": { + "assetUrls": { + "additionalProperties": { + "pattern": "^\\/api\\/", + "type": "string" + }, + "type": "object" + }, + "dirty": { + "type": "boolean" + }, + "draftId": { + "format": "uuid", + "type": "string" + }, + "issues": { + "items": { + "additionalProperties": false, + "properties": { + "code": { + "pattern": "^[A-Z0-9_]+$", + "type": "string" + }, + "message": { + "maxLength": 500, + "minLength": 1, + "type": "string" + }, + "path": { + "maxLength": 240, + "type": "string" + }, + "severity": { + "enum": [ + "error", + "warning" + ], + "type": "string" + } + }, + "required": [ + "code", + "path", + "message", + "severity" + ], + "type": "object" + }, + "type": "array" + }, + "redoAvailable": { + "type": "boolean" + }, + "revision": { + "minimum": 0, + "type": "integer" + }, + "savedRef": { + "anyOf": [ + { + "additionalProperties": false, + "properties": { + "id": { + "maxLength": 80, + "minLength": 3, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "type": "string" + }, + "version": { + "pattern": "^(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)$", + "type": "string" + } + }, + "required": [ + "id", + "version" + ], + "type": "object" + }, + { + "type": "null" + } + ] + }, + "theme": { + "additionalProperties": false, + "properties": { + "appearance": { + "default": "auto", + "enum": [ + "auto", + "light", + "dark" + ], + "type": "string" + }, + "assets": { + "additionalProperties": false, + "properties": { + "background": { + "type": "string" + }, + "decorations": { + "additionalProperties": { + "type": "string" + }, + "propertyNames": { + "maxLength": 40, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$" + }, + "type": "object" + }, + "fonts": { + "additionalProperties": { + "type": "string" + }, + "propertyNames": { + "maxLength": 40, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$" + }, + "type": "object" + }, + "portrait": { + "type": "string" + }, + "profileAvatar": { + "type": "string" + }, + "projectIcons": { + "items": { + "type": "string" + }, + "maxItems": 12, + "minItems": 1, + "type": "array" + }, + "suggestionIcons": { + "additionalProperties": false, + "properties": { + "card1": { + "type": "string" + }, + "card2": { + "type": "string" + }, + "card3": { + "type": "string" + }, + "card4": { + "type": "string" + } + }, + "type": "object" + } + }, + "type": "object" + }, + "author": { + "maxLength": 80, + "minLength": 1, + "type": "string" + }, + "background": { + "additionalProperties": false, + "properties": { + "blur": { + "maximum": 30, + "minimum": 0, + "type": "number" + }, + "brightness": { + "maximum": 1.5, + "minimum": 0.3, + "type": "number" + }, + "overlay": { + "maximum": 0.9, + "minimum": 0, + "type": "number" + }, + "positionX": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "positionY": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "safeArea": { + "default": "auto", + "enum": [ + "auto", + "left", + "center", + "right", + "none" + ], + "type": "string" + }, + "scale": { + "maximum": 3, + "minimum": 0.5, + "type": "number" + }, + "taskMode": { + "default": "full", + "enum": [ + "auto", + "full", + "ambient", + "banner", + "off" + ], + "type": "string" + }, + "taskOpacity": { + "default": 0.82, + "maximum": 1, + "minimum": 0, + "type": "number" + } + }, + "required": [ + "positionX", + "positionY", + "scale", + "blur", + "brightness", + "overlay" + ], + "type": "object" + }, + "colors": { + "additionalProperties": false, + "properties": { + "accent": { + "type": "string" + }, + "border": { + "type": "string" + }, + "codeText": { + "type": "string" + }, + "danger": { + "type": "string" + }, + "info": { + "type": "string" + }, + "inputText": { + "type": "string" + }, + "link": { + "type": "string" + }, + "muted": { + "type": "string" + }, + "panel": { + "type": "string" + }, + "placeholder": { + "type": "string" + }, + "secondary": { + "type": "string" + }, + "success": { + "type": "string" + }, + "text": { + "type": "string" + }, + "textSecondary": { + "type": "string" + }, + "warning": { + "type": "string" + } + }, + "required": [ + "accent", + "secondary", + "text", + "muted", + "panel", + "border", + "success", + "warning", + "danger", + "info", + "textSecondary", + "link", + "inputText", + "placeholder", + "codeText" + ], + "type": "object" + }, + "composition": { + "additionalProperties": false, + "properties": { + "layers": { + "items": { + "additionalProperties": false, + "properties": { + "anchor": { + "enum": [ + "top-left", + "top-center", + "top-right", + "center-left", + "center", + "center-right", + "bottom-left", + "bottom-center", + "bottom-right" + ], + "type": "string" + }, + "asset": { + "anyOf": [ + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "portrait", + "type": "string" + } + }, + "required": [ + "kind" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "assetKey": { + "maxLength": 40, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "type": "string" + }, + "kind": { + "const": "decoration", + "type": "string" + } + }, + "required": [ + "kind", + "assetKey" + ], + "type": "object" + } + ] + }, + "id": { + "maxLength": 40, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "type": "string" + }, + "opacity": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "positionX": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "positionY": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "required": { + "type": "boolean" + }, + "rotation": { + "maximum": 180, + "minimum": -180, + "type": "number" + }, + "surface": { + "enum": [ + "viewport", + "main", + "home-hero", + "suggestions" + ], + "type": "string" + }, + "width": { + "maximum": 1.5, + "minimum": 0.02, + "type": "number" + } + }, + "required": [ + "id", + "asset", + "surface", + "anchor", + "positionX", + "positionY", + "width", + "opacity", + "rotation", + "required" + ], + "type": "object" + }, + "maxItems": 24, + "type": "array" + } + }, + "required": [ + "layers" + ], + "type": "object" + }, + "decorations": { + "items": { + "additionalProperties": false, + "properties": { + "assetKey": { + "maxLength": 40, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "intensity": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "opacity": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "placement": { + "enum": [ + "background", + "corners", + "hero", + "cards" + ], + "type": "string" + }, + "scale": { + "maximum": 3, + "minimum": 0.25, + "type": "number" + }, + "type": { + "enum": [ + "particles", + "ribbon", + "butterflies", + "polaroid", + "badge", + "sparkles", + "image" + ], + "type": "string" + } + }, + "required": [ + "type", + "enabled", + "intensity" + ], + "type": "object" + }, + "maxItems": 16, + "type": "array" + }, + "description": { + "maxLength": 240, + "minLength": 1, + "type": "string" + }, + "home": { + "additionalProperties": false, + "properties": { + "welcome": { + "additionalProperties": false, + "properties": { + "layout": { + "additionalProperties": false, + "properties": { + "anchor": { + "enum": [ + "top-left", + "top-center", + "top-right", + "center-left", + "center", + "center-right", + "bottom-left", + "bottom-center", + "bottom-right" + ], + "type": "string" + }, + "hideNativeIcon": { + "default": false, + "type": "boolean" + }, + "positionX": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "positionY": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "textAlign": { + "enum": [ + "left", + "center", + "right" + ], + "type": "string" + }, + "width": { + "maximum": 1, + "minimum": 0.2, + "type": "number" + } + }, + "required": [ + "anchor", + "positionX", + "positionY", + "width", + "textAlign" + ], + "type": "object" + }, + "localized": { + "additionalProperties": { + "additionalProperties": false, + "properties": { + "lines": { + "items": { + "type": "string" + }, + "maxItems": 3, + "minItems": 1, + "type": "array" + } + }, + "required": [ + "lines" + ], + "type": "object" + }, + "propertyNames": { + "enum": [ + "zh-CN", + "en" + ] + }, + "type": "object" + } + }, + "required": [ + "localized" + ], + "type": "object" + } + }, + "required": [ + "welcome" + ], + "type": "object" + }, + "id": { + "maxLength": 80, + "minLength": 3, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "type": "string" + }, + "interfaceImages": { + "additionalProperties": false, + "properties": { + "profileAvatarSize": { + "maximum": 48, + "minimum": 16, + "type": "integer" + }, + "projectIconSize": { + "maximum": 32, + "minimum": 12, + "type": "integer" + }, + "suggestionIconSize": { + "maximum": 64, + "minimum": 16, + "type": "integer" + } + }, + "required": [ + "profileAvatarSize", + "suggestionIconSize", + "projectIconSize" + ], + "type": "object" + }, + "kind": { + "enum": [ + "theme", + "recipe" + ], + "type": "string" + }, + "layout": { + "additionalProperties": false, + "properties": { + "cardColumns": { + "maximum": 4, + "minimum": 2, + "type": "integer" + }, + "composerWidth": { + "maximum": 1, + "minimum": 0.5, + "type": "number" + }, + "heroHeight": { + "maximum": 560, + "minimum": 180, + "type": "integer" + }, + "moduleGap": { + "maximum": 48, + "minimum": 0, + "type": "integer" + }, + "modules": { + "items": { + "additionalProperties": false, + "properties": { + "align": { + "enum": [ + "start", + "center", + "end", + "stretch" + ], + "type": "string" + }, + "id": { + "enum": [ + "sidebar", + "topbar", + "hero", + "suggestions", + "project-picker", + "composer", + "task-background", + "content-layer" + ], + "type": "string" + }, + "order": { + "maximum": 7, + "minimum": 0, + "type": "integer" + }, + "size": { + "enum": [ + "compact", + "regular", + "expanded" + ], + "type": "string" + }, + "spacing": { + "maximum": 48, + "minimum": 0, + "type": "integer" + }, + "visible": { + "type": "boolean" + } + }, + "required": [ + "id", + "order", + "visible", + "size", + "align", + "spacing" + ], + "type": "object" + }, + "maxItems": 8, + "minItems": 8, + "type": "array" + }, + "sidebarDensity": { + "enum": [ + "compact", + "comfortable" + ], + "type": "string" + } + }, + "required": [ + "heroHeight", + "cardColumns", + "composerWidth", + "sidebarDensity", + "moduleGap", + "modules" + ], + "type": "object" + }, + "metadata": { + "additionalProperties": false, + "properties": { + "homepage": { + "format": "uri", + "maxLength": 500, + "type": "string" + }, + "localized": { + "additionalProperties": { + "additionalProperties": false, + "properties": { + "description": { + "maxLength": 240, + "minLength": 1, + "type": "string" + }, + "name": { + "maxLength": 80, + "minLength": 1, + "type": "string" + } + }, + "type": "object" + }, + "propertyNames": { + "enum": [ + "zh-CN", + "en" + ] + }, + "type": "object" + } + }, + "type": "object" + }, + "name": { + "maxLength": 80, + "minLength": 1, + "type": "string" + }, + "rights": { + "additionalProperties": false, + "properties": { + "attribution": { + "maxLength": 240, + "minLength": 1, + "type": "string" + }, + "licenseId": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "localOnly": { + "type": "boolean" + }, + "source": { + "format": "uri", + "maxLength": 500, + "type": "string" + } + }, + "required": [ + "licenseId", + "localOnly" + ], + "type": "object" + }, + "schemaVersion": { + "const": 4, + "type": "number" + }, + "surfaces": { + "additionalProperties": false, + "default": { + "baseOpacity": 0.68, + "blur": 0, + "elevatedOpacity": 0.92, + "terminalOpacity": 0.82 + }, + "properties": { + "baseOpacity": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "blur": { + "maximum": 30, + "minimum": 0, + "type": "number" + }, + "elevatedOpacity": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "terminalOpacity": { + "maximum": 1, + "minimum": 0, + "type": "number" + } + }, + "required": [ + "baseOpacity", + "elevatedOpacity", + "terminalOpacity", + "blur" + ], + "type": "object" + }, + "typography": { + "additionalProperties": false, + "properties": { + "codeFamily": { + "maxLength": 120, + "minLength": 1, + "type": "string" + }, + "codeFontAssetKey": { + "maxLength": 40, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "type": "string" + }, + "codeSize": { + "maximum": 22, + "minimum": 11, + "type": "number" + }, + "codeWeight": { + "enum": [ + 400, + 500, + 600, + 700 + ], + "type": "number" + }, + "displayFamily": { + "maxLength": 120, + "minLength": 1, + "type": "string" + }, + "displayFontAssetKey": { + "maxLength": 40, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "type": "string" + }, + "displayLetterSpacing": { + "maximum": 0.2, + "minimum": -0.05, + "type": "number" + }, + "displayLineHeight": { + "maximum": 1.8, + "minimum": 1.1, + "type": "number" + }, + "displaySize": { + "maximum": 72, + "minimum": 20, + "type": "number" + }, + "displayWeight": { + "enum": [ + 400, + 500, + 600, + 700 + ], + "type": "number" + }, + "lineHeight": { + "maximum": 1.8, + "minimum": 1.2, + "type": "number" + }, + "scale": { + "maximum": 1.3, + "minimum": 0.85, + "type": "number" + }, + "uiFamily": { + "maxLength": 120, + "minLength": 1, + "type": "string" + }, + "uiFontAssetKey": { + "maxLength": 40, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "type": "string" + }, + "uiSize": { + "maximum": 22, + "minimum": 12, + "type": "number" + }, + "uiWeight": { + "enum": [ + 400, + 500, + 600, + 700 + ], + "type": "number" + } + }, + "required": [ + "uiFamily", + "codeFamily", + "scale", + "uiSize", + "codeSize", + "uiWeight", + "codeWeight", + "lineHeight", + "displayFamily", + "displaySize", + "displayWeight", + "displayLineHeight", + "displayLetterSpacing" + ], + "type": "object" + }, + "version": { + "pattern": "^(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)$", + "type": "string" + } + }, + "required": [ + "schemaVersion", + "kind", + "id", + "name", + "version", + "author", + "assets", + "colors", + "typography", + "background", + "decorations", + "layout", + "rights", + "interfaceImages", + "composition" + ], + "type": "object" + }, + "undoAvailable": { + "type": "boolean" + }, + "updatedAt": { + "format": "date-time", + "type": "string" + } + }, + "required": [ + "draftId", + "theme", + "revision", + "updatedAt", + "savedRef", + "dirty", + "undoAvailable", + "redoAvailable", + "issues", + "assetUrls" + ], + "type": "object" + } + } + }, + "draftCommand": { + "$ref": "#/definitions/draftCommand", + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "draftCommand": { + "additionalProperties": false, + "properties": { + "draftId": { + "format": "uuid", + "type": "string" + }, + "expectedRevision": { + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "draftId", + "expectedRevision" + ], + "type": "object" + } + } + }, + "event": { + "$ref": "#/definitions/event", + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "event": { + "anyOf": [ + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "runtime-status", + "type": "string" + }, + "protocolVersion": { + "const": 2, + "type": "number" + }, + "runtime": { + "additionalProperties": false, + "properties": { + "appliedTheme": { + "anyOf": [ + { + "additionalProperties": false, + "properties": { + "id": { + "maxLength": 80, + "minLength": 3, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "type": "string" + }, + "version": { + "pattern": "^(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)$", + "type": "string" + } + }, + "required": [ + "id", + "version" + ], + "type": "object" + }, + { + "type": "null" + } + ] + }, + "controllerAvailable": { + "type": "boolean" + }, + "nextAction": { + "maxLength": 500, + "type": "string" + }, + "operation": { + "anyOf": [ + { + "enum": [ + "launch", + "switch", + "pause", + "resume", + "restore" + ], + "type": "string" + }, + { + "type": "null" + } + ] + }, + "packageVersion": { + "anyOf": [ + { + "maxLength": 40, + "type": "string" + }, + { + "type": "null" + } + ] + }, + "selectedTheme": { + "anyOf": [ + { + "additionalProperties": false, + "properties": { + "id": { + "maxLength": 80, + "minLength": 3, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "type": "string" + }, + "version": { + "pattern": "^(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)$", + "type": "string" + } + }, + "required": [ + "id", + "version" + ], + "type": "object" + }, + { + "type": "null" + } + ] + }, + "skinApplied": { + "type": [ + "boolean", + "null" + ] + }, + "status": { + "enum": [ + "stopped", + "launching", + "active", + "paused", + "paused-incompatible", + "recovery-required", + "restoring", + "restored-awaiting-exit", + "restored-cleanup-required" + ], + "type": "string" + } + }, + "required": [ + "status", + "controllerAvailable", + "selectedTheme", + "appliedTheme", + "skinApplied", + "packageVersion", + "operation", + "nextAction" + ], + "type": "object" + }, + "sequence": { + "exclusiveMinimum": 0, + "type": "integer" + } + }, + "required": [ + "protocolVersion", + "sequence", + "kind", + "runtime" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "draftId": { + "format": "uuid", + "type": "string" + }, + "kind": { + "const": "draft-updated", + "type": "string" + }, + "protocolVersion": { + "const": 2, + "type": "number" + }, + "revision": { + "minimum": 0, + "type": "integer" + }, + "sequence": { + "exclusiveMinimum": 0, + "type": "integer" + } + }, + "required": [ + "protocolVersion", + "sequence", + "kind", + "draftId", + "revision" + ], + "type": "object" + } + ] + } + } + }, + "exportedTheme": { + "$ref": "#/definitions/exportedTheme", + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "exportedTheme": { + "additionalProperties": false, + "properties": { + "bytes": {}, + "fileName": { + "pattern": "^[a-z0-9-]+-\\d+\\.\\d+\\.\\d+\\.ocskin$", + "type": "string" + }, + "mimeType": { + "const": "application/vnd.open-chatgpt-skin+zip", + "type": "string" + } + }, + "required": [ + "fileName", + "mimeType", + "bytes" + ], + "type": "object" + } + } + }, + "importTheme": { + "$ref": "#/definitions/importTheme", + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "importTheme": { + "additionalProperties": false, + "properties": { + "bytes": {}, + "fileName": { + "maxLength": 160, + "minLength": 1, + "type": "string" + } + }, + "required": [ + "fileName", + "bytes" + ], + "type": "object" + } + } + }, + "runtimeStatus": { + "$ref": "#/definitions/runtimeStatus", + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "runtimeStatus": { + "additionalProperties": false, + "properties": { + "appliedTheme": { + "anyOf": [ + { + "additionalProperties": false, + "properties": { + "id": { + "maxLength": 80, + "minLength": 3, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "type": "string" + }, + "version": { + "pattern": "^(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)$", + "type": "string" + } + }, + "required": [ + "id", + "version" + ], + "type": "object" + }, + { + "type": "null" + } + ] + }, + "controllerAvailable": { + "type": "boolean" + }, + "nextAction": { + "maxLength": 500, + "type": "string" + }, + "operation": { + "anyOf": [ + { + "enum": [ + "launch", + "switch", + "pause", + "resume", + "restore" + ], + "type": "string" + }, + { + "type": "null" + } + ] + }, + "packageVersion": { + "anyOf": [ + { + "maxLength": 40, + "type": "string" + }, + { + "type": "null" + } + ] + }, + "selectedTheme": { + "anyOf": [ + { + "additionalProperties": false, + "properties": { + "id": { + "maxLength": 80, + "minLength": 3, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "type": "string" + }, + "version": { + "pattern": "^(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)$", + "type": "string" + } + }, + "required": [ + "id", + "version" + ], + "type": "object" + }, + { + "type": "null" + } + ] + }, + "skinApplied": { + "type": [ + "boolean", + "null" + ] + }, + "status": { + "enum": [ + "stopped", + "launching", + "active", + "paused", + "paused-incompatible", + "recovery-required", + "restoring", + "restored-awaiting-exit", + "restored-cleanup-required" + ], + "type": "string" + } + }, + "required": [ + "status", + "controllerAvailable", + "selectedTheme", + "appliedTheme", + "skinApplied", + "packageVersion", + "operation", + "nextAction" + ], + "type": "object" + } + } + }, + "saveResult": { + "$ref": "#/definitions/saveResult", + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "saveResult": { + "additionalProperties": false, + "properties": { + "draft": { + "additionalProperties": false, + "properties": { + "assetUrls": { + "additionalProperties": { + "pattern": "^\\/api\\/", + "type": "string" + }, + "type": "object" + }, + "dirty": { + "type": "boolean" + }, + "draftId": { + "format": "uuid", + "type": "string" + }, + "issues": { + "items": { + "additionalProperties": false, + "properties": { + "code": { + "pattern": "^[A-Z0-9_]+$", + "type": "string" + }, + "message": { + "maxLength": 500, + "minLength": 1, + "type": "string" + }, + "path": { + "maxLength": 240, + "type": "string" + }, + "severity": { + "enum": [ + "error", + "warning" + ], + "type": "string" + } + }, + "required": [ + "code", + "path", + "message", + "severity" + ], + "type": "object" + }, + "type": "array" + }, + "redoAvailable": { + "type": "boolean" + }, + "revision": { + "minimum": 0, + "type": "integer" + }, + "savedRef": { + "anyOf": [ + { + "additionalProperties": false, + "properties": { + "id": { + "maxLength": 80, + "minLength": 3, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "type": "string" + }, + "version": { + "pattern": "^(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)$", + "type": "string" + } + }, + "required": [ + "id", + "version" + ], + "type": "object" + }, + { + "type": "null" + } + ] + }, + "theme": { + "additionalProperties": false, + "properties": { + "appearance": { + "default": "auto", + "enum": [ + "auto", + "light", + "dark" + ], + "type": "string" + }, + "assets": { + "additionalProperties": false, + "properties": { + "background": { + "type": "string" + }, + "decorations": { + "additionalProperties": { + "type": "string" + }, + "propertyNames": { + "maxLength": 40, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$" + }, + "type": "object" + }, + "fonts": { + "additionalProperties": { + "type": "string" + }, + "propertyNames": { + "maxLength": 40, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$" + }, + "type": "object" + }, + "portrait": { + "type": "string" + }, + "profileAvatar": { + "type": "string" + }, + "projectIcons": { + "items": { + "type": "string" + }, + "maxItems": 12, + "minItems": 1, + "type": "array" + }, + "suggestionIcons": { + "additionalProperties": false, + "properties": { + "card1": { + "type": "string" + }, + "card2": { + "type": "string" + }, + "card3": { + "type": "string" + }, + "card4": { + "type": "string" + } + }, + "type": "object" + } + }, + "type": "object" + }, + "author": { + "maxLength": 80, + "minLength": 1, + "type": "string" + }, + "background": { + "additionalProperties": false, + "properties": { + "blur": { + "maximum": 30, + "minimum": 0, + "type": "number" + }, + "brightness": { + "maximum": 1.5, + "minimum": 0.3, + "type": "number" + }, + "overlay": { + "maximum": 0.9, + "minimum": 0, + "type": "number" + }, + "positionX": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "positionY": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "safeArea": { + "default": "auto", + "enum": [ + "auto", + "left", + "center", + "right", + "none" + ], + "type": "string" + }, + "scale": { + "maximum": 3, + "minimum": 0.5, + "type": "number" + }, + "taskMode": { + "default": "full", + "enum": [ + "auto", + "full", + "ambient", + "banner", + "off" + ], + "type": "string" + }, + "taskOpacity": { + "default": 0.82, + "maximum": 1, + "minimum": 0, + "type": "number" + } + }, + "required": [ + "positionX", + "positionY", + "scale", + "blur", + "brightness", + "overlay" + ], + "type": "object" + }, + "colors": { + "additionalProperties": false, + "properties": { + "accent": { + "type": "string" + }, + "border": { + "type": "string" + }, + "codeText": { + "type": "string" + }, + "danger": { + "type": "string" + }, + "info": { + "type": "string" + }, + "inputText": { + "type": "string" + }, + "link": { + "type": "string" + }, + "muted": { + "type": "string" + }, + "panel": { + "type": "string" + }, + "placeholder": { + "type": "string" + }, + "secondary": { + "type": "string" + }, + "success": { + "type": "string" + }, + "text": { + "type": "string" + }, + "textSecondary": { + "type": "string" + }, + "warning": { + "type": "string" + } + }, + "required": [ + "accent", + "secondary", + "text", + "muted", + "panel", + "border", + "success", + "warning", + "danger", + "info", + "textSecondary", + "link", + "inputText", + "placeholder", + "codeText" + ], + "type": "object" + }, + "composition": { + "additionalProperties": false, + "properties": { + "layers": { + "items": { + "additionalProperties": false, + "properties": { + "anchor": { + "enum": [ + "top-left", + "top-center", + "top-right", + "center-left", + "center", + "center-right", + "bottom-left", + "bottom-center", + "bottom-right" + ], + "type": "string" + }, + "asset": { + "anyOf": [ + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "portrait", + "type": "string" + } + }, + "required": [ + "kind" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "assetKey": { + "maxLength": 40, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "type": "string" + }, + "kind": { + "const": "decoration", + "type": "string" + } + }, + "required": [ + "kind", + "assetKey" + ], + "type": "object" + } + ] + }, + "id": { + "maxLength": 40, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "type": "string" + }, + "opacity": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "positionX": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "positionY": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "required": { + "type": "boolean" + }, + "rotation": { + "maximum": 180, + "minimum": -180, + "type": "number" + }, + "surface": { + "enum": [ + "viewport", + "main", + "home-hero", + "suggestions" + ], + "type": "string" + }, + "width": { + "maximum": 1.5, + "minimum": 0.02, + "type": "number" + } + }, + "required": [ + "id", + "asset", + "surface", + "anchor", + "positionX", + "positionY", + "width", + "opacity", + "rotation", + "required" + ], + "type": "object" + }, + "maxItems": 24, + "type": "array" + } + }, + "required": [ + "layers" + ], + "type": "object" + }, + "decorations": { + "items": { + "additionalProperties": false, + "properties": { + "assetKey": { + "maxLength": 40, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "intensity": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "opacity": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "placement": { + "enum": [ + "background", + "corners", + "hero", + "cards" + ], + "type": "string" + }, + "scale": { + "maximum": 3, + "minimum": 0.25, + "type": "number" + }, + "type": { + "enum": [ + "particles", + "ribbon", + "butterflies", + "polaroid", + "badge", + "sparkles", + "image" + ], + "type": "string" + } + }, + "required": [ + "type", + "enabled", + "intensity" + ], + "type": "object" + }, + "maxItems": 16, + "type": "array" + }, + "description": { + "maxLength": 240, + "minLength": 1, + "type": "string" + }, + "home": { + "additionalProperties": false, + "properties": { + "welcome": { + "additionalProperties": false, + "properties": { + "layout": { + "additionalProperties": false, + "properties": { + "anchor": { + "enum": [ + "top-left", + "top-center", + "top-right", + "center-left", + "center", + "center-right", + "bottom-left", + "bottom-center", + "bottom-right" + ], + "type": "string" + }, + "hideNativeIcon": { + "default": false, + "type": "boolean" + }, + "positionX": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "positionY": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "textAlign": { + "enum": [ + "left", + "center", + "right" + ], + "type": "string" + }, + "width": { + "maximum": 1, + "minimum": 0.2, + "type": "number" + } + }, + "required": [ + "anchor", + "positionX", + "positionY", + "width", + "textAlign" + ], + "type": "object" + }, + "localized": { + "additionalProperties": { + "additionalProperties": false, + "properties": { + "lines": { + "items": { + "type": "string" + }, + "maxItems": 3, + "minItems": 1, + "type": "array" + } + }, + "required": [ + "lines" + ], + "type": "object" + }, + "propertyNames": { + "enum": [ + "zh-CN", + "en" + ] + }, + "type": "object" + } + }, + "required": [ + "localized" + ], + "type": "object" + } + }, + "required": [ + "welcome" + ], + "type": "object" + }, + "id": { + "maxLength": 80, + "minLength": 3, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "type": "string" + }, + "interfaceImages": { + "additionalProperties": false, + "properties": { + "profileAvatarSize": { + "maximum": 48, + "minimum": 16, + "type": "integer" + }, + "projectIconSize": { + "maximum": 32, + "minimum": 12, + "type": "integer" + }, + "suggestionIconSize": { + "maximum": 64, + "minimum": 16, + "type": "integer" + } + }, + "required": [ + "profileAvatarSize", + "suggestionIconSize", + "projectIconSize" + ], + "type": "object" + }, + "kind": { + "enum": [ + "theme", + "recipe" + ], + "type": "string" + }, + "layout": { + "additionalProperties": false, + "properties": { + "cardColumns": { + "maximum": 4, + "minimum": 2, + "type": "integer" + }, + "composerWidth": { + "maximum": 1, + "minimum": 0.5, + "type": "number" + }, + "heroHeight": { + "maximum": 560, + "minimum": 180, + "type": "integer" + }, + "moduleGap": { + "maximum": 48, + "minimum": 0, + "type": "integer" + }, + "modules": { + "items": { + "additionalProperties": false, + "properties": { + "align": { + "enum": [ + "start", + "center", + "end", + "stretch" + ], + "type": "string" + }, + "id": { + "enum": [ + "sidebar", + "topbar", + "hero", + "suggestions", + "project-picker", + "composer", + "task-background", + "content-layer" + ], + "type": "string" + }, + "order": { + "maximum": 7, + "minimum": 0, + "type": "integer" + }, + "size": { + "enum": [ + "compact", + "regular", + "expanded" + ], + "type": "string" + }, + "spacing": { + "maximum": 48, + "minimum": 0, + "type": "integer" + }, + "visible": { + "type": "boolean" + } + }, + "required": [ + "id", + "order", + "visible", + "size", + "align", + "spacing" + ], + "type": "object" + }, + "maxItems": 8, + "minItems": 8, + "type": "array" + }, + "sidebarDensity": { + "enum": [ + "compact", + "comfortable" + ], + "type": "string" + } + }, + "required": [ + "heroHeight", + "cardColumns", + "composerWidth", + "sidebarDensity", + "moduleGap", + "modules" + ], + "type": "object" + }, + "metadata": { + "additionalProperties": false, + "properties": { + "homepage": { + "format": "uri", + "maxLength": 500, + "type": "string" + }, + "localized": { + "additionalProperties": { + "additionalProperties": false, + "properties": { + "description": { + "maxLength": 240, + "minLength": 1, + "type": "string" + }, + "name": { + "maxLength": 80, + "minLength": 1, + "type": "string" + } + }, + "type": "object" + }, + "propertyNames": { + "enum": [ + "zh-CN", + "en" + ] + }, + "type": "object" + } + }, + "type": "object" + }, + "name": { + "maxLength": 80, + "minLength": 1, + "type": "string" + }, + "rights": { + "additionalProperties": false, + "properties": { + "attribution": { + "maxLength": 240, + "minLength": 1, + "type": "string" + }, + "licenseId": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "localOnly": { + "type": "boolean" + }, + "source": { + "format": "uri", + "maxLength": 500, + "type": "string" + } + }, + "required": [ + "licenseId", + "localOnly" + ], + "type": "object" + }, + "schemaVersion": { + "const": 4, + "type": "number" + }, + "surfaces": { + "additionalProperties": false, + "default": { + "baseOpacity": 0.68, + "blur": 0, + "elevatedOpacity": 0.92, + "terminalOpacity": 0.82 + }, + "properties": { + "baseOpacity": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "blur": { + "maximum": 30, + "minimum": 0, + "type": "number" + }, + "elevatedOpacity": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "terminalOpacity": { + "maximum": 1, + "minimum": 0, + "type": "number" + } + }, + "required": [ + "baseOpacity", + "elevatedOpacity", + "terminalOpacity", + "blur" + ], + "type": "object" + }, + "typography": { + "additionalProperties": false, + "properties": { + "codeFamily": { + "maxLength": 120, + "minLength": 1, + "type": "string" + }, + "codeFontAssetKey": { + "maxLength": 40, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "type": "string" + }, + "codeSize": { + "maximum": 22, + "minimum": 11, + "type": "number" + }, + "codeWeight": { + "enum": [ + 400, + 500, + 600, + 700 + ], + "type": "number" + }, + "displayFamily": { + "maxLength": 120, + "minLength": 1, + "type": "string" + }, + "displayFontAssetKey": { + "maxLength": 40, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "type": "string" + }, + "displayLetterSpacing": { + "maximum": 0.2, + "minimum": -0.05, + "type": "number" + }, + "displayLineHeight": { + "maximum": 1.8, + "minimum": 1.1, + "type": "number" + }, + "displaySize": { + "maximum": 72, + "minimum": 20, + "type": "number" + }, + "displayWeight": { + "enum": [ + 400, + 500, + 600, + 700 + ], + "type": "number" + }, + "lineHeight": { + "maximum": 1.8, + "minimum": 1.2, + "type": "number" + }, + "scale": { + "maximum": 1.3, + "minimum": 0.85, + "type": "number" + }, + "uiFamily": { + "maxLength": 120, + "minLength": 1, + "type": "string" + }, + "uiFontAssetKey": { + "maxLength": 40, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "type": "string" + }, + "uiSize": { + "maximum": 22, + "minimum": 12, + "type": "number" + }, + "uiWeight": { + "enum": [ + 400, + 500, + 600, + 700 + ], + "type": "number" + } + }, + "required": [ + "uiFamily", + "codeFamily", + "scale", + "uiSize", + "codeSize", + "uiWeight", + "codeWeight", + "lineHeight", + "displayFamily", + "displaySize", + "displayWeight", + "displayLineHeight", + "displayLetterSpacing" + ], + "type": "object" + }, + "version": { + "pattern": "^(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)$", + "type": "string" + } + }, + "required": [ + "schemaVersion", + "kind", + "id", + "name", + "version", + "author", + "assets", + "colors", + "typography", + "background", + "decorations", + "layout", + "rights", + "interfaceImages", + "composition" + ], + "type": "object" + }, + "undoAvailable": { + "type": "boolean" + }, + "updatedAt": { + "format": "date-time", + "type": "string" + } + }, + "required": [ + "draftId", + "theme", + "revision", + "updatedAt", + "savedRef", + "dirty", + "undoAvailable", + "redoAvailable", + "issues", + "assetUrls" + ], + "type": "object" + }, + "ref": { + "additionalProperties": false, + "properties": { + "id": { + "maxLength": 80, + "minLength": 3, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "type": "string" + }, + "version": { + "pattern": "^(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)$", + "type": "string" + } + }, + "required": [ + "id", + "version" + ], + "type": "object" + } + }, + "required": [ + "draft", + "ref" + ], + "type": "object" + } + } + }, + "sessionExchange": { + "$ref": "#/definitions/sessionExchange", + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "sessionExchange": { + "additionalProperties": false, + "properties": { + "token": { + "pattern": "^[0-9a-f]{64}$", + "type": "string" + } + }, + "required": [ + "token" + ], + "type": "object" + } + } + }, + "themeLibrary": { + "$ref": "#/definitions/themeLibrary", + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "themeLibrary": { + "additionalProperties": false, + "properties": { + "themes": { + "items": { + "additionalProperties": false, + "properties": { + "author": { + "maxLength": 80, + "minLength": 1, + "type": "string" + }, + "description": { + "maxLength": 240, + "type": "string" + }, + "homepage": { + "anyOf": [ + { + "format": "uri", + "pattern": "^https\\:\\/\\/", + "type": "string" + }, + { + "type": "null" + } + ] + }, + "localized": { + "additionalProperties": { + "additionalProperties": false, + "properties": { + "description": { + "maxLength": 240, + "minLength": 1, + "type": "string" + }, + "name": { + "maxLength": 80, + "minLength": 1, + "type": "string" + } + }, + "type": "object" + }, + "propertyNames": { + "enum": [ + "zh-CN", + "en" + ] + }, + "type": "object" + }, + "localOnly": { + "type": "boolean" + }, + "name": { + "maxLength": 80, + "minLength": 1, + "type": "string" + }, + "previewUrl": { + "anyOf": [ + { + "pattern": "^\\/api\\/", + "type": "string" + }, + { + "type": "null" + } + ] + }, + "ready": { + "type": "boolean" + }, + "ref": { + "additionalProperties": false, + "properties": { + "id": { + "maxLength": 80, + "minLength": 3, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "type": "string" + }, + "version": { + "pattern": "^(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)$", + "type": "string" + } + }, + "required": [ + "id", + "version" + ], + "type": "object" + }, + "source": { + "enum": [ + "builtin", + "personal", + "recipe" + ], + "type": "string" + } + }, + "required": [ + "ref", + "name", + "author", + "homepage", + "source", + "ready", + "localOnly", + "previewUrl" + ], + "type": "object" + }, + "type": "array" + } + }, + "required": [ + "themes" + ], + "type": "object" + } + } + }, + "themeRef": { + "$ref": "#/definitions/themeRef", + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "themeRef": { + "additionalProperties": false, + "properties": { + "id": { + "maxLength": 80, + "minLength": 3, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "type": "string" + }, + "version": { + "pattern": "^(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)$", + "type": "string" + } + }, + "required": [ + "id", + "version" + ], + "type": "object" + } + } + }, + "updateDraft": { + "$ref": "#/definitions/updateDraft", + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "updateDraft": { + "additionalProperties": false, + "properties": { + "draftId": { + "format": "uuid", + "type": "string" + }, + "expectedRevision": { + "minimum": 0, + "type": "integer" + }, + "theme": { + "additionalProperties": false, + "properties": { + "appearance": { + "default": "auto", + "enum": [ + "auto", + "light", + "dark" + ], + "type": "string" + }, + "assets": { + "additionalProperties": false, + "properties": { + "background": { + "type": "string" + }, + "decorations": { + "additionalProperties": { + "type": "string" + }, + "propertyNames": { + "maxLength": 40, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$" + }, + "type": "object" + }, + "fonts": { + "additionalProperties": { + "type": "string" + }, + "propertyNames": { + "maxLength": 40, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$" + }, + "type": "object" + }, + "portrait": { + "type": "string" + }, + "profileAvatar": { + "type": "string" + }, + "projectIcons": { + "items": { + "type": "string" + }, + "maxItems": 12, + "minItems": 1, + "type": "array" + }, + "suggestionIcons": { + "additionalProperties": false, + "properties": { + "card1": { + "type": "string" + }, + "card2": { + "type": "string" + }, + "card3": { + "type": "string" + }, + "card4": { + "type": "string" + } + }, + "type": "object" + } + }, + "type": "object" + }, + "author": { + "maxLength": 80, + "minLength": 1, + "type": "string" + }, + "background": { + "additionalProperties": false, + "properties": { + "blur": { + "maximum": 30, + "minimum": 0, + "type": "number" + }, + "brightness": { + "maximum": 1.5, + "minimum": 0.3, + "type": "number" + }, + "overlay": { + "maximum": 0.9, + "minimum": 0, + "type": "number" + }, + "positionX": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "positionY": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "safeArea": { + "default": "auto", + "enum": [ + "auto", + "left", + "center", + "right", + "none" + ], + "type": "string" + }, + "scale": { + "maximum": 3, + "minimum": 0.5, + "type": "number" + }, + "taskMode": { + "default": "full", + "enum": [ + "auto", + "full", + "ambient", + "banner", + "off" + ], + "type": "string" + }, + "taskOpacity": { + "default": 0.82, + "maximum": 1, + "minimum": 0, + "type": "number" + } + }, + "required": [ + "positionX", + "positionY", + "scale", + "blur", + "brightness", + "overlay" + ], + "type": "object" + }, + "colors": { + "additionalProperties": false, + "properties": { + "accent": { + "type": "string" + }, + "border": { + "type": "string" + }, + "codeText": { + "type": "string" + }, + "danger": { + "type": "string" + }, + "info": { + "type": "string" + }, + "inputText": { + "type": "string" + }, + "link": { + "type": "string" + }, + "muted": { + "type": "string" + }, + "panel": { + "type": "string" + }, + "placeholder": { + "type": "string" + }, + "secondary": { + "type": "string" + }, + "success": { + "type": "string" + }, + "text": { + "type": "string" + }, + "textSecondary": { + "type": "string" + }, + "warning": { + "type": "string" + } + }, + "required": [ + "accent", + "secondary", + "text", + "muted", + "panel", + "border", + "success", + "warning", + "danger", + "info", + "textSecondary", + "link", + "inputText", + "placeholder", + "codeText" + ], + "type": "object" + }, + "composition": { + "additionalProperties": false, + "properties": { + "layers": { + "items": { + "additionalProperties": false, + "properties": { + "anchor": { + "enum": [ + "top-left", + "top-center", + "top-right", + "center-left", + "center", + "center-right", + "bottom-left", + "bottom-center", + "bottom-right" + ], + "type": "string" + }, + "asset": { + "anyOf": [ + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "portrait", + "type": "string" + } + }, + "required": [ + "kind" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "assetKey": { + "maxLength": 40, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "type": "string" + }, + "kind": { + "const": "decoration", + "type": "string" + } + }, + "required": [ + "kind", + "assetKey" + ], + "type": "object" + } + ] + }, + "id": { + "maxLength": 40, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "type": "string" + }, + "opacity": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "positionX": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "positionY": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "required": { + "type": "boolean" + }, + "rotation": { + "maximum": 180, + "minimum": -180, + "type": "number" + }, + "surface": { + "enum": [ + "viewport", + "main", + "home-hero", + "suggestions" + ], + "type": "string" + }, + "width": { + "maximum": 1.5, + "minimum": 0.02, + "type": "number" + } + }, + "required": [ + "id", + "asset", + "surface", + "anchor", + "positionX", + "positionY", + "width", + "opacity", + "rotation", + "required" + ], + "type": "object" + }, + "maxItems": 24, + "type": "array" + } + }, + "required": [ + "layers" + ], + "type": "object" + }, + "decorations": { + "items": { + "additionalProperties": false, + "properties": { + "assetKey": { + "maxLength": 40, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "intensity": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "opacity": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "placement": { + "enum": [ + "background", + "corners", + "hero", + "cards" + ], + "type": "string" + }, + "scale": { + "maximum": 3, + "minimum": 0.25, + "type": "number" + }, + "type": { + "enum": [ + "particles", + "ribbon", + "butterflies", + "polaroid", + "badge", + "sparkles", + "image" + ], + "type": "string" + } + }, + "required": [ + "type", + "enabled", + "intensity" + ], + "type": "object" + }, + "maxItems": 16, + "type": "array" + }, + "description": { + "maxLength": 240, + "minLength": 1, + "type": "string" + }, + "home": { + "additionalProperties": false, + "properties": { + "welcome": { + "additionalProperties": false, + "properties": { + "layout": { + "additionalProperties": false, + "properties": { + "anchor": { + "enum": [ + "top-left", + "top-center", + "top-right", + "center-left", + "center", + "center-right", + "bottom-left", + "bottom-center", + "bottom-right" + ], + "type": "string" + }, + "hideNativeIcon": { + "default": false, + "type": "boolean" + }, + "positionX": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "positionY": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "textAlign": { + "enum": [ + "left", + "center", + "right" + ], + "type": "string" + }, + "width": { + "maximum": 1, + "minimum": 0.2, + "type": "number" + } + }, + "required": [ + "anchor", + "positionX", + "positionY", + "width", + "textAlign" + ], + "type": "object" + }, + "localized": { + "additionalProperties": { + "additionalProperties": false, + "properties": { + "lines": { + "items": { + "type": "string" + }, + "maxItems": 3, + "minItems": 1, + "type": "array" + } + }, + "required": [ + "lines" + ], + "type": "object" + }, + "propertyNames": { + "enum": [ + "zh-CN", + "en" + ] + }, + "type": "object" + } + }, + "required": [ + "localized" + ], + "type": "object" + } + }, + "required": [ + "welcome" + ], + "type": "object" + }, + "id": { + "maxLength": 80, + "minLength": 3, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "type": "string" + }, + "interfaceImages": { + "additionalProperties": false, + "properties": { + "profileAvatarSize": { + "maximum": 48, + "minimum": 16, + "type": "integer" + }, + "projectIconSize": { + "maximum": 32, + "minimum": 12, + "type": "integer" + }, + "suggestionIconSize": { + "maximum": 64, + "minimum": 16, + "type": "integer" + } + }, + "required": [ + "profileAvatarSize", + "suggestionIconSize", + "projectIconSize" + ], + "type": "object" + }, + "kind": { + "enum": [ + "theme", + "recipe" + ], + "type": "string" + }, + "layout": { + "additionalProperties": false, + "properties": { + "cardColumns": { + "maximum": 4, + "minimum": 2, + "type": "integer" + }, + "composerWidth": { + "maximum": 1, + "minimum": 0.5, + "type": "number" + }, + "heroHeight": { + "maximum": 560, + "minimum": 180, + "type": "integer" + }, + "moduleGap": { + "maximum": 48, + "minimum": 0, + "type": "integer" + }, + "modules": { + "items": { + "additionalProperties": false, + "properties": { + "align": { + "enum": [ + "start", + "center", + "end", + "stretch" + ], + "type": "string" + }, + "id": { + "enum": [ + "sidebar", + "topbar", + "hero", + "suggestions", + "project-picker", + "composer", + "task-background", + "content-layer" + ], + "type": "string" + }, + "order": { + "maximum": 7, + "minimum": 0, + "type": "integer" + }, + "size": { + "enum": [ + "compact", + "regular", + "expanded" + ], + "type": "string" + }, + "spacing": { + "maximum": 48, + "minimum": 0, + "type": "integer" + }, + "visible": { + "type": "boolean" + } + }, + "required": [ + "id", + "order", + "visible", + "size", + "align", + "spacing" + ], + "type": "object" + }, + "maxItems": 8, + "minItems": 8, + "type": "array" + }, + "sidebarDensity": { + "enum": [ + "compact", + "comfortable" + ], + "type": "string" + } + }, + "required": [ + "heroHeight", + "cardColumns", + "composerWidth", + "sidebarDensity", + "moduleGap", + "modules" + ], + "type": "object" + }, + "metadata": { + "additionalProperties": false, + "properties": { + "homepage": { + "format": "uri", + "maxLength": 500, + "type": "string" + }, + "localized": { + "additionalProperties": { + "additionalProperties": false, + "properties": { + "description": { + "maxLength": 240, + "minLength": 1, + "type": "string" + }, + "name": { + "maxLength": 80, + "minLength": 1, + "type": "string" + } + }, + "type": "object" + }, + "propertyNames": { + "enum": [ + "zh-CN", + "en" + ] + }, + "type": "object" + } + }, + "type": "object" + }, + "name": { + "maxLength": 80, + "minLength": 1, + "type": "string" + }, + "rights": { + "additionalProperties": false, + "properties": { + "attribution": { + "maxLength": 240, + "minLength": 1, + "type": "string" + }, + "licenseId": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "localOnly": { + "type": "boolean" + }, + "source": { + "format": "uri", + "maxLength": 500, + "type": "string" + } + }, + "required": [ + "licenseId", + "localOnly" + ], + "type": "object" + }, + "schemaVersion": { + "const": 4, + "type": "number" + }, + "surfaces": { + "additionalProperties": false, + "default": { + "baseOpacity": 0.68, + "blur": 0, + "elevatedOpacity": 0.92, + "terminalOpacity": 0.82 + }, + "properties": { + "baseOpacity": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "blur": { + "maximum": 30, + "minimum": 0, + "type": "number" + }, + "elevatedOpacity": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "terminalOpacity": { + "maximum": 1, + "minimum": 0, + "type": "number" + } + }, + "required": [ + "baseOpacity", + "elevatedOpacity", + "terminalOpacity", + "blur" + ], + "type": "object" + }, + "typography": { + "additionalProperties": false, + "properties": { + "codeFamily": { + "maxLength": 120, + "minLength": 1, + "type": "string" + }, + "codeFontAssetKey": { + "maxLength": 40, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "type": "string" + }, + "codeSize": { + "maximum": 22, + "minimum": 11, + "type": "number" + }, + "codeWeight": { + "enum": [ + 400, + 500, + 600, + 700 + ], + "type": "number" + }, + "displayFamily": { + "maxLength": 120, + "minLength": 1, + "type": "string" + }, + "displayFontAssetKey": { + "maxLength": 40, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "type": "string" + }, + "displayLetterSpacing": { + "maximum": 0.2, + "minimum": -0.05, + "type": "number" + }, + "displayLineHeight": { + "maximum": 1.8, + "minimum": 1.1, + "type": "number" + }, + "displaySize": { + "maximum": 72, + "minimum": 20, + "type": "number" + }, + "displayWeight": { + "enum": [ + 400, + 500, + 600, + 700 + ], + "type": "number" + }, + "lineHeight": { + "maximum": 1.8, + "minimum": 1.2, + "type": "number" + }, + "scale": { + "maximum": 1.3, + "minimum": 0.85, + "type": "number" + }, + "uiFamily": { + "maxLength": 120, + "minLength": 1, + "type": "string" + }, + "uiFontAssetKey": { + "maxLength": 40, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "type": "string" + }, + "uiSize": { + "maximum": 22, + "minimum": 12, + "type": "number" + }, + "uiWeight": { + "enum": [ + 400, + 500, + 600, + 700 + ], + "type": "number" + } + }, + "required": [ + "uiFamily", + "codeFamily", + "scale", + "uiSize", + "codeSize", + "uiWeight", + "codeWeight", + "lineHeight", + "displayFamily", + "displaySize", + "displayWeight", + "displayLineHeight", + "displayLetterSpacing" + ], + "type": "object" + }, + "version": { + "pattern": "^(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)$", + "type": "string" + } + }, + "required": [ + "schemaVersion", + "kind", + "id", + "name", + "version", + "author", + "assets", + "colors", + "typography", + "background", + "decorations", + "layout", + "rights", + "interfaceImages", + "composition" + ], + "type": "object" + } + }, + "required": [ + "draftId", + "expectedRevision", + "theme" + ], + "type": "object" + } + } + }, + "uploadAsset": { + "$ref": "#/definitions/uploadAsset", + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "uploadAsset": { + "additionalProperties": false, + "properties": { + "assetKey": { + "maxLength": 40, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "type": "string" + }, + "bytes": {}, + "draftId": { + "format": "uuid", + "type": "string" + }, + "expectedRevision": { + "minimum": 0, + "type": "integer" + }, + "fileName": { + "maxLength": 160, + "minLength": 1, + "type": "string" + }, + "mimeType": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "slot": { + "enum": [ + "background", + "portrait", + "decoration", + "ui-font", + "code-font", + "display-font", + "composition-layer", + "profile-avatar", + "suggestion-card1", + "suggestion-card2", + "suggestion-card3", + "suggestion-card4", + "project-icon1", + "project-icon2", + "project-icon3", + "project-icon4" + ], + "type": "string" + } + }, + "required": [ + "draftId", + "expectedRevision", + "slot", + "fileName", + "mimeType", + "bytes" + ], + "type": "object" + } + } + } + }, + "schemaVersion": 2 +} diff --git a/contracts/theme/v4/archive-cases.json b/contracts/theme/v4/archive-cases.json new file mode 100644 index 0000000..0c9a5d6 --- /dev/null +++ b/contracts/theme/v4/archive-cases.json @@ -0,0 +1,109 @@ +{ + "cases": [ + { + "name": "valid archive", + "valid": true + }, + { + "expectedErrorCode": "ARCHIVE_ENTRY_UNSAFE", + "expectedPath": "/entries/../secret", + "name": "unsafe traversal entry", + "valid": false + }, + { + "expectedErrorCode": "ARCHIVE_ENTRY_DUPLICATE", + "expectedPath": "/entries", + "name": "case-folded duplicate entry", + "valid": false + }, + { + "expectedErrorCode": "ARCHIVE_ENTRY_UNSUPPORTED", + "expectedPath": "/entries/run.js", + "name": "unsupported entry", + "valid": false + }, + { + "expectedErrorCode": "PACKAGE_EXPANDED_TOO_LARGE", + "expectedPath": "/expandedBytes", + "name": "expanded size limit", + "valid": false + }, + { + "expectedErrorCode": "ARCHIVE_REQUIRED_FILE_MISSING", + "expectedPath": "/entries/theme.json", + "name": "missing required files", + "valid": false + }, + { + "expectedErrorCode": "ARCHIVE_MANIFEST_MISMATCH", + "expectedPath": "/manifest/files", + "name": "manifest mismatch", + "valid": false + }, + { + "expectedErrorCode": "ARCHIVE_HASH_MISMATCH", + "expectedPath": "/manifest/files/sha256", + "name": "hash mismatch", + "valid": false + } + ], + "limits": { + "archiveBytes": 33554432, + "expandedBytes": 33554432 + }, + "manifestSchema": { + "$ref": "#/definitions/OcskinManifest", + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "OcskinManifest": { + "additionalProperties": false, + "properties": { + "files": { + "additionalProperties": { + "additionalProperties": false, + "properties": { + "bytes": { + "maximum": 33554432, + "minimum": 1, + "type": "integer" + }, + "sha256": { + "pattern": "^[0-9a-f]{64}$", + "type": "string" + } + }, + "required": [ + "bytes", + "sha256" + ], + "type": "object" + }, + "type": "object" + }, + "schemaVersion": { + "const": 1, + "type": "number" + }, + "themeId": { + "maxLength": 80, + "minLength": 3, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "type": "string" + }, + "themeVersion": { + "pattern": "^(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)$", + "type": "string" + } + }, + "required": [ + "schemaVersion", + "themeId", + "themeVersion", + "files" + ], + "type": "object" + } + } + }, + "schemaVersion": 1 +} diff --git a/contracts/theme/v4/draft-schema.json b/contracts/theme/v4/draft-schema.json new file mode 100644 index 0000000..e298293 --- /dev/null +++ b/contracts/theme/v4/draft-schema.json @@ -0,0 +1,902 @@ +{ + "schema": { + "$ref": "#/definitions/ThemeDraftDocument", + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "ThemeDraftDocument": { + "additionalProperties": false, + "properties": { + "appearance": { + "default": "auto", + "enum": [ + "auto", + "light", + "dark" + ], + "type": "string" + }, + "assets": { + "additionalProperties": false, + "properties": { + "background": { + "type": "string" + }, + "decorations": { + "additionalProperties": { + "type": "string" + }, + "propertyNames": { + "maxLength": 40, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$" + }, + "type": "object" + }, + "fonts": { + "additionalProperties": { + "type": "string" + }, + "propertyNames": { + "maxLength": 40, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$" + }, + "type": "object" + }, + "portrait": { + "type": "string" + }, + "profileAvatar": { + "type": "string" + }, + "projectIcons": { + "items": { + "type": "string" + }, + "maxItems": 12, + "minItems": 1, + "type": "array" + }, + "suggestionIcons": { + "additionalProperties": false, + "properties": { + "card1": { + "type": "string" + }, + "card2": { + "type": "string" + }, + "card3": { + "type": "string" + }, + "card4": { + "type": "string" + } + }, + "type": "object" + } + }, + "type": "object" + }, + "author": { + "maxLength": 80, + "minLength": 1, + "type": "string" + }, + "background": { + "additionalProperties": false, + "properties": { + "blur": { + "maximum": 30, + "minimum": 0, + "type": "number" + }, + "brightness": { + "maximum": 1.5, + "minimum": 0.3, + "type": "number" + }, + "overlay": { + "maximum": 0.9, + "minimum": 0, + "type": "number" + }, + "positionX": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "positionY": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "safeArea": { + "default": "auto", + "enum": [ + "auto", + "left", + "center", + "right", + "none" + ], + "type": "string" + }, + "scale": { + "maximum": 3, + "minimum": 0.5, + "type": "number" + }, + "taskMode": { + "default": "full", + "enum": [ + "auto", + "full", + "ambient", + "banner", + "off" + ], + "type": "string" + }, + "taskOpacity": { + "default": 0.82, + "maximum": 1, + "minimum": 0, + "type": "number" + } + }, + "required": [ + "positionX", + "positionY", + "scale", + "blur", + "brightness", + "overlay" + ], + "type": "object" + }, + "colors": { + "additionalProperties": false, + "properties": { + "accent": { + "type": "string" + }, + "border": { + "type": "string" + }, + "codeText": { + "type": "string" + }, + "danger": { + "type": "string" + }, + "info": { + "type": "string" + }, + "inputText": { + "type": "string" + }, + "link": { + "type": "string" + }, + "muted": { + "type": "string" + }, + "panel": { + "type": "string" + }, + "placeholder": { + "type": "string" + }, + "secondary": { + "type": "string" + }, + "success": { + "type": "string" + }, + "text": { + "type": "string" + }, + "textSecondary": { + "type": "string" + }, + "warning": { + "type": "string" + } + }, + "required": [ + "accent", + "secondary", + "text", + "muted", + "panel", + "border", + "success", + "warning", + "danger", + "info", + "textSecondary", + "link", + "inputText", + "placeholder", + "codeText" + ], + "type": "object" + }, + "composition": { + "additionalProperties": false, + "properties": { + "layers": { + "items": { + "additionalProperties": false, + "properties": { + "anchor": { + "enum": [ + "top-left", + "top-center", + "top-right", + "center-left", + "center", + "center-right", + "bottom-left", + "bottom-center", + "bottom-right" + ], + "type": "string" + }, + "asset": { + "anyOf": [ + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "portrait", + "type": "string" + } + }, + "required": [ + "kind" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "assetKey": { + "maxLength": 40, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "type": "string" + }, + "kind": { + "const": "decoration", + "type": "string" + } + }, + "required": [ + "kind", + "assetKey" + ], + "type": "object" + } + ] + }, + "id": { + "maxLength": 40, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "type": "string" + }, + "opacity": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "positionX": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "positionY": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "required": { + "type": "boolean" + }, + "rotation": { + "maximum": 180, + "minimum": -180, + "type": "number" + }, + "surface": { + "enum": [ + "viewport", + "main", + "home-hero", + "suggestions" + ], + "type": "string" + }, + "width": { + "maximum": 1.5, + "minimum": 0.02, + "type": "number" + } + }, + "required": [ + "id", + "asset", + "surface", + "anchor", + "positionX", + "positionY", + "width", + "opacity", + "rotation", + "required" + ], + "type": "object" + }, + "maxItems": 24, + "type": "array" + } + }, + "required": [ + "layers" + ], + "type": "object" + }, + "decorations": { + "items": { + "additionalProperties": false, + "properties": { + "assetKey": { + "maxLength": 40, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "intensity": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "opacity": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "placement": { + "enum": [ + "background", + "corners", + "hero", + "cards" + ], + "type": "string" + }, + "scale": { + "maximum": 3, + "minimum": 0.25, + "type": "number" + }, + "type": { + "enum": [ + "particles", + "ribbon", + "butterflies", + "polaroid", + "badge", + "sparkles", + "image" + ], + "type": "string" + } + }, + "required": [ + "type", + "enabled", + "intensity" + ], + "type": "object" + }, + "maxItems": 16, + "type": "array" + }, + "description": { + "maxLength": 240, + "minLength": 1, + "type": "string" + }, + "home": { + "additionalProperties": false, + "properties": { + "welcome": { + "additionalProperties": false, + "properties": { + "layout": { + "additionalProperties": false, + "properties": { + "anchor": { + "enum": [ + "top-left", + "top-center", + "top-right", + "center-left", + "center", + "center-right", + "bottom-left", + "bottom-center", + "bottom-right" + ], + "type": "string" + }, + "hideNativeIcon": { + "default": false, + "type": "boolean" + }, + "positionX": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "positionY": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "textAlign": { + "enum": [ + "left", + "center", + "right" + ], + "type": "string" + }, + "width": { + "maximum": 1, + "minimum": 0.2, + "type": "number" + } + }, + "required": [ + "anchor", + "positionX", + "positionY", + "width", + "textAlign" + ], + "type": "object" + }, + "localized": { + "additionalProperties": { + "additionalProperties": false, + "properties": { + "lines": { + "items": { + "type": "string" + }, + "maxItems": 3, + "minItems": 1, + "type": "array" + } + }, + "required": [ + "lines" + ], + "type": "object" + }, + "propertyNames": { + "enum": [ + "zh-CN", + "en" + ] + }, + "type": "object" + } + }, + "required": [ + "localized" + ], + "type": "object" + } + }, + "required": [ + "welcome" + ], + "type": "object" + }, + "id": { + "maxLength": 80, + "minLength": 3, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "type": "string" + }, + "interfaceImages": { + "additionalProperties": false, + "properties": { + "profileAvatarSize": { + "maximum": 48, + "minimum": 16, + "type": "integer" + }, + "projectIconSize": { + "maximum": 32, + "minimum": 12, + "type": "integer" + }, + "suggestionIconSize": { + "maximum": 64, + "minimum": 16, + "type": "integer" + } + }, + "required": [ + "profileAvatarSize", + "suggestionIconSize", + "projectIconSize" + ], + "type": "object" + }, + "kind": { + "enum": [ + "theme", + "recipe" + ], + "type": "string" + }, + "layout": { + "additionalProperties": false, + "properties": { + "cardColumns": { + "maximum": 4, + "minimum": 2, + "type": "integer" + }, + "composerWidth": { + "maximum": 1, + "minimum": 0.5, + "type": "number" + }, + "heroHeight": { + "maximum": 560, + "minimum": 180, + "type": "integer" + }, + "moduleGap": { + "maximum": 48, + "minimum": 0, + "type": "integer" + }, + "modules": { + "items": { + "additionalProperties": false, + "properties": { + "align": { + "enum": [ + "start", + "center", + "end", + "stretch" + ], + "type": "string" + }, + "id": { + "enum": [ + "sidebar", + "topbar", + "hero", + "suggestions", + "project-picker", + "composer", + "task-background", + "content-layer" + ], + "type": "string" + }, + "order": { + "maximum": 7, + "minimum": 0, + "type": "integer" + }, + "size": { + "enum": [ + "compact", + "regular", + "expanded" + ], + "type": "string" + }, + "spacing": { + "maximum": 48, + "minimum": 0, + "type": "integer" + }, + "visible": { + "type": "boolean" + } + }, + "required": [ + "id", + "order", + "visible", + "size", + "align", + "spacing" + ], + "type": "object" + }, + "maxItems": 8, + "minItems": 8, + "type": "array" + }, + "sidebarDensity": { + "enum": [ + "compact", + "comfortable" + ], + "type": "string" + } + }, + "required": [ + "heroHeight", + "cardColumns", + "composerWidth", + "sidebarDensity", + "moduleGap", + "modules" + ], + "type": "object" + }, + "metadata": { + "additionalProperties": false, + "properties": { + "homepage": { + "format": "uri", + "maxLength": 500, + "type": "string" + }, + "localized": { + "additionalProperties": { + "additionalProperties": false, + "properties": { + "description": { + "maxLength": 240, + "minLength": 1, + "type": "string" + }, + "name": { + "maxLength": 80, + "minLength": 1, + "type": "string" + } + }, + "type": "object" + }, + "propertyNames": { + "enum": [ + "zh-CN", + "en" + ] + }, + "type": "object" + } + }, + "type": "object" + }, + "name": { + "maxLength": 80, + "minLength": 1, + "type": "string" + }, + "rights": { + "additionalProperties": false, + "properties": { + "attribution": { + "maxLength": 240, + "minLength": 1, + "type": "string" + }, + "licenseId": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "localOnly": { + "type": "boolean" + }, + "source": { + "format": "uri", + "maxLength": 500, + "type": "string" + } + }, + "required": [ + "licenseId", + "localOnly" + ], + "type": "object" + }, + "schemaVersion": { + "const": 4, + "type": "number" + }, + "surfaces": { + "additionalProperties": false, + "default": { + "baseOpacity": 0.68, + "blur": 0, + "elevatedOpacity": 0.92, + "terminalOpacity": 0.82 + }, + "properties": { + "baseOpacity": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "blur": { + "maximum": 30, + "minimum": 0, + "type": "number" + }, + "elevatedOpacity": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "terminalOpacity": { + "maximum": 1, + "minimum": 0, + "type": "number" + } + }, + "required": [ + "baseOpacity", + "elevatedOpacity", + "terminalOpacity", + "blur" + ], + "type": "object" + }, + "typography": { + "additionalProperties": false, + "properties": { + "codeFamily": { + "maxLength": 120, + "minLength": 1, + "type": "string" + }, + "codeFontAssetKey": { + "maxLength": 40, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "type": "string" + }, + "codeSize": { + "maximum": 22, + "minimum": 11, + "type": "number" + }, + "codeWeight": { + "enum": [ + 400, + 500, + 600, + 700 + ], + "type": "number" + }, + "displayFamily": { + "maxLength": 120, + "minLength": 1, + "type": "string" + }, + "displayFontAssetKey": { + "maxLength": 40, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "type": "string" + }, + "displayLetterSpacing": { + "maximum": 0.2, + "minimum": -0.05, + "type": "number" + }, + "displayLineHeight": { + "maximum": 1.8, + "minimum": 1.1, + "type": "number" + }, + "displaySize": { + "maximum": 72, + "minimum": 20, + "type": "number" + }, + "displayWeight": { + "enum": [ + 400, + 500, + 600, + 700 + ], + "type": "number" + }, + "lineHeight": { + "maximum": 1.8, + "minimum": 1.2, + "type": "number" + }, + "scale": { + "maximum": 1.3, + "minimum": 0.85, + "type": "number" + }, + "uiFamily": { + "maxLength": 120, + "minLength": 1, + "type": "string" + }, + "uiFontAssetKey": { + "maxLength": 40, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "type": "string" + }, + "uiSize": { + "maximum": 22, + "minimum": 12, + "type": "number" + }, + "uiWeight": { + "enum": [ + 400, + 500, + 600, + 700 + ], + "type": "number" + } + }, + "required": [ + "uiFamily", + "codeFamily", + "scale", + "uiSize", + "codeSize", + "uiWeight", + "codeWeight", + "lineHeight", + "displayFamily", + "displaySize", + "displayWeight", + "displayLineHeight", + "displayLetterSpacing" + ], + "type": "object" + }, + "version": { + "pattern": "^(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)$", + "type": "string" + } + }, + "required": [ + "schemaVersion", + "kind", + "id", + "name", + "version", + "author", + "assets", + "colors", + "typography", + "background", + "decorations", + "layout", + "rights", + "interfaceImages", + "composition" + ], + "type": "object" + } + } + }, + "schemaVersion": 4 +} diff --git a/contracts/theme/v4/migrations.json b/contracts/theme/v4/migrations.json new file mode 100644 index 0000000..cfd2f45 --- /dev/null +++ b/contracts/theme/v4/migrations.json @@ -0,0 +1,2696 @@ +{ + "defaults": { + "appearance": "auto", + "background": { + "safeArea": "auto", + "taskMode": "full", + "taskOpacity": 0.82 + }, + "composition": { + "layers": [] + }, + "interfaceImages": { + "profileAvatarSize": 24, + "projectIconSize": 16, + "suggestionIconSize": 20 + }, + "surfaces": { + "baseOpacity": 0.68, + "blur": 0, + "elevatedOpacity": 0.92, + "terminalOpacity": 0.82 + } + }, + "sources": [ + { + "addsSemanticColors": true, + "addsV4Defaults": true, + "version": 1 + }, + { + "addsSemanticColors": false, + "addsV4Defaults": true, + "version": 2 + }, + { + "addsSemanticColors": false, + "addsV4Defaults": true, + "version": 3 + }, + { + "addsSemanticColors": false, + "addsV4Defaults": true, + "version": 4 + } + ], + "sourceSchemas": { + "v1": { + "$ref": "#/definitions/ThemeDocumentV1", + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "ThemeDocumentV1": { + "additionalProperties": false, + "properties": { + "appearance": { + "default": "auto", + "enum": [ + "auto", + "light", + "dark" + ], + "type": "string" + }, + "assets": { + "additionalProperties": false, + "properties": { + "background": { + "type": "string" + }, + "decorations": { + "additionalProperties": { + "type": "string" + }, + "propertyNames": { + "maxLength": 40, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$" + }, + "type": "object" + }, + "fonts": { + "additionalProperties": { + "type": "string" + }, + "propertyNames": { + "maxLength": 40, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$" + }, + "type": "object" + }, + "portrait": { + "type": "string" + } + }, + "type": "object" + }, + "author": { + "maxLength": 80, + "minLength": 1, + "type": "string" + }, + "background": { + "additionalProperties": false, + "properties": { + "blur": { + "maximum": 30, + "minimum": 0, + "type": "number" + }, + "brightness": { + "maximum": 1.5, + "minimum": 0.3, + "type": "number" + }, + "overlay": { + "maximum": 0.9, + "minimum": 0, + "type": "number" + }, + "positionX": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "positionY": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "safeArea": { + "default": "auto", + "enum": [ + "auto", + "left", + "center", + "right", + "none" + ], + "type": "string" + }, + "scale": { + "maximum": 3, + "minimum": 0.5, + "type": "number" + }, + "taskMode": { + "default": "full", + "enum": [ + "auto", + "full", + "ambient", + "banner", + "off" + ], + "type": "string" + }, + "taskOpacity": { + "default": 0.82, + "maximum": 1, + "minimum": 0, + "type": "number" + } + }, + "required": [ + "positionX", + "positionY", + "scale", + "blur", + "brightness", + "overlay" + ], + "type": "object" + }, + "colors": { + "additionalProperties": false, + "properties": { + "accent": { + "type": "string" + }, + "border": { + "type": "string" + }, + "danger": { + "type": "string" + }, + "info": { + "type": "string" + }, + "muted": { + "type": "string" + }, + "panel": { + "type": "string" + }, + "secondary": { + "type": "string" + }, + "success": { + "type": "string" + }, + "text": { + "type": "string" + }, + "warning": { + "type": "string" + } + }, + "required": [ + "accent", + "secondary", + "text", + "muted", + "panel", + "border", + "success", + "warning", + "danger", + "info" + ], + "type": "object" + }, + "decorations": { + "items": { + "additionalProperties": false, + "properties": { + "assetKey": { + "maxLength": 40, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "intensity": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "opacity": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "placement": { + "enum": [ + "background", + "corners", + "hero", + "cards" + ], + "type": "string" + }, + "scale": { + "maximum": 3, + "minimum": 0.25, + "type": "number" + }, + "type": { + "enum": [ + "particles", + "ribbon", + "butterflies", + "polaroid", + "badge", + "sparkles", + "image" + ], + "type": "string" + } + }, + "required": [ + "type", + "enabled", + "intensity" + ], + "type": "object" + }, + "maxItems": 16, + "type": "array" + }, + "description": { + "maxLength": 240, + "minLength": 1, + "type": "string" + }, + "id": { + "maxLength": 80, + "minLength": 3, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "type": "string" + }, + "kind": { + "enum": [ + "theme", + "recipe" + ], + "type": "string" + }, + "layout": { + "additionalProperties": false, + "properties": { + "cardColumns": { + "maximum": 4, + "minimum": 2, + "type": "integer" + }, + "composerWidth": { + "maximum": 1, + "minimum": 0.5, + "type": "number" + }, + "heroHeight": { + "maximum": 560, + "minimum": 180, + "type": "integer" + }, + "moduleGap": { + "maximum": 48, + "minimum": 0, + "type": "integer" + }, + "modules": { + "items": { + "additionalProperties": false, + "properties": { + "align": { + "enum": [ + "start", + "center", + "end", + "stretch" + ], + "type": "string" + }, + "id": { + "enum": [ + "sidebar", + "topbar", + "hero", + "suggestions", + "project-picker", + "composer", + "task-background", + "content-layer" + ], + "type": "string" + }, + "order": { + "maximum": 7, + "minimum": 0, + "type": "integer" + }, + "size": { + "enum": [ + "compact", + "regular", + "expanded" + ], + "type": "string" + }, + "spacing": { + "maximum": 48, + "minimum": 0, + "type": "integer" + }, + "visible": { + "type": "boolean" + } + }, + "required": [ + "id", + "order", + "visible", + "size", + "align", + "spacing" + ], + "type": "object" + }, + "maxItems": 8, + "minItems": 8, + "type": "array" + }, + "sidebarDensity": { + "enum": [ + "compact", + "comfortable" + ], + "type": "string" + } + }, + "required": [ + "heroHeight", + "cardColumns", + "composerWidth", + "sidebarDensity", + "moduleGap", + "modules" + ], + "type": "object" + }, + "metadata": { + "additionalProperties": false, + "properties": { + "homepage": { + "format": "uri", + "maxLength": 500, + "type": "string" + }, + "localized": { + "additionalProperties": { + "additionalProperties": false, + "properties": { + "description": { + "maxLength": 240, + "minLength": 1, + "type": "string" + }, + "name": { + "maxLength": 80, + "minLength": 1, + "type": "string" + } + }, + "type": "object" + }, + "propertyNames": { + "enum": [ + "zh-CN", + "en" + ] + }, + "type": "object" + } + }, + "type": "object" + }, + "name": { + "maxLength": 80, + "minLength": 1, + "type": "string" + }, + "rights": { + "additionalProperties": false, + "properties": { + "attribution": { + "maxLength": 240, + "minLength": 1, + "type": "string" + }, + "licenseId": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "localOnly": { + "type": "boolean" + }, + "source": { + "format": "uri", + "maxLength": 500, + "type": "string" + } + }, + "required": [ + "licenseId", + "localOnly" + ], + "type": "object" + }, + "schemaVersion": { + "const": 1, + "type": "number" + }, + "surfaces": { + "additionalProperties": false, + "default": { + "baseOpacity": 0.68, + "blur": 0, + "elevatedOpacity": 0.92, + "terminalOpacity": 0.82 + }, + "properties": { + "baseOpacity": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "blur": { + "maximum": 30, + "minimum": 0, + "type": "number" + }, + "elevatedOpacity": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "terminalOpacity": { + "maximum": 1, + "minimum": 0, + "type": "number" + } + }, + "required": [ + "baseOpacity", + "elevatedOpacity", + "terminalOpacity", + "blur" + ], + "type": "object" + }, + "typography": { + "additionalProperties": false, + "properties": { + "codeFamily": { + "maxLength": 120, + "minLength": 1, + "type": "string" + }, + "codeFontAssetKey": { + "maxLength": 40, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "type": "string" + }, + "codeSize": { + "maximum": 22, + "minimum": 11, + "type": "number" + }, + "codeWeight": { + "enum": [ + 400, + 500, + 600, + 700 + ], + "type": "number" + }, + "lineHeight": { + "maximum": 1.8, + "minimum": 1.2, + "type": "number" + }, + "scale": { + "maximum": 1.3, + "minimum": 0.85, + "type": "number" + }, + "uiFamily": { + "maxLength": 120, + "minLength": 1, + "type": "string" + }, + "uiFontAssetKey": { + "maxLength": 40, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "type": "string" + }, + "uiSize": { + "maximum": 22, + "minimum": 12, + "type": "number" + }, + "uiWeight": { + "enum": [ + 400, + 500, + 600, + 700 + ], + "type": "number" + } + }, + "required": [ + "uiFamily", + "codeFamily", + "scale", + "uiSize", + "codeSize", + "uiWeight", + "codeWeight", + "lineHeight" + ], + "type": "object" + }, + "version": { + "pattern": "^(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)$", + "type": "string" + } + }, + "required": [ + "schemaVersion", + "kind", + "id", + "name", + "version", + "author", + "assets", + "colors", + "typography", + "background", + "decorations", + "layout", + "rights" + ], + "type": "object" + } + } + }, + "v2": { + "$ref": "#/definitions/ThemeDocumentV2", + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "ThemeDocumentV2": { + "additionalProperties": false, + "properties": { + "appearance": { + "default": "auto", + "enum": [ + "auto", + "light", + "dark" + ], + "type": "string" + }, + "assets": { + "additionalProperties": false, + "properties": { + "background": { + "type": "string" + }, + "decorations": { + "additionalProperties": { + "type": "string" + }, + "propertyNames": { + "maxLength": 40, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$" + }, + "type": "object" + }, + "fonts": { + "additionalProperties": { + "type": "string" + }, + "propertyNames": { + "maxLength": 40, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$" + }, + "type": "object" + }, + "portrait": { + "type": "string" + } + }, + "type": "object" + }, + "author": { + "maxLength": 80, + "minLength": 1, + "type": "string" + }, + "background": { + "additionalProperties": false, + "properties": { + "blur": { + "maximum": 30, + "minimum": 0, + "type": "number" + }, + "brightness": { + "maximum": 1.5, + "minimum": 0.3, + "type": "number" + }, + "overlay": { + "maximum": 0.9, + "minimum": 0, + "type": "number" + }, + "positionX": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "positionY": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "safeArea": { + "default": "auto", + "enum": [ + "auto", + "left", + "center", + "right", + "none" + ], + "type": "string" + }, + "scale": { + "maximum": 3, + "minimum": 0.5, + "type": "number" + }, + "taskMode": { + "default": "full", + "enum": [ + "auto", + "full", + "ambient", + "banner", + "off" + ], + "type": "string" + }, + "taskOpacity": { + "default": 0.82, + "maximum": 1, + "minimum": 0, + "type": "number" + } + }, + "required": [ + "positionX", + "positionY", + "scale", + "blur", + "brightness", + "overlay" + ], + "type": "object" + }, + "colors": { + "additionalProperties": false, + "properties": { + "accent": { + "type": "string" + }, + "border": { + "type": "string" + }, + "codeText": { + "type": "string" + }, + "danger": { + "type": "string" + }, + "info": { + "type": "string" + }, + "inputText": { + "type": "string" + }, + "link": { + "type": "string" + }, + "muted": { + "type": "string" + }, + "panel": { + "type": "string" + }, + "placeholder": { + "type": "string" + }, + "secondary": { + "type": "string" + }, + "success": { + "type": "string" + }, + "text": { + "type": "string" + }, + "textSecondary": { + "type": "string" + }, + "warning": { + "type": "string" + } + }, + "required": [ + "accent", + "secondary", + "text", + "muted", + "panel", + "border", + "success", + "warning", + "danger", + "info", + "textSecondary", + "link", + "inputText", + "placeholder", + "codeText" + ], + "type": "object" + }, + "decorations": { + "items": { + "additionalProperties": false, + "properties": { + "assetKey": { + "maxLength": 40, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "intensity": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "opacity": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "placement": { + "enum": [ + "background", + "corners", + "hero", + "cards" + ], + "type": "string" + }, + "scale": { + "maximum": 3, + "minimum": 0.25, + "type": "number" + }, + "type": { + "enum": [ + "particles", + "ribbon", + "butterflies", + "polaroid", + "badge", + "sparkles", + "image" + ], + "type": "string" + } + }, + "required": [ + "type", + "enabled", + "intensity" + ], + "type": "object" + }, + "maxItems": 16, + "type": "array" + }, + "description": { + "maxLength": 240, + "minLength": 1, + "type": "string" + }, + "id": { + "maxLength": 80, + "minLength": 3, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "type": "string" + }, + "kind": { + "enum": [ + "theme", + "recipe" + ], + "type": "string" + }, + "layout": { + "additionalProperties": false, + "properties": { + "cardColumns": { + "maximum": 4, + "minimum": 2, + "type": "integer" + }, + "composerWidth": { + "maximum": 1, + "minimum": 0.5, + "type": "number" + }, + "heroHeight": { + "maximum": 560, + "minimum": 180, + "type": "integer" + }, + "moduleGap": { + "maximum": 48, + "minimum": 0, + "type": "integer" + }, + "modules": { + "items": { + "additionalProperties": false, + "properties": { + "align": { + "enum": [ + "start", + "center", + "end", + "stretch" + ], + "type": "string" + }, + "id": { + "enum": [ + "sidebar", + "topbar", + "hero", + "suggestions", + "project-picker", + "composer", + "task-background", + "content-layer" + ], + "type": "string" + }, + "order": { + "maximum": 7, + "minimum": 0, + "type": "integer" + }, + "size": { + "enum": [ + "compact", + "regular", + "expanded" + ], + "type": "string" + }, + "spacing": { + "maximum": 48, + "minimum": 0, + "type": "integer" + }, + "visible": { + "type": "boolean" + } + }, + "required": [ + "id", + "order", + "visible", + "size", + "align", + "spacing" + ], + "type": "object" + }, + "maxItems": 8, + "minItems": 8, + "type": "array" + }, + "sidebarDensity": { + "enum": [ + "compact", + "comfortable" + ], + "type": "string" + } + }, + "required": [ + "heroHeight", + "cardColumns", + "composerWidth", + "sidebarDensity", + "moduleGap", + "modules" + ], + "type": "object" + }, + "metadata": { + "additionalProperties": false, + "properties": { + "homepage": { + "format": "uri", + "maxLength": 500, + "type": "string" + }, + "localized": { + "additionalProperties": { + "additionalProperties": false, + "properties": { + "description": { + "maxLength": 240, + "minLength": 1, + "type": "string" + }, + "name": { + "maxLength": 80, + "minLength": 1, + "type": "string" + } + }, + "type": "object" + }, + "propertyNames": { + "enum": [ + "zh-CN", + "en" + ] + }, + "type": "object" + } + }, + "type": "object" + }, + "name": { + "maxLength": 80, + "minLength": 1, + "type": "string" + }, + "rights": { + "additionalProperties": false, + "properties": { + "attribution": { + "maxLength": 240, + "minLength": 1, + "type": "string" + }, + "licenseId": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "localOnly": { + "type": "boolean" + }, + "source": { + "format": "uri", + "maxLength": 500, + "type": "string" + } + }, + "required": [ + "licenseId", + "localOnly" + ], + "type": "object" + }, + "schemaVersion": { + "const": 2, + "type": "number" + }, + "surfaces": { + "additionalProperties": false, + "default": { + "baseOpacity": 0.68, + "blur": 0, + "elevatedOpacity": 0.92, + "terminalOpacity": 0.82 + }, + "properties": { + "baseOpacity": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "blur": { + "maximum": 30, + "minimum": 0, + "type": "number" + }, + "elevatedOpacity": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "terminalOpacity": { + "maximum": 1, + "minimum": 0, + "type": "number" + } + }, + "required": [ + "baseOpacity", + "elevatedOpacity", + "terminalOpacity", + "blur" + ], + "type": "object" + }, + "typography": { + "additionalProperties": false, + "properties": { + "codeFamily": { + "maxLength": 120, + "minLength": 1, + "type": "string" + }, + "codeFontAssetKey": { + "maxLength": 40, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "type": "string" + }, + "codeSize": { + "maximum": 22, + "minimum": 11, + "type": "number" + }, + "codeWeight": { + "enum": [ + 400, + 500, + 600, + 700 + ], + "type": "number" + }, + "lineHeight": { + "maximum": 1.8, + "minimum": 1.2, + "type": "number" + }, + "scale": { + "maximum": 1.3, + "minimum": 0.85, + "type": "number" + }, + "uiFamily": { + "maxLength": 120, + "minLength": 1, + "type": "string" + }, + "uiFontAssetKey": { + "maxLength": 40, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "type": "string" + }, + "uiSize": { + "maximum": 22, + "minimum": 12, + "type": "number" + }, + "uiWeight": { + "enum": [ + 400, + 500, + 600, + 700 + ], + "type": "number" + } + }, + "required": [ + "uiFamily", + "codeFamily", + "scale", + "uiSize", + "codeSize", + "uiWeight", + "codeWeight", + "lineHeight" + ], + "type": "object" + }, + "version": { + "pattern": "^(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)$", + "type": "string" + } + }, + "required": [ + "schemaVersion", + "kind", + "id", + "name", + "version", + "author", + "assets", + "colors", + "typography", + "background", + "decorations", + "layout", + "rights" + ], + "type": "object" + } + } + }, + "v3": { + "$ref": "#/definitions/ThemeDocumentV3", + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "ThemeDocumentV3": { + "additionalProperties": false, + "properties": { + "appearance": { + "default": "auto", + "enum": [ + "auto", + "light", + "dark" + ], + "type": "string" + }, + "assets": { + "additionalProperties": false, + "properties": { + "background": { + "type": "string" + }, + "decorations": { + "additionalProperties": { + "type": "string" + }, + "propertyNames": { + "maxLength": 40, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$" + }, + "type": "object" + }, + "fonts": { + "additionalProperties": { + "type": "string" + }, + "propertyNames": { + "maxLength": 40, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$" + }, + "type": "object" + }, + "portrait": { + "type": "string" + }, + "profileAvatar": { + "type": "string" + }, + "projectIcons": { + "items": { + "type": "string" + }, + "maxItems": 12, + "minItems": 1, + "type": "array" + }, + "suggestionIcons": { + "additionalProperties": false, + "properties": { + "card1": { + "type": "string" + }, + "card2": { + "type": "string" + }, + "card3": { + "type": "string" + }, + "card4": { + "type": "string" + } + }, + "type": "object" + } + }, + "type": "object" + }, + "author": { + "maxLength": 80, + "minLength": 1, + "type": "string" + }, + "background": { + "additionalProperties": false, + "properties": { + "blur": { + "maximum": 30, + "minimum": 0, + "type": "number" + }, + "brightness": { + "maximum": 1.5, + "minimum": 0.3, + "type": "number" + }, + "overlay": { + "maximum": 0.9, + "minimum": 0, + "type": "number" + }, + "positionX": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "positionY": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "safeArea": { + "default": "auto", + "enum": [ + "auto", + "left", + "center", + "right", + "none" + ], + "type": "string" + }, + "scale": { + "maximum": 3, + "minimum": 0.5, + "type": "number" + }, + "taskMode": { + "default": "full", + "enum": [ + "auto", + "full", + "ambient", + "banner", + "off" + ], + "type": "string" + }, + "taskOpacity": { + "default": 0.82, + "maximum": 1, + "minimum": 0, + "type": "number" + } + }, + "required": [ + "positionX", + "positionY", + "scale", + "blur", + "brightness", + "overlay" + ], + "type": "object" + }, + "colors": { + "additionalProperties": false, + "properties": { + "accent": { + "type": "string" + }, + "border": { + "type": "string" + }, + "codeText": { + "type": "string" + }, + "danger": { + "type": "string" + }, + "info": { + "type": "string" + }, + "inputText": { + "type": "string" + }, + "link": { + "type": "string" + }, + "muted": { + "type": "string" + }, + "panel": { + "type": "string" + }, + "placeholder": { + "type": "string" + }, + "secondary": { + "type": "string" + }, + "success": { + "type": "string" + }, + "text": { + "type": "string" + }, + "textSecondary": { + "type": "string" + }, + "warning": { + "type": "string" + } + }, + "required": [ + "accent", + "secondary", + "text", + "muted", + "panel", + "border", + "success", + "warning", + "danger", + "info", + "textSecondary", + "link", + "inputText", + "placeholder", + "codeText" + ], + "type": "object" + }, + "decorations": { + "items": { + "additionalProperties": false, + "properties": { + "assetKey": { + "maxLength": 40, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "intensity": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "opacity": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "placement": { + "enum": [ + "background", + "corners", + "hero", + "cards" + ], + "type": "string" + }, + "scale": { + "maximum": 3, + "minimum": 0.25, + "type": "number" + }, + "type": { + "enum": [ + "particles", + "ribbon", + "butterflies", + "polaroid", + "badge", + "sparkles", + "image" + ], + "type": "string" + } + }, + "required": [ + "type", + "enabled", + "intensity" + ], + "type": "object" + }, + "maxItems": 16, + "type": "array" + }, + "description": { + "maxLength": 240, + "minLength": 1, + "type": "string" + }, + "id": { + "maxLength": 80, + "minLength": 3, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "type": "string" + }, + "kind": { + "enum": [ + "theme", + "recipe" + ], + "type": "string" + }, + "layout": { + "additionalProperties": false, + "properties": { + "cardColumns": { + "maximum": 4, + "minimum": 2, + "type": "integer" + }, + "composerWidth": { + "maximum": 1, + "minimum": 0.5, + "type": "number" + }, + "heroHeight": { + "maximum": 560, + "minimum": 180, + "type": "integer" + }, + "moduleGap": { + "maximum": 48, + "minimum": 0, + "type": "integer" + }, + "modules": { + "items": { + "additionalProperties": false, + "properties": { + "align": { + "enum": [ + "start", + "center", + "end", + "stretch" + ], + "type": "string" + }, + "id": { + "enum": [ + "sidebar", + "topbar", + "hero", + "suggestions", + "project-picker", + "composer", + "task-background", + "content-layer" + ], + "type": "string" + }, + "order": { + "maximum": 7, + "minimum": 0, + "type": "integer" + }, + "size": { + "enum": [ + "compact", + "regular", + "expanded" + ], + "type": "string" + }, + "spacing": { + "maximum": 48, + "minimum": 0, + "type": "integer" + }, + "visible": { + "type": "boolean" + } + }, + "required": [ + "id", + "order", + "visible", + "size", + "align", + "spacing" + ], + "type": "object" + }, + "maxItems": 8, + "minItems": 8, + "type": "array" + }, + "sidebarDensity": { + "enum": [ + "compact", + "comfortable" + ], + "type": "string" + } + }, + "required": [ + "heroHeight", + "cardColumns", + "composerWidth", + "sidebarDensity", + "moduleGap", + "modules" + ], + "type": "object" + }, + "metadata": { + "additionalProperties": false, + "properties": { + "homepage": { + "format": "uri", + "maxLength": 500, + "type": "string" + }, + "localized": { + "additionalProperties": { + "additionalProperties": false, + "properties": { + "description": { + "maxLength": 240, + "minLength": 1, + "type": "string" + }, + "name": { + "maxLength": 80, + "minLength": 1, + "type": "string" + } + }, + "type": "object" + }, + "propertyNames": { + "enum": [ + "zh-CN", + "en" + ] + }, + "type": "object" + } + }, + "type": "object" + }, + "name": { + "maxLength": 80, + "minLength": 1, + "type": "string" + }, + "rights": { + "additionalProperties": false, + "properties": { + "attribution": { + "maxLength": 240, + "minLength": 1, + "type": "string" + }, + "licenseId": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "localOnly": { + "type": "boolean" + }, + "source": { + "format": "uri", + "maxLength": 500, + "type": "string" + } + }, + "required": [ + "licenseId", + "localOnly" + ], + "type": "object" + }, + "schemaVersion": { + "const": 3, + "type": "number" + }, + "surfaces": { + "additionalProperties": false, + "default": { + "baseOpacity": 0.68, + "blur": 0, + "elevatedOpacity": 0.92, + "terminalOpacity": 0.82 + }, + "properties": { + "baseOpacity": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "blur": { + "maximum": 30, + "minimum": 0, + "type": "number" + }, + "elevatedOpacity": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "terminalOpacity": { + "maximum": 1, + "minimum": 0, + "type": "number" + } + }, + "required": [ + "baseOpacity", + "elevatedOpacity", + "terminalOpacity", + "blur" + ], + "type": "object" + }, + "typography": { + "additionalProperties": false, + "properties": { + "codeFamily": { + "maxLength": 120, + "minLength": 1, + "type": "string" + }, + "codeFontAssetKey": { + "maxLength": 40, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "type": "string" + }, + "codeSize": { + "maximum": 22, + "minimum": 11, + "type": "number" + }, + "codeWeight": { + "enum": [ + 400, + 500, + 600, + 700 + ], + "type": "number" + }, + "lineHeight": { + "maximum": 1.8, + "minimum": 1.2, + "type": "number" + }, + "scale": { + "maximum": 1.3, + "minimum": 0.85, + "type": "number" + }, + "uiFamily": { + "maxLength": 120, + "minLength": 1, + "type": "string" + }, + "uiFontAssetKey": { + "maxLength": 40, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "type": "string" + }, + "uiSize": { + "maximum": 22, + "minimum": 12, + "type": "number" + }, + "uiWeight": { + "enum": [ + 400, + 500, + 600, + 700 + ], + "type": "number" + } + }, + "required": [ + "uiFamily", + "codeFamily", + "scale", + "uiSize", + "codeSize", + "uiWeight", + "codeWeight", + "lineHeight" + ], + "type": "object" + }, + "version": { + "pattern": "^(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)$", + "type": "string" + } + }, + "required": [ + "schemaVersion", + "kind", + "id", + "name", + "version", + "author", + "assets", + "colors", + "typography", + "background", + "decorations", + "layout", + "rights" + ], + "type": "object" + } + } + }, + "v4": { + "$ref": "#/definitions/ThemeDocumentV4Input", + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "ThemeDocumentV4Input": { + "additionalProperties": false, + "properties": { + "appearance": { + "default": "auto", + "enum": [ + "auto", + "light", + "dark" + ], + "type": "string" + }, + "assets": { + "additionalProperties": false, + "properties": { + "background": { + "type": "string" + }, + "decorations": { + "additionalProperties": { + "type": "string" + }, + "propertyNames": { + "maxLength": 40, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$" + }, + "type": "object" + }, + "fonts": { + "additionalProperties": { + "type": "string" + }, + "propertyNames": { + "maxLength": 40, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$" + }, + "type": "object" + }, + "portrait": { + "type": "string" + }, + "profileAvatar": { + "type": "string" + }, + "projectIcons": { + "items": { + "type": "string" + }, + "maxItems": 12, + "minItems": 1, + "type": "array" + }, + "suggestionIcons": { + "additionalProperties": false, + "properties": { + "card1": { + "type": "string" + }, + "card2": { + "type": "string" + }, + "card3": { + "type": "string" + }, + "card4": { + "type": "string" + } + }, + "type": "object" + } + }, + "type": "object" + }, + "author": { + "maxLength": 80, + "minLength": 1, + "type": "string" + }, + "background": { + "additionalProperties": false, + "properties": { + "blur": { + "maximum": 30, + "minimum": 0, + "type": "number" + }, + "brightness": { + "maximum": 1.5, + "minimum": 0.3, + "type": "number" + }, + "overlay": { + "maximum": 0.9, + "minimum": 0, + "type": "number" + }, + "positionX": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "positionY": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "safeArea": { + "default": "auto", + "enum": [ + "auto", + "left", + "center", + "right", + "none" + ], + "type": "string" + }, + "scale": { + "maximum": 3, + "minimum": 0.5, + "type": "number" + }, + "taskMode": { + "default": "full", + "enum": [ + "auto", + "full", + "ambient", + "banner", + "off" + ], + "type": "string" + }, + "taskOpacity": { + "default": 0.82, + "maximum": 1, + "minimum": 0, + "type": "number" + } + }, + "required": [ + "positionX", + "positionY", + "scale", + "blur", + "brightness", + "overlay" + ], + "type": "object" + }, + "colors": { + "additionalProperties": false, + "properties": { + "accent": { + "type": "string" + }, + "border": { + "type": "string" + }, + "codeText": { + "type": "string" + }, + "danger": { + "type": "string" + }, + "info": { + "type": "string" + }, + "inputText": { + "type": "string" + }, + "link": { + "type": "string" + }, + "muted": { + "type": "string" + }, + "panel": { + "type": "string" + }, + "placeholder": { + "type": "string" + }, + "secondary": { + "type": "string" + }, + "success": { + "type": "string" + }, + "text": { + "type": "string" + }, + "textSecondary": { + "type": "string" + }, + "warning": { + "type": "string" + } + }, + "required": [ + "accent", + "secondary", + "text", + "muted", + "panel", + "border", + "success", + "warning", + "danger", + "info", + "textSecondary", + "link", + "inputText", + "placeholder", + "codeText" + ], + "type": "object" + }, + "composition": { + "additionalProperties": false, + "properties": { + "layers": { + "items": { + "additionalProperties": false, + "properties": { + "anchor": { + "enum": [ + "top-left", + "top-center", + "top-right", + "center-left", + "center", + "center-right", + "bottom-left", + "bottom-center", + "bottom-right" + ], + "type": "string" + }, + "asset": { + "anyOf": [ + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "portrait", + "type": "string" + } + }, + "required": [ + "kind" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "assetKey": { + "maxLength": 40, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "type": "string" + }, + "kind": { + "const": "decoration", + "type": "string" + } + }, + "required": [ + "kind", + "assetKey" + ], + "type": "object" + } + ] + }, + "id": { + "maxLength": 40, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "type": "string" + }, + "opacity": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "positionX": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "positionY": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "required": { + "type": "boolean" + }, + "rotation": { + "maximum": 180, + "minimum": -180, + "type": "number" + }, + "surface": { + "enum": [ + "viewport", + "main", + "home-hero", + "suggestions" + ], + "type": "string" + }, + "width": { + "maximum": 1.5, + "minimum": 0.02, + "type": "number" + } + }, + "required": [ + "id", + "asset", + "surface", + "anchor", + "positionX", + "positionY", + "width", + "opacity", + "rotation", + "required" + ], + "type": "object" + }, + "maxItems": 24, + "type": "array" + } + }, + "required": [ + "layers" + ], + "type": "object" + }, + "decorations": { + "items": { + "additionalProperties": false, + "properties": { + "assetKey": { + "maxLength": 40, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "intensity": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "opacity": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "placement": { + "enum": [ + "background", + "corners", + "hero", + "cards" + ], + "type": "string" + }, + "scale": { + "maximum": 3, + "minimum": 0.25, + "type": "number" + }, + "type": { + "enum": [ + "particles", + "ribbon", + "butterflies", + "polaroid", + "badge", + "sparkles", + "image" + ], + "type": "string" + } + }, + "required": [ + "type", + "enabled", + "intensity" + ], + "type": "object" + }, + "maxItems": 16, + "type": "array" + }, + "description": { + "maxLength": 240, + "minLength": 1, + "type": "string" + }, + "home": { + "additionalProperties": false, + "properties": { + "welcome": { + "additionalProperties": false, + "properties": { + "layout": { + "additionalProperties": false, + "properties": { + "anchor": { + "enum": [ + "top-left", + "top-center", + "top-right", + "center-left", + "center", + "center-right", + "bottom-left", + "bottom-center", + "bottom-right" + ], + "type": "string" + }, + "hideNativeIcon": { + "default": false, + "type": "boolean" + }, + "positionX": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "positionY": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "textAlign": { + "enum": [ + "left", + "center", + "right" + ], + "type": "string" + }, + "width": { + "maximum": 1, + "minimum": 0.2, + "type": "number" + } + }, + "required": [ + "anchor", + "positionX", + "positionY", + "width", + "textAlign" + ], + "type": "object" + }, + "localized": { + "additionalProperties": { + "additionalProperties": false, + "properties": { + "lines": { + "items": { + "type": "string" + }, + "maxItems": 3, + "minItems": 1, + "type": "array" + } + }, + "required": [ + "lines" + ], + "type": "object" + }, + "propertyNames": { + "enum": [ + "zh-CN", + "en" + ] + }, + "type": "object" + } + }, + "required": [ + "localized" + ], + "type": "object" + } + }, + "required": [ + "welcome" + ], + "type": "object" + }, + "id": { + "maxLength": 80, + "minLength": 3, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "type": "string" + }, + "interfaceImages": { + "additionalProperties": false, + "properties": { + "profileAvatarSize": { + "maximum": 48, + "minimum": 16, + "type": "integer" + }, + "projectIconSize": { + "maximum": 32, + "minimum": 12, + "type": "integer" + }, + "suggestionIconSize": { + "maximum": 64, + "minimum": 16, + "type": "integer" + } + }, + "required": [ + "profileAvatarSize", + "suggestionIconSize", + "projectIconSize" + ], + "type": "object" + }, + "kind": { + "enum": [ + "theme", + "recipe" + ], + "type": "string" + }, + "layout": { + "additionalProperties": false, + "properties": { + "cardColumns": { + "maximum": 4, + "minimum": 2, + "type": "integer" + }, + "composerWidth": { + "maximum": 1, + "minimum": 0.5, + "type": "number" + }, + "heroHeight": { + "maximum": 560, + "minimum": 180, + "type": "integer" + }, + "moduleGap": { + "maximum": 48, + "minimum": 0, + "type": "integer" + }, + "modules": { + "items": { + "additionalProperties": false, + "properties": { + "align": { + "enum": [ + "start", + "center", + "end", + "stretch" + ], + "type": "string" + }, + "id": { + "enum": [ + "sidebar", + "topbar", + "hero", + "suggestions", + "project-picker", + "composer", + "task-background", + "content-layer" + ], + "type": "string" + }, + "order": { + "maximum": 7, + "minimum": 0, + "type": "integer" + }, + "size": { + "enum": [ + "compact", + "regular", + "expanded" + ], + "type": "string" + }, + "spacing": { + "maximum": 48, + "minimum": 0, + "type": "integer" + }, + "visible": { + "type": "boolean" + } + }, + "required": [ + "id", + "order", + "visible", + "size", + "align", + "spacing" + ], + "type": "object" + }, + "maxItems": 8, + "minItems": 8, + "type": "array" + }, + "sidebarDensity": { + "enum": [ + "compact", + "comfortable" + ], + "type": "string" + } + }, + "required": [ + "heroHeight", + "cardColumns", + "composerWidth", + "sidebarDensity", + "moduleGap", + "modules" + ], + "type": "object" + }, + "metadata": { + "additionalProperties": false, + "properties": { + "homepage": { + "format": "uri", + "maxLength": 500, + "type": "string" + }, + "localized": { + "additionalProperties": { + "additionalProperties": false, + "properties": { + "description": { + "maxLength": 240, + "minLength": 1, + "type": "string" + }, + "name": { + "maxLength": 80, + "minLength": 1, + "type": "string" + } + }, + "type": "object" + }, + "propertyNames": { + "enum": [ + "zh-CN", + "en" + ] + }, + "type": "object" + } + }, + "type": "object" + }, + "name": { + "maxLength": 80, + "minLength": 1, + "type": "string" + }, + "rights": { + "additionalProperties": false, + "properties": { + "attribution": { + "maxLength": 240, + "minLength": 1, + "type": "string" + }, + "licenseId": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "localOnly": { + "type": "boolean" + }, + "source": { + "format": "uri", + "maxLength": 500, + "type": "string" + } + }, + "required": [ + "licenseId", + "localOnly" + ], + "type": "object" + }, + "schemaVersion": { + "const": 4, + "type": "number" + }, + "surfaces": { + "additionalProperties": false, + "default": { + "baseOpacity": 0.68, + "blur": 0, + "elevatedOpacity": 0.92, + "terminalOpacity": 0.82 + }, + "properties": { + "baseOpacity": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "blur": { + "maximum": 30, + "minimum": 0, + "type": "number" + }, + "elevatedOpacity": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "terminalOpacity": { + "maximum": 1, + "minimum": 0, + "type": "number" + } + }, + "required": [ + "baseOpacity", + "elevatedOpacity", + "terminalOpacity", + "blur" + ], + "type": "object" + }, + "typography": { + "additionalProperties": false, + "properties": { + "codeFamily": { + "maxLength": 120, + "minLength": 1, + "type": "string" + }, + "codeFontAssetKey": { + "maxLength": 40, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "type": "string" + }, + "codeSize": { + "maximum": 22, + "minimum": 11, + "type": "number" + }, + "codeWeight": { + "enum": [ + 400, + 500, + 600, + 700 + ], + "type": "number" + }, + "displayFamily": { + "maxLength": 120, + "minLength": 1, + "type": "string" + }, + "displayFontAssetKey": { + "maxLength": 40, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "type": "string" + }, + "displayLetterSpacing": { + "maximum": 0.2, + "minimum": -0.05, + "type": "number" + }, + "displayLineHeight": { + "maximum": 1.8, + "minimum": 1.1, + "type": "number" + }, + "displaySize": { + "maximum": 72, + "minimum": 20, + "type": "number" + }, + "displayWeight": { + "enum": [ + 400, + 500, + 600, + 700 + ], + "type": "number" + }, + "lineHeight": { + "maximum": 1.8, + "minimum": 1.2, + "type": "number" + }, + "scale": { + "maximum": 1.3, + "minimum": 0.85, + "type": "number" + }, + "uiFamily": { + "maxLength": 120, + "minLength": 1, + "type": "string" + }, + "uiFontAssetKey": { + "maxLength": 40, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "type": "string" + }, + "uiSize": { + "maximum": 22, + "minimum": 12, + "type": "number" + }, + "uiWeight": { + "enum": [ + 400, + 500, + 600, + 700 + ], + "type": "number" + } + }, + "required": [ + "uiFamily", + "codeFamily", + "scale", + "uiSize", + "codeSize", + "uiWeight", + "codeWeight", + "lineHeight" + ], + "type": "object" + }, + "version": { + "pattern": "^(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)$", + "type": "string" + } + }, + "required": [ + "schemaVersion", + "kind", + "id", + "name", + "version", + "author", + "assets", + "colors", + "typography", + "background", + "decorations", + "layout", + "rights" + ], + "type": "object" + } + } + } + }, + "targetVersion": 4 +} diff --git a/contracts/theme/v4/schema.json b/contracts/theme/v4/schema.json new file mode 100644 index 0000000..1139e87 --- /dev/null +++ b/contracts/theme/v4/schema.json @@ -0,0 +1,909 @@ +{ + "errorCodes": [ + "THEME_SCHEMA_VERSION_UNSUPPORTED", + "THEME_WELCOME_INVALID", + "THEME_DISPLAY_FONT_MISSING", + "THEME_COMPOSITION_INVALID", + "THEME_SCHEMA_INVALID" + ], + "schema": { + "$ref": "#/definitions/ThemeDocument", + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "ThemeDocument": { + "additionalProperties": false, + "properties": { + "appearance": { + "default": "auto", + "enum": [ + "auto", + "light", + "dark" + ], + "type": "string" + }, + "assets": { + "additionalProperties": false, + "properties": { + "background": { + "type": "string" + }, + "decorations": { + "additionalProperties": { + "type": "string" + }, + "propertyNames": { + "maxLength": 40, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$" + }, + "type": "object" + }, + "fonts": { + "additionalProperties": { + "type": "string" + }, + "propertyNames": { + "maxLength": 40, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$" + }, + "type": "object" + }, + "portrait": { + "type": "string" + }, + "profileAvatar": { + "type": "string" + }, + "projectIcons": { + "items": { + "type": "string" + }, + "maxItems": 12, + "minItems": 1, + "type": "array" + }, + "suggestionIcons": { + "additionalProperties": false, + "properties": { + "card1": { + "type": "string" + }, + "card2": { + "type": "string" + }, + "card3": { + "type": "string" + }, + "card4": { + "type": "string" + } + }, + "type": "object" + } + }, + "type": "object" + }, + "author": { + "maxLength": 80, + "minLength": 1, + "type": "string" + }, + "background": { + "additionalProperties": false, + "properties": { + "blur": { + "maximum": 30, + "minimum": 0, + "type": "number" + }, + "brightness": { + "maximum": 1.5, + "minimum": 0.3, + "type": "number" + }, + "overlay": { + "maximum": 0.9, + "minimum": 0, + "type": "number" + }, + "positionX": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "positionY": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "safeArea": { + "default": "auto", + "enum": [ + "auto", + "left", + "center", + "right", + "none" + ], + "type": "string" + }, + "scale": { + "maximum": 3, + "minimum": 0.5, + "type": "number" + }, + "taskMode": { + "default": "full", + "enum": [ + "auto", + "full", + "ambient", + "banner", + "off" + ], + "type": "string" + }, + "taskOpacity": { + "default": 0.82, + "maximum": 1, + "minimum": 0, + "type": "number" + } + }, + "required": [ + "positionX", + "positionY", + "scale", + "blur", + "brightness", + "overlay" + ], + "type": "object" + }, + "colors": { + "additionalProperties": false, + "properties": { + "accent": { + "type": "string" + }, + "border": { + "type": "string" + }, + "codeText": { + "type": "string" + }, + "danger": { + "type": "string" + }, + "info": { + "type": "string" + }, + "inputText": { + "type": "string" + }, + "link": { + "type": "string" + }, + "muted": { + "type": "string" + }, + "panel": { + "type": "string" + }, + "placeholder": { + "type": "string" + }, + "secondary": { + "type": "string" + }, + "success": { + "type": "string" + }, + "text": { + "type": "string" + }, + "textSecondary": { + "type": "string" + }, + "warning": { + "type": "string" + } + }, + "required": [ + "accent", + "secondary", + "text", + "muted", + "panel", + "border", + "success", + "warning", + "danger", + "info", + "textSecondary", + "link", + "inputText", + "placeholder", + "codeText" + ], + "type": "object" + }, + "composition": { + "additionalProperties": false, + "properties": { + "layers": { + "items": { + "additionalProperties": false, + "properties": { + "anchor": { + "enum": [ + "top-left", + "top-center", + "top-right", + "center-left", + "center", + "center-right", + "bottom-left", + "bottom-center", + "bottom-right" + ], + "type": "string" + }, + "asset": { + "anyOf": [ + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "portrait", + "type": "string" + } + }, + "required": [ + "kind" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "assetKey": { + "maxLength": 40, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "type": "string" + }, + "kind": { + "const": "decoration", + "type": "string" + } + }, + "required": [ + "kind", + "assetKey" + ], + "type": "object" + } + ] + }, + "id": { + "maxLength": 40, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "type": "string" + }, + "opacity": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "positionX": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "positionY": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "required": { + "type": "boolean" + }, + "rotation": { + "maximum": 180, + "minimum": -180, + "type": "number" + }, + "surface": { + "enum": [ + "viewport", + "main", + "home-hero", + "suggestions" + ], + "type": "string" + }, + "width": { + "maximum": 1.5, + "minimum": 0.02, + "type": "number" + } + }, + "required": [ + "id", + "asset", + "surface", + "anchor", + "positionX", + "positionY", + "width", + "opacity", + "rotation", + "required" + ], + "type": "object" + }, + "maxItems": 24, + "type": "array" + } + }, + "required": [ + "layers" + ], + "type": "object" + }, + "decorations": { + "items": { + "additionalProperties": false, + "properties": { + "assetKey": { + "maxLength": 40, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "intensity": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "opacity": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "placement": { + "enum": [ + "background", + "corners", + "hero", + "cards" + ], + "type": "string" + }, + "scale": { + "maximum": 3, + "minimum": 0.25, + "type": "number" + }, + "type": { + "enum": [ + "particles", + "ribbon", + "butterflies", + "polaroid", + "badge", + "sparkles", + "image" + ], + "type": "string" + } + }, + "required": [ + "type", + "enabled", + "intensity" + ], + "type": "object" + }, + "maxItems": 16, + "type": "array" + }, + "description": { + "maxLength": 240, + "minLength": 1, + "type": "string" + }, + "home": { + "additionalProperties": false, + "properties": { + "welcome": { + "additionalProperties": false, + "properties": { + "layout": { + "additionalProperties": false, + "properties": { + "anchor": { + "enum": [ + "top-left", + "top-center", + "top-right", + "center-left", + "center", + "center-right", + "bottom-left", + "bottom-center", + "bottom-right" + ], + "type": "string" + }, + "hideNativeIcon": { + "default": false, + "type": "boolean" + }, + "positionX": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "positionY": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "textAlign": { + "enum": [ + "left", + "center", + "right" + ], + "type": "string" + }, + "width": { + "maximum": 1, + "minimum": 0.2, + "type": "number" + } + }, + "required": [ + "anchor", + "positionX", + "positionY", + "width", + "textAlign" + ], + "type": "object" + }, + "localized": { + "additionalProperties": { + "additionalProperties": false, + "properties": { + "lines": { + "items": { + "type": "string" + }, + "maxItems": 3, + "minItems": 1, + "type": "array" + } + }, + "required": [ + "lines" + ], + "type": "object" + }, + "propertyNames": { + "enum": [ + "zh-CN", + "en" + ] + }, + "type": "object" + } + }, + "required": [ + "localized" + ], + "type": "object" + } + }, + "required": [ + "welcome" + ], + "type": "object" + }, + "id": { + "maxLength": 80, + "minLength": 3, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "type": "string" + }, + "interfaceImages": { + "additionalProperties": false, + "properties": { + "profileAvatarSize": { + "maximum": 48, + "minimum": 16, + "type": "integer" + }, + "projectIconSize": { + "maximum": 32, + "minimum": 12, + "type": "integer" + }, + "suggestionIconSize": { + "maximum": 64, + "minimum": 16, + "type": "integer" + } + }, + "required": [ + "profileAvatarSize", + "suggestionIconSize", + "projectIconSize" + ], + "type": "object" + }, + "kind": { + "enum": [ + "theme", + "recipe" + ], + "type": "string" + }, + "layout": { + "additionalProperties": false, + "properties": { + "cardColumns": { + "maximum": 4, + "minimum": 2, + "type": "integer" + }, + "composerWidth": { + "maximum": 1, + "minimum": 0.5, + "type": "number" + }, + "heroHeight": { + "maximum": 560, + "minimum": 180, + "type": "integer" + }, + "moduleGap": { + "maximum": 48, + "minimum": 0, + "type": "integer" + }, + "modules": { + "items": { + "additionalProperties": false, + "properties": { + "align": { + "enum": [ + "start", + "center", + "end", + "stretch" + ], + "type": "string" + }, + "id": { + "enum": [ + "sidebar", + "topbar", + "hero", + "suggestions", + "project-picker", + "composer", + "task-background", + "content-layer" + ], + "type": "string" + }, + "order": { + "maximum": 7, + "minimum": 0, + "type": "integer" + }, + "size": { + "enum": [ + "compact", + "regular", + "expanded" + ], + "type": "string" + }, + "spacing": { + "maximum": 48, + "minimum": 0, + "type": "integer" + }, + "visible": { + "type": "boolean" + } + }, + "required": [ + "id", + "order", + "visible", + "size", + "align", + "spacing" + ], + "type": "object" + }, + "maxItems": 8, + "minItems": 8, + "type": "array" + }, + "sidebarDensity": { + "enum": [ + "compact", + "comfortable" + ], + "type": "string" + } + }, + "required": [ + "heroHeight", + "cardColumns", + "composerWidth", + "sidebarDensity", + "moduleGap", + "modules" + ], + "type": "object" + }, + "metadata": { + "additionalProperties": false, + "properties": { + "homepage": { + "format": "uri", + "maxLength": 500, + "type": "string" + }, + "localized": { + "additionalProperties": { + "additionalProperties": false, + "properties": { + "description": { + "maxLength": 240, + "minLength": 1, + "type": "string" + }, + "name": { + "maxLength": 80, + "minLength": 1, + "type": "string" + } + }, + "type": "object" + }, + "propertyNames": { + "enum": [ + "zh-CN", + "en" + ] + }, + "type": "object" + } + }, + "type": "object" + }, + "name": { + "maxLength": 80, + "minLength": 1, + "type": "string" + }, + "rights": { + "additionalProperties": false, + "properties": { + "attribution": { + "maxLength": 240, + "minLength": 1, + "type": "string" + }, + "licenseId": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "localOnly": { + "type": "boolean" + }, + "source": { + "format": "uri", + "maxLength": 500, + "type": "string" + } + }, + "required": [ + "licenseId", + "localOnly" + ], + "type": "object" + }, + "schemaVersion": { + "const": 4, + "type": "number" + }, + "surfaces": { + "additionalProperties": false, + "default": { + "baseOpacity": 0.68, + "blur": 0, + "elevatedOpacity": 0.92, + "terminalOpacity": 0.82 + }, + "properties": { + "baseOpacity": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "blur": { + "maximum": 30, + "minimum": 0, + "type": "number" + }, + "elevatedOpacity": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "terminalOpacity": { + "maximum": 1, + "minimum": 0, + "type": "number" + } + }, + "required": [ + "baseOpacity", + "elevatedOpacity", + "terminalOpacity", + "blur" + ], + "type": "object" + }, + "typography": { + "additionalProperties": false, + "properties": { + "codeFamily": { + "maxLength": 120, + "minLength": 1, + "type": "string" + }, + "codeFontAssetKey": { + "maxLength": 40, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "type": "string" + }, + "codeSize": { + "maximum": 22, + "minimum": 11, + "type": "number" + }, + "codeWeight": { + "enum": [ + 400, + 500, + 600, + 700 + ], + "type": "number" + }, + "displayFamily": { + "maxLength": 120, + "minLength": 1, + "type": "string" + }, + "displayFontAssetKey": { + "maxLength": 40, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "type": "string" + }, + "displayLetterSpacing": { + "maximum": 0.2, + "minimum": -0.05, + "type": "number" + }, + "displayLineHeight": { + "maximum": 1.8, + "minimum": 1.1, + "type": "number" + }, + "displaySize": { + "maximum": 72, + "minimum": 20, + "type": "number" + }, + "displayWeight": { + "enum": [ + 400, + 500, + 600, + 700 + ], + "type": "number" + }, + "lineHeight": { + "maximum": 1.8, + "minimum": 1.2, + "type": "number" + }, + "scale": { + "maximum": 1.3, + "minimum": 0.85, + "type": "number" + }, + "uiFamily": { + "maxLength": 120, + "minLength": 1, + "type": "string" + }, + "uiFontAssetKey": { + "maxLength": 40, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "type": "string" + }, + "uiSize": { + "maximum": 22, + "minimum": 12, + "type": "number" + }, + "uiWeight": { + "enum": [ + 400, + 500, + 600, + 700 + ], + "type": "number" + } + }, + "required": [ + "uiFamily", + "codeFamily", + "scale", + "uiSize", + "codeSize", + "uiWeight", + "codeWeight", + "lineHeight", + "displayFamily", + "displaySize", + "displayWeight", + "displayLineHeight", + "displayLetterSpacing" + ], + "type": "object" + }, + "version": { + "pattern": "^(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)$", + "type": "string" + } + }, + "required": [ + "schemaVersion", + "kind", + "id", + "name", + "version", + "author", + "assets", + "colors", + "typography", + "background", + "decorations", + "layout", + "rights", + "interfaceImages", + "composition" + ], + "type": "object" + } + } + }, + "schemaVersion": 4 +} diff --git a/contracts/theme/v4/semantic-cases.json b/contracts/theme/v4/semantic-cases.json new file mode 100644 index 0000000..7477503 --- /dev/null +++ b/contracts/theme/v4/semantic-cases.json @@ -0,0 +1,64 @@ +{ + "cases": [ + { + "expectedSchemaVersion": 4, + "name": "complete v4 theme", + "valid": true + }, + { + "expectedSchemaVersion": 4, + "name": "v1 semantic color migration", + "sourceVersion": 1, + "valid": true + }, + { + "expectedSchemaVersion": 4, + "name": "v2 migration", + "sourceVersion": 2, + "valid": true + }, + { + "expectedSchemaVersion": 4, + "name": "v3 migration", + "sourceVersion": 3, + "valid": true + }, + { + "expectedErrorCode": "THEME_SCHEMA_VERSION_UNSUPPORTED", + "expectedPath": "/schemaVersion", + "name": "future schema version", + "valid": false + }, + { + "expectedErrorCode": "THEME_WELCOME_INVALID", + "expectedPath": "/home/welcome", + "name": "unsafe welcome placeholder", + "valid": false + }, + { + "expectedErrorCode": "THEME_DISPLAY_FONT_MISSING", + "expectedPath": "/typography/displayFontAssetKey", + "name": "missing display font", + "valid": false + }, + { + "expectedErrorCode": "THEME_COMPOSITION_INVALID", + "expectedPath": "/composition/layers/0/asset", + "name": "undeclared composition asset", + "valid": false + }, + { + "documentKind": "draft", + "name": "draft may omit background", + "valid": true + }, + { + "documentKind": "theme", + "expectedErrorCode": "THEME_SCHEMA_INVALID", + "expectedPath": "/assets/background", + "name": "saved theme requires background", + "valid": false + } + ], + "schemaVersion": 4 +} diff --git a/package-lock.json b/package-lock.json index 9c80bdf..144da0b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,7 +20,8 @@ "tsx": "^4.23.1", "typescript": "^5.9.3", "vitest": "^3.2.7", - "ws": "^8.18.3" + "ws": "^8.18.3", + "zod-to-json-schema": "^3.25.2" }, "engines": { "node": ">=22.0.0" @@ -4911,6 +4912,16 @@ "url": "https://github.com/sponsors/colinhacks" } }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmmirror.com/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "dev": true, + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + }, "packages/cdp-adapter": { "name": "@open-chatgpt-skin/cdp-adapter", "version": "0.2.0", diff --git a/package.json b/package.json index 554b6ec..5e9ae63 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,8 @@ "build": "tsc -b", "themes:build": "tsx --tsconfig tsconfig.scripts.json scripts/build-theme-catalog.ts", "go:baseline:verify": "tsx --tsconfig tsconfig.scripts.json scripts/contracts/verify-baseline.ts", + "contracts:build": "tsx --tsconfig tsconfig.scripts.json scripts/contracts/build.ts", + "contracts:verify": "tsx --tsconfig tsconfig.scripts.json scripts/contracts/verify.ts", "typecheck": "tsc -b --pretty false", "test": "vitest run", "test:watch": "vitest", @@ -38,7 +40,7 @@ "release:macos": "tsx scripts/release/build-macos-distribution.ts", "release:acceptance:macos": "tsx scripts/release/accept-macos-distribution.ts", "studio:dev": "npm run build && npm run dev -w @open-chatgpt-skin/theme-studio-service", - "verify": "npm run build && npm run test && npm run typecheck", + "verify": "npm run contracts:verify && npm run build && npm run test && npm run typecheck", "verify:runtime": "npm run build && npm run test && npm run typecheck", "verify:foundation": "npm run themes:build && npm run build && npm run test && npm run typecheck && node packages/theme-core/dist/cli.js catalog --root themes" }, @@ -53,6 +55,7 @@ "tsx": "^4.23.1", "typescript": "^5.9.3", "vitest": "^3.2.7", - "ws": "^8.18.3" + "ws": "^8.18.3", + "zod-to-json-schema": "^3.25.2" } } diff --git a/packages/theme-core/src/archive.ts b/packages/theme-core/src/archive.ts index fdc7600..55b61d5 100644 --- a/packages/theme-core/src/archive.ts +++ b/packages/theme-core/src/archive.ts @@ -19,6 +19,17 @@ import type { ValidatedThemeBundle } from "./types.js"; export const OCSKIN_MAX_ARCHIVE_BYTES = 32 * 1024 * 1024; export const OCSKIN_MAX_EXPANDED_BYTES = 32 * 1024 * 1024; +export const OCSKIN_ARCHIVE_SEMANTIC_CASES = [ + { name: "valid archive", valid: true }, + { name: "unsafe traversal entry", valid: false, expectedErrorCode: "ARCHIVE_ENTRY_UNSAFE", expectedPath: "/entries/../secret" }, + { name: "case-folded duplicate entry", valid: false, expectedErrorCode: "ARCHIVE_ENTRY_DUPLICATE", expectedPath: "/entries" }, + { name: "unsupported entry", valid: false, expectedErrorCode: "ARCHIVE_ENTRY_UNSUPPORTED", expectedPath: "/entries/run.js" }, + { name: "expanded size limit", valid: false, expectedErrorCode: "PACKAGE_EXPANDED_TOO_LARGE", expectedPath: "/expandedBytes" }, + { name: "missing required files", valid: false, expectedErrorCode: "ARCHIVE_REQUIRED_FILE_MISSING", expectedPath: "/entries/theme.json" }, + { name: "manifest mismatch", valid: false, expectedErrorCode: "ARCHIVE_MANIFEST_MISMATCH", expectedPath: "/manifest/files" }, + { name: "hash mismatch", valid: false, expectedErrorCode: "ARCHIVE_HASH_MISMATCH", expectedPath: "/manifest/files/sha256" }, +] as const; + export interface OcskinManifest { readonly schemaVersion: 1; readonly themeId: string; @@ -29,7 +40,7 @@ export interface OcskinManifest { }>>; } -const ManifestSchema = z.object({ +export const OcskinManifestSchema = z.object({ schemaVersion: z.literal(1), themeId: ThemeIdSchema, themeVersion: ThemeVersionSchema, @@ -230,7 +241,7 @@ export async function unpackTheme(bytes: Uint8Array): Promise validateThemeRelationships(theme, context, true), ); -const ThemeDocumentV2Schema = ThemeDocumentV3FieldsSchema.extend({ +export const ThemeDocumentV2Schema = ThemeDocumentV3FieldsSchema.extend({ schemaVersion: z.literal(2), assets: ThemeAssetsV2Schema, }).strict(); -const LegacyThemeDocumentSchema = ThemeDocumentV2Schema.extend({ +export const LegacyThemeDocumentSchema = ThemeDocumentV2Schema.extend({ schemaVersion: z.literal(1), colors: ThemeColorsV1Schema, }).strict(); diff --git a/packages/theme-studio-core/src/contracts.ts b/packages/theme-studio-core/src/contracts.ts index 58c017a..a58fd90 100644 --- a/packages/theme-studio-core/src/contracts.ts +++ b/packages/theme-studio-core/src/contracts.ts @@ -8,6 +8,10 @@ import { PRODUCT_VERSION_PATTERN } from "./security.js"; export const STUDIO_PROTOCOL_VERSION = 2 as const; +export const StudioSessionExchangeSchema = z.object({ + token: z.string().regex(/^[0-9a-f]{64}$/), +}).strict(); + export const StudioThemeRefSchema = z.object({ id: ThemeIdSchema, version: ThemeVersionSchema, diff --git a/packages/theme-studio-core/src/errors.ts b/packages/theme-studio-core/src/errors.ts index 782c0bb..b50eebe 100644 --- a/packages/theme-studio-core/src/errors.ts +++ b/packages/theme-studio-core/src/errors.ts @@ -18,6 +18,24 @@ export const STUDIO_ERROR_CODES = [ export type StudioErrorCode = typeof STUDIO_ERROR_CODES[number]; +export const STUDIO_ERROR_HTTP_STATUS: Readonly> = { + STUDIO_SESSION_INVALID: 401, + STUDIO_ORIGIN_REJECTED: 403, + STUDIO_REQUEST_TOO_LARGE: 413, + STUDIO_REQUEST_INVALID: 400, + STUDIO_DRAFT_NOT_FOUND: 404, + STUDIO_DRAFT_CONFLICT: 409, + STUDIO_DRAFT_INVALID: 422, + STUDIO_ASSET_INVALID: 422, + STUDIO_IMPORT_INVALID: 422, + STUDIO_EXPORT_INVALID: 422, + STUDIO_DELETE_FAILED: 422, + STUDIO_SAVE_FAILED: 422, + STUDIO_APPLY_FAILED: 409, + RUNTIME_STATUS_UNAVAILABLE: 503, + INTERNAL: 500, +}; + export class StudioError extends Error { constructor( public readonly code: StudioErrorCode, diff --git a/packages/theme-studio-core/src/http-contract.ts b/packages/theme-studio-core/src/http-contract.ts new file mode 100644 index 0000000..4fab0f0 --- /dev/null +++ b/packages/theme-studio-core/src/http-contract.ts @@ -0,0 +1,88 @@ +export const STUDIO_SECURITY_HEADERS = [ + "Content-Security-Policy", + "Cross-Origin-Opener-Policy", + "Referrer-Policy", + "X-Content-Type-Options", + "X-Frame-Options", +] as const; + +export const STUDIO_BODY_LIMITS = { + sessionJsonBytes: 16 * 1024, + jsonBytes: 256 * 1024, + imageBytes: 50 * 1024 * 1024, + archiveBytes: 32 * 1024 * 1024, +} as const; + +export const STUDIO_RESPONSE_POLICIES = { + json: { + contentType: "application/json; charset=utf-8", + cacheControl: "no-store", + contentTypeOptions: "nosniff", + }, + binary: { + contentType: "route-defined", + cacheControl: "no-store", + contentTypeOptions: "nosniff", + }, + html: { + contentType: "text/html; charset=utf-8", + cacheControl: "no-store", + contentTypeOptions: "nosniff", + }, + sse: { + contentType: "text/event-stream; charset=utf-8", + cacheControl: "no-store", + contentTypeOptions: "nosniff", + }, +} as const; + +export const STUDIO_ROUTE_DEFINITIONS = [ + { id: "home", method: "GET", path: "/", successStatus: 200, response: "html", originRequired: false }, + { id: "session", method: "POST", path: "/api/session", successStatus: 204, request: "sessionExchange", response: "none", originRequired: true }, + { id: "bootstrap", method: "GET", path: "/api/bootstrap", successStatus: 200, response: "bootstrap", originRequired: false }, + { id: "themes", method: "GET", path: "/api/themes", successStatus: 200, response: "themeLibrary", originRequired: false }, + { id: "applySavedTheme", method: "POST", path: "/api/themes/apply", successStatus: 200, request: "themeRef", response: "applyResult", originRequired: true }, + { id: "deleteTheme", method: "DELETE", path: "/api/themes/{id}", successStatus: 200, request: "deleteTheme", response: "themeLibrary", originRequired: true }, + { id: "createDraft", method: "POST", path: "/api/drafts", successStatus: 201, request: "createDraft", response: "draft", originRequired: true }, + { id: "latestDraft", method: "GET", path: "/api/drafts/latest", successStatus: 200, response: "nullableDraft", originRequired: false }, + { id: "draft", method: "GET", path: "/api/drafts/{draftId}", successStatus: 200, response: "draft", originRequired: false }, + { id: "updateDraft", method: "PUT", path: "/api/drafts/{draftId}", successStatus: 200, request: "updateDraft", response: "draft", originRequired: true }, + { id: "undoDraft", method: "POST", path: "/api/drafts/{draftId}/undo", successStatus: 200, request: "draftCommand", response: "draft", originRequired: true }, + { id: "redoDraft", method: "POST", path: "/api/drafts/{draftId}/redo", successStatus: 200, request: "draftCommand", response: "draft", originRequired: true }, + { id: "validateDraft", method: "POST", path: "/api/drafts/{draftId}/validate", successStatus: 200, response: "draft", originRequired: true }, + { id: "saveDraft", method: "POST", path: "/api/drafts/{draftId}/save", successStatus: 200, request: "draftCommand", response: "saveResult", originRequired: true }, + { id: "applyDraft", method: "POST", path: "/api/drafts/{draftId}/apply", successStatus: 200, request: "draftCommand", response: "applyResult", originRequired: true }, + { id: "uploadAsset", method: "POST", path: "/api/drafts/{draftId}/assets", successStatus: 200, request: "binaryAsset", response: "draft", originRequired: true }, + { id: "importTheme", method: "POST", path: "/api/import", successStatus: 201, request: "binaryArchive", response: "draft", originRequired: true }, + { id: "exportTheme", method: "GET", path: "/api/export", successStatus: 200, request: "themeRefQuery", response: "binaryArchive", originRequired: false }, + { id: "runtime", method: "GET", path: "/api/runtime", successStatus: 200, response: "runtimeStatus", originRequired: false }, + { id: "restoreRuntime", method: "POST", path: "/api/runtime/restore", successStatus: 200, response: "runtimeStatus", originRequired: true }, + { id: "draftAsset", method: "GET", path: "/api/draft-asset", successStatus: 200, response: "binaryAsset", originRequired: false }, + { id: "themePreview", method: "GET", path: "/api/theme-preview", successStatus: 200, response: "binaryImage", originRequired: false }, + { id: "events", method: "GET", path: "/api/events", successStatus: 200, response: "sseRuntimeStatus", originRequired: false }, +] as const; + +export const STUDIO_HTTP_SEMANTIC_CASES = [ + { name: "authenticated bootstrap", valid: true, expectedStatus: 200 }, + { name: "one-time session exchange", valid: true, expectedStatus: 204 }, + { name: "reused bootstrap token", valid: false, expectedStatus: 401, expectedErrorCode: "STUDIO_SESSION_INVALID", expectedPath: "/api/session" }, + { name: "cross-origin mutation", valid: false, expectedStatus: 403, expectedErrorCode: "STUDIO_ORIGIN_REJECTED", expectedPath: "/headers/origin" }, + { name: "oversized binary upload", valid: false, expectedStatus: 413, expectedErrorCode: "STUDIO_REQUEST_TOO_LARGE", expectedPath: "/body" }, + { name: "binary export", valid: true, expectedStatus: 200, responseKind: "binary" }, + { name: "runtime status stream", valid: true, expectedStatus: 200, responseKind: "sse" }, +] as const; + +export type StudioRouteId = typeof STUDIO_ROUTE_DEFINITIONS[number]["id"]; + +export function isStudioRoute( + id: StudioRouteId, + method: string | undefined, + path: string, +): boolean { + const route = STUDIO_ROUTE_DEFINITIONS.find((candidate) => candidate.id === id); + if (!route) throw new Error(`Unknown Studio route: ${id}`); + if (route.path.includes("{")) { + throw new Error(`Parameterized Studio route requires explicit parsing: ${id}`); + } + return method === route.method && path === route.path; +} diff --git a/packages/theme-studio-core/src/index.ts b/packages/theme-studio-core/src/index.ts index bfe65a6..fcb5bc9 100644 --- a/packages/theme-studio-core/src/index.ts +++ b/packages/theme-studio-core/src/index.ts @@ -1,5 +1,6 @@ export * from "./contracts.js"; export * from "./errors.js"; +export * from "./http-contract.js"; export * from "./personal-theme.js"; export * from "./project.js"; export * from "./security.js"; diff --git a/runtime/theme-studio-service/src/http.ts b/runtime/theme-studio-service/src/http.ts index beca463..d9b3268 100644 --- a/runtime/theme-studio-service/src/http.ts +++ b/runtime/theme-studio-service/src/http.ts @@ -1,7 +1,11 @@ import type { IncomingMessage, ServerResponse } from "node:http"; -import { StudioError } from "@open-chatgpt-skin/theme-studio-core"; +import { + STUDIO_BODY_LIMITS, + STUDIO_RESPONSE_POLICIES, + StudioError, +} from "@open-chatgpt-skin/theme-studio-core"; -export const STUDIO_JSON_LIMIT_BYTES = 256 * 1024; +export const STUDIO_JSON_LIMIT_BYTES = STUDIO_BODY_LIMITS.jsonBytes; export async function readBoundedJson( request: IncomingMessage, @@ -72,9 +76,9 @@ export function writeJson( body: unknown, ): void { response.writeHead(status, { - "Content-Type": "application/json; charset=utf-8", - "Cache-Control": "no-store", - "X-Content-Type-Options": "nosniff", + "Content-Type": STUDIO_RESPONSE_POLICIES.json.contentType, + "Cache-Control": STUDIO_RESPONSE_POLICIES.json.cacheControl, + "X-Content-Type-Options": STUDIO_RESPONSE_POLICIES.json.contentTypeOptions, }); response.end(`${JSON.stringify(body)}\n`); } @@ -89,8 +93,8 @@ export function writeBytes( response.writeHead(status, { "Content-Type": contentType, "Content-Length": String(body.length), - "Cache-Control": "no-store", - "X-Content-Type-Options": "nosniff", + "Cache-Control": STUDIO_RESPONSE_POLICIES.binary.cacheControl, + "X-Content-Type-Options": STUDIO_RESPONSE_POLICIES.binary.contentTypeOptions, ...headers, }); response.end(body); diff --git a/runtime/theme-studio-service/src/server.ts b/runtime/theme-studio-service/src/server.ts index 1e7a11f..44717ac 100644 --- a/runtime/theme-studio-service/src/server.ts +++ b/runtime/theme-studio-service/src/server.ts @@ -6,20 +6,25 @@ import { } from "node:http"; import { STUDIO_PROTOCOL_VERSION, + STUDIO_BODY_LIMITS, + STUDIO_RESPONSE_POLICIES, StudioBootstrapSchema, StudioCreateDraftInputSchema, StudioDeleteThemeInputSchema, StudioDraftCommandInputSchema, StudioError, + STUDIO_ERROR_HTTP_STATUS, + isStudioRoute, StudioEventSchema, StudioImportThemeInputSchema, + StudioSessionExchangeSchema, StudioThemeRefSchema, StudioUpdateDraftInputSchema, StudioUploadAssetInputSchema, type StudioErrorCode, type StudioRuntimeStatus, } from "@open-chatgpt-skin/theme-studio-core"; -import { z, ZodError } from "zod"; +import { ZodError } from "zod"; import { assertExactOrigin, readBoundedBytes, @@ -33,15 +38,7 @@ import { } from "./session.js"; import type { ThemeStudioWorkspace } from "./workspace.js"; -const SessionExchangeSchema = z.object({ - token: z.string().regex(/^[0-9a-f]{64}$/), -}).strict(); - const RUNTIME_EVENT_INTERVAL_MS = 5_000; -const SESSION_JSON_LIMIT_BYTES = 16 * 1024; -const IMAGE_UPLOAD_LIMIT_BYTES = 50 * 1024 * 1024; -const ARCHIVE_UPLOAD_LIMIT_BYTES = 32 * 1024 * 1024; - export interface ThemeStudioServerDependencies { readonly studioVersion: string; readonly repositoryUrl?: string | null; @@ -65,34 +62,7 @@ export interface RunningThemeStudioServer { function statusFor(error: unknown): number { if (error instanceof ZodError) return 400; - if (!(error instanceof StudioError)) return 500; - switch (error.code) { - case "STUDIO_ORIGIN_REJECTED": - return 403; - case "STUDIO_SESSION_INVALID": - return 401; - case "STUDIO_REQUEST_TOO_LARGE": - return 413; - case "STUDIO_REQUEST_INVALID": - return 400; - case "STUDIO_DRAFT_NOT_FOUND": - return 404; - case "STUDIO_DRAFT_CONFLICT": - return 409; - case "STUDIO_DRAFT_INVALID": - case "STUDIO_ASSET_INVALID": - case "STUDIO_IMPORT_INVALID": - case "STUDIO_EXPORT_INVALID": - case "STUDIO_SAVE_FAILED": - case "STUDIO_DELETE_FAILED": - return 422; - case "STUDIO_APPLY_FAILED": - return 409; - case "RUNTIME_STATUS_UNAVAILABLE": - return 503; - default: - return 500; - } + return error instanceof StudioError ? STUDIO_ERROR_HTTP_STATUS[error.code] : 500; } function requireWorkspace( @@ -121,8 +91,9 @@ function serveIndex( indexHtml: string, ): void { response.writeHead(200, { - "Content-Type": "text/html; charset=utf-8", - "Cache-Control": "no-store", + "Content-Type": STUDIO_RESPONSE_POLICIES.html.contentType, + "Cache-Control": STUDIO_RESPONSE_POLICIES.html.cacheControl, + "X-Content-Type-Options": STUDIO_RESPONSE_POLICIES.html.contentTypeOptions, }); response.end(indexHtml); } @@ -163,10 +134,10 @@ export async function startThemeStudioServer( } const url = new URL(request.url ?? "/", origin); - if (request.method === "POST" && url.pathname === "/api/session") { + if (isStudioRoute("session", request.method, url.pathname)) { assertExactOrigin(request, origin); - const body = SessionExchangeSchema.parse( - await readBoundedJson(request, SESSION_JSON_LIMIT_BYTES), + const body = StudioSessionExchangeSchema.parse( + await readBoundedJson(request, STUDIO_BODY_LIMITS.sessionJsonBytes), ); const cookie = session.exchange(body.token); response.writeHead(204, { @@ -185,7 +156,7 @@ export async function startThemeStudioServer( ); } - if (request.method === "GET" && url.pathname === "/api/bootstrap") { + if (isStudioRoute("bootstrap", request.method, url.pathname)) { writeJson(response, 200, StudioBootstrapSchema.parse({ protocolVersion: STUDIO_PROTOCOL_VERSION, studioVersion: dependencies.studioVersion, @@ -208,12 +179,12 @@ export async function startThemeStudioServer( return; } - if (request.method === "GET" && url.pathname === "/api/themes") { + if (isStudioRoute("themes", request.method, url.pathname)) { writeJson(response, 200, await requireWorkspace(dependencies.workspace).listThemes()); return; } - if (request.method === "POST" && url.pathname === "/api/themes/apply") { + if (isStudioRoute("applySavedTheme", request.method, url.pathname)) { assertExactOrigin(request, origin); const ref = StudioThemeRefSchema.parse(await readBoundedJson(request)); writeJson( @@ -239,14 +210,14 @@ export async function startThemeStudioServer( return; } - if (request.method === "POST" && url.pathname === "/api/drafts") { + if (isStudioRoute("createDraft", request.method, url.pathname)) { assertExactOrigin(request, origin); const input = StudioCreateDraftInputSchema.parse(await readBoundedJson(request)); writeJson(response, 201, await requireWorkspace(dependencies.workspace).createDraft(input)); return; } - if (request.method === "GET" && url.pathname === "/api/drafts/latest") { + if (isStudioRoute("latestDraft", request.method, url.pathname)) { writeJson( response, 200, @@ -290,7 +261,7 @@ export async function startThemeStudioServer( } if (request.method === "POST" && action === "assets") { assertExactOrigin(request, origin); - const bytes = await readBoundedBytes(request, IMAGE_UPLOAD_LIMIT_BYTES); + const bytes = await readBoundedBytes(request, STUDIO_BODY_LIMITS.imageBytes); const input = StudioUploadAssetInputSchema.parse({ draftId, expectedRevision: Number(url.searchParams.get("revision")), @@ -305,9 +276,9 @@ export async function startThemeStudioServer( } } - if (request.method === "POST" && url.pathname === "/api/import") { + if (isStudioRoute("importTheme", request.method, url.pathname)) { assertExactOrigin(request, origin); - const bytes = await readBoundedBytes(request, ARCHIVE_UPLOAD_LIMIT_BYTES); + const bytes = await readBoundedBytes(request, STUDIO_BODY_LIMITS.archiveBytes); const input = StudioImportThemeInputSchema.parse({ fileName: headerValue(request.headers["x-file-name"]), bytes, @@ -316,7 +287,7 @@ export async function startThemeStudioServer( return; } - if (request.method === "GET" && url.pathname === "/api/export") { + if (isStudioRoute("exportTheme", request.method, url.pathname)) { const ref = StudioThemeRefSchema.parse({ id: url.searchParams.get("id"), version: url.searchParams.get("version"), @@ -328,18 +299,18 @@ export async function startThemeStudioServer( return; } - if (request.method === "GET" && url.pathname === "/api/runtime") { + if (isStudioRoute("runtime", request.method, url.pathname)) { writeJson(response, 200, await requireWorkspace(dependencies.workspace).getRuntimeStatus()); return; } - if (request.method === "POST" && url.pathname === "/api/runtime/restore") { + if (isStudioRoute("restoreRuntime", request.method, url.pathname)) { assertExactOrigin(request, origin); writeJson(response, 200, await requireWorkspace(dependencies.workspace).restoreRuntime()); return; } - if (request.method === "GET" && url.pathname === "/api/draft-asset") { + if (isStudioRoute("draftAsset", request.method, url.pathname)) { const asset = await requireWorkspace(dependencies.workspace).readDraftAsset( url.searchParams.get("draftId") ?? "", url.searchParams.get("path") ?? "", @@ -348,7 +319,7 @@ export async function startThemeStudioServer( return; } - if (request.method === "GET" && url.pathname === "/api/theme-preview") { + if (isStudioRoute("themePreview", request.method, url.pathname)) { const source = url.searchParams.get("source"); if (source !== "builtin" && source !== "personal") { throw new StudioError("STUDIO_REQUEST_INVALID", "Theme preview source is invalid"); @@ -362,13 +333,13 @@ export async function startThemeStudioServer( return; } - if (request.method === "GET" && url.pathname === "/api/events") { + if (isStudioRoute("events", request.method, url.pathname)) { const firstRuntime = await dependencies.runtimeStatus(); response.writeHead(200, { - "Content-Type": "text/event-stream; charset=utf-8", - "Cache-Control": "no-store", + "Content-Type": STUDIO_RESPONSE_POLICIES.sse.contentType, + "Cache-Control": STUDIO_RESPONSE_POLICIES.sse.cacheControl, Connection: "keep-alive", - "X-Content-Type-Options": "nosniff", + "X-Content-Type-Options": STUDIO_RESPONSE_POLICIES.sse.contentTypeOptions, }); let closed = false; @@ -413,7 +384,7 @@ export async function startThemeStudioServer( if (dependencies.fallback && await dependencies.fallback(request, response)) { return; } - if (request.method === "GET" && url.pathname === "/") { + if (isStudioRoute("home", request.method, url.pathname)) { serveIndex(response, dependencies.indexHtml); return; } diff --git a/runtime/theme-studio-service/src/workspace.ts b/runtime/theme-studio-service/src/workspace.ts index c17f711..f9fffe1 100644 --- a/runtime/theme-studio-service/src/workspace.ts +++ b/runtime/theme-studio-service/src/workspace.ts @@ -86,7 +86,7 @@ function studioThemeError( ); } -const DraftRecordSchema = z.object({ +export const DraftRecordSchema = z.object({ schemaVersion: z.literal(1), draftId: z.string().uuid(), theme: ThemeDraftDocumentSchema, @@ -103,7 +103,7 @@ const DraftRecordSchema = z.object({ type DraftRecord = z.infer; -const PersistedDraftRecordSchema = DraftRecordSchema.omit({ +export const PersistedDraftRecordSchema = DraftRecordSchema.omit({ theme: true, past: true, future: true, diff --git a/runtime/windows/src/control/controller-lock.ts b/runtime/windows/src/control/controller-lock.ts index 8037a94..c9d7e4f 100644 --- a/runtime/windows/src/control/controller-lock.ts +++ b/runtime/windows/src/control/controller-lock.ts @@ -3,7 +3,7 @@ import { open, readFile, rename, unlink } from "node:fs/promises"; import { z } from "zod"; import { RuntimeError } from "../errors.js"; -const ControllerLockRecordSchema = z.object({ +export const ControllerLockRecordSchema = z.object({ schemaVersion: z.literal(1), pid: z.number().int().positive(), startedAt: z.string().datetime(), diff --git a/runtime/windows/src/control/protocol.ts b/runtime/windows/src/control/protocol.ts index 9e8700a..67c1d56 100644 --- a/runtime/windows/src/control/protocol.ts +++ b/runtime/windows/src/control/protocol.ts @@ -5,7 +5,9 @@ import { ThemeVersionSchema, } from "@open-chatgpt-skin/theme-schema"; import { RuntimeBuiltinThemeIdSchema } from "../themes/ids.js"; -import { CONTROL_PROTOCOL_VERSION } from "./result.js"; +import { CONTROL_COMMANDS, CONTROL_PROTOCOL_VERSION } from "./result.js"; +import { CONTROL_MAX_FRAME_BYTES } from "./framing.js"; +import { RUNTIME_ERROR_CODES } from "../errors.js"; export { CONTROL_PROTOCOL_VERSION, @@ -79,6 +81,32 @@ export const ControlRequestSchema = z.discriminatedUnion("command", [ export type ControlRequest = z.infer; export type ControlCommand = ControlRequest["command"]; +export const RUNTIME_CONTROL_CONTRACT = { + protocolVersion: CONTROL_PROTOCOL_VERSION, + frame: { + encoding: "json" as const, + byteOrder: "little-endian" as const, + lengthPrefixBytes: 4, + maxPayloadBytes: CONTROL_MAX_FRAME_BYTES, + }, + commands: CONTROL_COMMANDS, + readOnlyCommands: ["status"], + mutationConcurrency: "serialized" as const, + statusConcurrency: "parallel-with-mutation" as const, + requestIdSemantics: "same-id-same-command-replays; same-id-different-command-rejected" as const, + afterResponseCommands: ["launch", "restore"], + errors: RUNTIME_ERROR_CODES, +} as const; + +export const RUNTIME_CONTROL_SEMANTIC_CASES = [ + { name: "status during mutation", valid: true, command: "status", expectedConcurrency: "parallel-with-mutation" }, + { name: "duplicate request replay", valid: true, command: "switch", expectedBehavior: "replay-stored-response" }, + { name: "request ID reused for another command", valid: false, command: "pause", expectedErrorCode: "RUNTIME_INVALID_STATE", expectedPath: "/requestId" }, + { name: "oversized frame", valid: false, expectedErrorCode: "RUNTIME_CONTROL_UNAVAILABLE", expectedPath: "/frame/length" }, + { name: "restore callback after response", valid: true, command: "restore", expectedBehavior: "after-response" }, + { name: "personal theme without version", valid: false, command: "launch", expectedErrorCode: "THEME_NOT_FOUND", expectedPath: "/params/themeVersion" }, +] as const; + export function pipeNameForSid(sid: string): string { const digest = createHash("sha256").update(sid, "utf8").digest("hex").slice(0, 24); return `\\\\.\\pipe\\OpenChatGPTSkin-${digest}`; diff --git a/runtime/windows/src/controller/recovery.ts b/runtime/windows/src/controller/recovery.ts index 20e9688..27e3073 100644 --- a/runtime/windows/src/controller/recovery.ts +++ b/runtime/windows/src/controller/recovery.ts @@ -24,6 +24,15 @@ export interface RecoverRuntimeControllerDependencies extends RuntimeControllerD readonly discoverCodexInstall?: typeof discoverCodexInstall; } +export const RUNTIME_RECOVERY_SEMANTIC_CASES = [ + { name: "stable active appearance", valid: true, sourceStatus: "active", expectedStatus: "active" }, + { name: "stable paused appearance", valid: true, sourceStatus: "paused", expectedStatus: "paused" }, + { name: "interrupted launch restores official appearance", valid: true, operation: "launch", expectedStatus: "restored-awaiting-exit" }, + { name: "interrupted restore retries official appearance", valid: true, operation: "restore", expectedStatus: "restored-awaiting-exit" }, + { name: "failed cleanup marks appearance unknown", valid: true, cleanupSucceeded: false, expectedStatus: "recovery-required" }, + { name: "managed process identity changed", valid: false, expectedErrorCode: "RUNTIME_SESSION_STALE", expectedPath: "/codex/startedAt" }, +] as const; + interface RecoveredAppearance { readonly state: RuntimeSessionState; readonly keepPage: boolean; diff --git a/runtime/windows/src/discovery/trusted-cache.ts b/runtime/windows/src/discovery/trusted-cache.ts index c256ede..aca1da7 100644 --- a/runtime/windows/src/discovery/trusted-cache.ts +++ b/runtime/windows/src/discovery/trusted-cache.ts @@ -12,7 +12,7 @@ import { MACOS_CODEX_TEAM_ID, } from "../macos/identity.js"; -const TrustedWindowsCodexInstallSchema = z.object({ +export const TrustedWindowsCodexInstallSchema = z.object({ schemaVersion: z.literal(1), packageRoot: z.string().min(1), entryPath: z.string().min(1), @@ -32,7 +32,7 @@ const TrustedWindowsCodexInstallSchema = z.object({ verifiedAt: z.string().datetime(), }).strict(); -const TrustedMacOsCodexInstallSchema = z.object({ +export const TrustedMacOsCodexInstallSchema = z.object({ schemaVersion: z.literal(1), packageRoot: z.string().endsWith("/Codex.app"), entryPath: z.string().min(1), @@ -52,7 +52,7 @@ const TrustedMacOsCodexInstallSchema = z.object({ verifiedAt: z.string().datetime(), }).strict(); -const TrustedCodexInstallSchema = z.union([ +export const TrustedCodexInstallSchema = z.union([ TrustedWindowsCodexInstallSchema, TrustedMacOsCodexInstallSchema, ]); diff --git a/runtime/windows/src/session/state-machine.ts b/runtime/windows/src/session/state-machine.ts index 5801d81..48708ab 100644 --- a/runtime/windows/src/session/state-machine.ts +++ b/runtime/windows/src/session/state-machine.ts @@ -5,7 +5,7 @@ import { type RuntimeStatus, } from "../state.js"; -const ALLOWED: Readonly> = { +export const RUNTIME_STATE_TRANSITIONS: Readonly> = { launching: ["launching", "active", "recovery-required", "restored-awaiting-exit"], active: ["active", "paused", "paused-incompatible", "recovery-required", "restoring"], paused: ["paused", "active", "paused-incompatible", "recovery-required", "restoring"], @@ -26,7 +26,7 @@ export function transitionRuntimeState( current: RuntimeSessionState, next: RuntimeSessionState, ): RuntimeSessionState { - if (!ALLOWED[current.status].includes(next.status)) { + if (!RUNTIME_STATE_TRANSITIONS[current.status].includes(next.status)) { throw new RuntimeError( "RUNTIME_INVALID_STATE", `Illegal Runtime transition: ${current.status} -> ${next.status}`, diff --git a/scripts/contracts/build.ts b/scripts/contracts/build.ts new file mode 100644 index 0000000..d474b53 --- /dev/null +++ b/scripts/contracts/build.ts @@ -0,0 +1,303 @@ +import { mkdtemp, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join, relative, resolve, sep } from "node:path"; +import { pathToFileURL } from "node:url"; +import { + STUDIO_ERROR_HTTP_STATUS, + STUDIO_BODY_LIMITS, + STUDIO_HTTP_SEMANTIC_CASES, + STUDIO_PROTOCOL_VERSION, + STUDIO_ROUTE_DEFINITIONS, + STUDIO_SECURITY_HEADERS, + STUDIO_RESPONSE_POLICIES, + StudioApplyResultSchema, + StudioBootstrapSchema, + StudioCreateDraftInputSchema, + StudioDeleteThemeInputSchema, + StudioDraftCommandInputSchema, + StudioDraftSchema, + StudioEventSchema, + StudioExportedThemeSchema, + StudioImportThemeInputSchema, + StudioRuntimeStatusSchema, + StudioSaveResultSchema, + StudioSessionExchangeSchema, + StudioThemeLibrarySchema, + StudioThemeRefSchema, + StudioUpdateDraftInputSchema, + StudioUploadAssetInputSchema, +} from "../../packages/theme-studio-core/src/index.js"; +import { + OCSKIN_ARCHIVE_SEMANTIC_CASES, + OCSKIN_MAX_ARCHIVE_BYTES, + OCSKIN_MAX_EXPANDED_BYTES, + OcskinManifestSchema, + ThemeRefSchema, + ThemeStateSchema, +} from "../../packages/theme-core/src/index.js"; +import { + THEME_MIGRATION_CONTRACT, + THEME_SCHEMA_ERROR_CODES, + THEME_SCHEMA_VERSION, + THEME_SEMANTIC_CASES, + ThemeDocumentSchema, + ThemeDocumentV2Schema, + ThemeDocumentV3FieldsSchema, + ThemeDocumentV4InputFieldsSchema, + ThemeDraftDocumentSchema, + LegacyThemeDocumentSchema, +} from "../../packages/theme-schema/src/index.js"; +import { + ControlErrorSchema, + ControlRequestSchema, + ControlResponseSchema, + ControllerLockRecordSchema, + RecentRequestSchema, + RUNTIME_CONTROL_CONTRACT, + RUNTIME_CONTROL_SEMANTIC_CASES, + RUNTIME_RECOVERY_SEMANTIC_CASES, + RUNTIME_STATE_TRANSITIONS, + RuntimeSessionStateSchema, + RuntimeStatusViewSchema, + TrustedCodexInstallSchema, +} from "../../runtime/windows/src/index.js"; +import { + DraftRecordSchema, + PersistedDraftRecordSchema, +} from "../../runtime/theme-studio-service/src/workspace.js"; +import { ReleaseManifestSchema } from "../release/payload.js"; +import type { ZodTypeAny } from "zod"; +import { zodToJsonSchema } from "zod-to-json-schema"; + +const GENERATED_FILES = [ + "data/v1/cases/compatibility.json", + "data/v1/schemas/index.json", + "runtime/v1/cases/semantics.json", + "runtime/v1/frames.json", + "runtime/v1/schemas/index.json", + "studio/v2/cases/http.json", + "studio/v2/routes.json", + "studio/v2/schemas/index.json", + "theme/v4/archive-cases.json", + "theme/v4/draft-schema.json", + "theme/v4/migrations.json", + "theme/v4/schema.json", + "theme/v4/semantic-cases.json", +] as const; + +const GENERATED_ROOTS = ["studio/v2", "runtime/v1", "theme/v4", "data/v1"] as const; + +const DATA_COMPATIBILITY_CASES = [ + { name: "draft v1", valid: true, schema: "draftRecord", version: 1 }, + { name: "theme store v1", valid: true, schema: "themeStore", version: 1 }, + { name: "runtime state v2", valid: true, schema: "runtimeState", version: 2 }, + { name: "trusted install cache v1", valid: true, schema: "trustedInstall", version: 1 }, + { name: "controller lock v1", valid: true, schema: "controllerLock", version: 1 }, + { name: "release manifest v1", valid: true, schema: "releaseManifest", version: 1 }, + { name: "unknown draft version", valid: false, schema: "draftRecord", expectedErrorCode: "DATA_SCHEMA_INVALID", expectedPath: "/schemaVersion" }, + { name: "dirty draft without history", valid: false, schema: "draftRecord", expectedErrorCode: "DATA_SCHEMA_INVALID", expectedPath: "/past" }, + { name: "runtime request ID mismatch", valid: false, schema: "runtimeState", expectedErrorCode: "RUNTIME_INVALID_STATE", expectedPath: "/recentRequests/0/requestId" }, + { name: "trusted cache identity mismatch", valid: false, schema: "trustedInstall", expectedErrorCode: "CODEX_IDENTITY_INVALID", expectedPath: "/packagePublisher" }, + { name: "lock without process identity", valid: false, schema: "controllerLock", expectedErrorCode: "RUNTIME_SESSION_STALE", expectedPath: "/startedAt" }, + { name: "release file hash malformed", valid: false, schema: "releaseManifest", expectedErrorCode: "RELEASE_MANIFEST_INVALID", expectedPath: "/files/sha256" }, +] as const; + +function portable(path: string): string { + return path.split(sep).join("/"); +} + +function stable(value: unknown): unknown { + if (Array.isArray(value)) return value.map(stable); + if (typeof value !== "object" || value === null) return value; + return Object.fromEntries(Object.entries(value) + .filter(([, entry]) => entry !== undefined) + .sort(([left], [right]) => left.localeCompare(right, "en")) + .map(([key, entry]) => [key, stable(entry)])); +} + +function json(value: unknown): string { + return `${JSON.stringify(stable(value), null, 2)}\n`; +} + +function schema(value: ZodTypeAny, name: string): unknown { + return zodToJsonSchema(value, { + name, + target: "jsonSchema7", + $refStrategy: "none", + errorMessages: true, + }); +} + +function schemaIndex( + version: number, + values: Readonly>, +): unknown { + return { + schemaVersion: version, + schemas: Object.fromEntries(Object.entries(values).map(([name, value]) => [ + name, + schema(value, name), + ])), + }; +} + +async function writeJson(root: string, path: string, value: unknown): Promise { + const destination = join(root, ...path.split("/")); + await mkdir(dirname(destination), { recursive: true }); + await writeFile(destination, json(value), "utf8"); +} + +async function walk(root: string, current = root): Promise { + const result: string[] = []; + for (const entry of await readdir(current, { withFileTypes: true })) { + const path = join(current, entry.name); + if (entry.isDirectory()) result.push(...await walk(root, path)); + else if (entry.isFile()) result.push(portable(relative(root, path))); + } + return result.sort(); +} + +export async function buildContracts( + workspaceRootInput: string, + outputRootInput = join(workspaceRootInput, "contracts"), +): Promise { + const workspaceRoot = resolve(workspaceRootInput); + const outputRoot = resolve(outputRootInput); + await readFile(join(workspaceRoot, "package.json"), "utf8"); + for (const root of GENERATED_ROOTS) { + await rm(join(outputRoot, ...root.split("/")), { recursive: true, force: true }); + } + + await Promise.all([ + writeJson(outputRoot, "studio/v2/routes.json", { + protocolVersion: STUDIO_PROTOCOL_VERSION, + securityHeaders: STUDIO_SECURITY_HEADERS, + bodyLimits: STUDIO_BODY_LIMITS, + responsePolicies: STUDIO_RESPONSE_POLICIES, + errorStatus: STUDIO_ERROR_HTTP_STATUS, + routes: STUDIO_ROUTE_DEFINITIONS, + }), + writeJson(outputRoot, "studio/v2/schemas/index.json", schemaIndex(2, { + sessionExchange: StudioSessionExchangeSchema, + themeRef: StudioThemeRefSchema, + runtimeStatus: StudioRuntimeStatusSchema, + bootstrap: StudioBootstrapSchema, + themeLibrary: StudioThemeLibrarySchema, + draft: StudioDraftSchema, + createDraft: StudioCreateDraftInputSchema, + updateDraft: StudioUpdateDraftInputSchema, + draftCommand: StudioDraftCommandInputSchema, + deleteTheme: StudioDeleteThemeInputSchema, + uploadAsset: StudioUploadAssetInputSchema, + importTheme: StudioImportThemeInputSchema, + exportedTheme: StudioExportedThemeSchema, + saveResult: StudioSaveResultSchema, + applyResult: StudioApplyResultSchema, + event: StudioEventSchema, + })), + writeJson(outputRoot, "studio/v2/cases/http.json", { + protocolVersion: STUDIO_PROTOCOL_VERSION, + cases: STUDIO_HTTP_SEMANTIC_CASES, + }), + writeJson(outputRoot, "runtime/v1/frames.json", RUNTIME_CONTROL_CONTRACT), + writeJson(outputRoot, "runtime/v1/schemas/index.json", schemaIndex(1, { + request: ControlRequestSchema, + response: ControlResponseSchema, + status: RuntimeStatusViewSchema, + error: ControlErrorSchema, + })), + writeJson(outputRoot, "runtime/v1/cases/semantics.json", { + protocolVersion: RUNTIME_CONTROL_CONTRACT.protocolVersion, + cases: RUNTIME_CONTROL_SEMANTIC_CASES, + recoveryCases: RUNTIME_RECOVERY_SEMANTIC_CASES, + stateTransitions: RUNTIME_STATE_TRANSITIONS, + }), + writeJson(outputRoot, "theme/v4/schema.json", { + schemaVersion: THEME_SCHEMA_VERSION, + errorCodes: THEME_SCHEMA_ERROR_CODES, + schema: schema(ThemeDocumentSchema, "ThemeDocument"), + }), + writeJson(outputRoot, "theme/v4/draft-schema.json", { + schemaVersion: THEME_SCHEMA_VERSION, + schema: schema(ThemeDraftDocumentSchema, "ThemeDraftDocument"), + }), + writeJson(outputRoot, "theme/v4/migrations.json", { + ...THEME_MIGRATION_CONTRACT, + sourceSchemas: { + v1: schema(LegacyThemeDocumentSchema, "ThemeDocumentV1"), + v2: schema(ThemeDocumentV2Schema, "ThemeDocumentV2"), + v3: schema(ThemeDocumentV3FieldsSchema, "ThemeDocumentV3"), + v4: schema(ThemeDocumentV4InputFieldsSchema, "ThemeDocumentV4Input"), + }, + }), + writeJson(outputRoot, "theme/v4/semantic-cases.json", { + schemaVersion: THEME_SCHEMA_VERSION, + cases: THEME_SEMANTIC_CASES, + }), + writeJson(outputRoot, "theme/v4/archive-cases.json", { + schemaVersion: 1, + limits: { + archiveBytes: OCSKIN_MAX_ARCHIVE_BYTES, + expandedBytes: OCSKIN_MAX_EXPANDED_BYTES, + }, + manifestSchema: schema(OcskinManifestSchema, "OcskinManifest"), + cases: OCSKIN_ARCHIVE_SEMANTIC_CASES, + }), + writeJson(outputRoot, "data/v1/schemas/index.json", schemaIndex(1, { + draftRecord: DraftRecordSchema, + persistedDraftRecord: PersistedDraftRecordSchema, + themeRef: ThemeRefSchema, + themeStore: ThemeStateSchema, + runtimeState: RuntimeSessionStateSchema, + recentRuntimeRequest: RecentRequestSchema, + trustedInstall: TrustedCodexInstallSchema, + controllerLock: ControllerLockRecordSchema, + releaseManifest: ReleaseManifestSchema, + })), + writeJson(outputRoot, "data/v1/cases/compatibility.json", { + schemaVersion: 1, + cases: DATA_COMPATIBILITY_CASES, + }), + ]); +} + +export async function verifyGeneratedContracts( + workspaceRootInput: string, +): Promise<{ readonly fileCount: 13; readonly verified: true }> { + const workspaceRoot = resolve(workspaceRootInput); + const expectedRoot = join(workspaceRoot, "contracts"); + const temporaryRoot = await mkdtemp(join(tmpdir(), "openchatgptskin-contracts-")); + try { + await buildContracts(workspaceRoot, temporaryRoot); + const actualFiles = (await Promise.all(GENERATED_ROOTS.map(async (root) => { + const directory = join(expectedRoot, ...root.split("/")); + try { + return (await walk(directory)).map((path) => `${root}/${path}`); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return []; + throw error; + } + }))).flat().sort(); + if (JSON.stringify(actualFiles) !== JSON.stringify(GENERATED_FILES)) { + throw new Error("Checked-in contract file list is stale; run npm run contracts:build"); + } + for (const path of GENERATED_FILES) { + const [expected, generated] = await Promise.all([ + readFile(join(expectedRoot, ...path.split("/"))), + readFile(join(temporaryRoot, ...path.split("/"))), + ]); + if (!expected.equals(generated)) { + throw new Error(`Checked-in contract is stale: ${path}`); + } + } + } finally { + await rm(temporaryRoot, { recursive: true, force: true }); + } + return { fileCount: 13, verified: true }; +} + +const invokedPath = process.argv[1] ? pathToFileURL(resolve(process.argv[1])).href : ""; +if (invokedPath === import.meta.url) { + await buildContracts(process.cwd()); + process.stdout.write(`${json({ fileCount: GENERATED_FILES.length, generated: true })}`); +} diff --git a/scripts/contracts/verify.ts b/scripts/contracts/verify.ts new file mode 100644 index 0000000..dd7837c --- /dev/null +++ b/scripts/contracts/verify.ts @@ -0,0 +1,9 @@ +import { verifyGeneratedContracts } from "./build.js"; + +try { + const result = await verifyGeneratedContracts(process.cwd()); + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); +} catch (error) { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 1; +} diff --git a/scripts/release/payload.ts b/scripts/release/payload.ts index 4f0054b..fcf2ae4 100644 --- a/scripts/release/payload.ts +++ b/scripts/release/payload.ts @@ -11,6 +11,7 @@ import { writeFile, } from "node:fs/promises"; import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; +import { z } from "zod"; import { PRODUCT_VERSION_PATTERN } from "../../packages/theme-studio-core/src/security.js"; @@ -56,30 +57,26 @@ interface ThemeManifest { }>>; } -export interface ReleaseManifest { - readonly schemaVersion: 1; - readonly product: typeof PRODUCT; - readonly version: string; - readonly target: { - readonly platform: ReleasePlatform; - readonly arch: ReleaseArch; - }; - readonly runtime: { - readonly nodeVersion: string; - }; - readonly build: { - readonly commit: string; - }; - readonly themeCatalog: { - readonly schemaVersion: number; - }; - readonly entry: "OpenChatGPTSkin.cmd" | "OpenChatGPTSkin"; - readonly themes: readonly string[]; - readonly files: Readonly>; -} +export const ReleaseManifestSchema = z.object({ + schemaVersion: z.literal(1), + product: z.literal(PRODUCT), + version: z.string().regex(/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/), + target: z.object({ + platform: z.enum(["win32", "darwin"]), + arch: z.enum(["x64", "arm64"]), + }).strict(), + runtime: z.object({ nodeVersion: z.string().regex(/^\d+\.\d+\.\d+$/) }).strict(), + build: z.object({ commit: z.string().regex(/^[0-9a-f]{40}$/i) }).strict(), + themeCatalog: z.object({ schemaVersion: z.number().int().positive() }).strict(), + entry: z.enum(["OpenChatGPTSkin.cmd", "OpenChatGPTSkin"]), + themes: z.array(z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/)).min(1), + files: z.record(z.object({ + bytes: z.number().int().nonnegative(), + sha256: z.string().regex(/^[0-9a-f]{64}$/i), + }).strict()), +}).strict(); + +export type ReleaseManifest = z.infer; export interface StageReleasePayloadOptions { readonly workspaceRoot: string; @@ -577,5 +574,5 @@ export async function readReleaseManifest( throw new Error(`Release manifest file metadata is invalid: ${path}`); } } - return manifest as unknown as ReleaseManifest; + return ReleaseManifestSchema.parse(manifest); } diff --git a/tests/contracts/generator.test.ts b/tests/contracts/generator.test.ts new file mode 100644 index 0000000..5096afa --- /dev/null +++ b/tests/contracts/generator.test.ts @@ -0,0 +1,143 @@ +import { mkdtemp, readFile, readdir, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, relative } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + buildContracts, + verifyGeneratedContracts, +} from "../../scripts/contracts/build.js"; + +const temporaryRoots: string[] = []; + +afterEach(async () => { + await Promise.all(temporaryRoots.splice(0).map((root) => + rm(root, { recursive: true, force: true }) + )); +}); + +async function files(root: string, current = root): Promise { + const result: string[] = []; + for (const entry of await readdir(current, { withFileTypes: true })) { + const path = join(current, entry.name); + if (entry.isDirectory()) result.push(...await files(root, path)); + if (entry.isFile()) result.push(relative(root, path).replaceAll("\\", "/")); + } + return result.sort(); +} + +async function contents(root: string): Promise> { + return Object.fromEntries(await Promise.all((await files(root)).map(async (path) => [ + path, + await readFile(join(root, ...path.split("/")), "utf8"), + ]))); +} + +describe("cross-language contract generator", () => { + it("generates the complete contract tree deterministically", async () => { + const first = await mkdtemp(join(tmpdir(), "ocskin-contracts-first-")); + const second = await mkdtemp(join(tmpdir(), "ocskin-contracts-second-")); + temporaryRoots.push(first, second); + + await buildContracts(process.cwd(), first); + await buildContracts(process.cwd(), second); + + expect(await files(first)).toEqual([ + "data/v1/cases/compatibility.json", + "data/v1/schemas/index.json", + "runtime/v1/cases/semantics.json", + "runtime/v1/frames.json", + "runtime/v1/schemas/index.json", + "studio/v2/cases/http.json", + "studio/v2/routes.json", + "studio/v2/schemas/index.json", + "theme/v4/archive-cases.json", + "theme/v4/draft-schema.json", + "theme/v4/migrations.json", + "theme/v4/schema.json", + "theme/v4/semantic-cases.json", + ]); + expect(await contents(second)).toEqual(await contents(first)); + }); + + it("keeps every generated negative semantic case actionable", async () => { + const output = await mkdtemp(join(tmpdir(), "ocskin-contracts-cases-")); + temporaryRoots.push(output); + await buildContracts(process.cwd(), output); + + for (const path of [ + "studio/v2/cases/http.json", + "runtime/v1/cases/semantics.json", + "theme/v4/semantic-cases.json", + "theme/v4/archive-cases.json", + "data/v1/cases/compatibility.json", + ]) { + const document = JSON.parse(await readFile(join(output, ...path.split("/")), "utf8")) as { + readonly cases: readonly { + readonly valid: boolean; + readonly expectedErrorCode?: string; + readonly expectedPath?: string; + }[]; + }; + const negative = document.cases.filter((entry) => !entry.valid); + expect(negative.length, path).toBeGreaterThan(0); + expect(negative.every((entry) => + Boolean(entry.expectedErrorCode) && Boolean(entry.expectedPath) + ), path).toBe(true); + } + }); + + it("captures the complete Studio surface and Runtime recovery model", async () => { + const output = await mkdtemp(join(tmpdir(), "ocskin-contracts-complete-")); + temporaryRoots.push(output); + await buildContracts(process.cwd(), output); + + const studio = JSON.parse(await readFile( + join(output, "studio", "v2", "routes.json"), + "utf8", + )) as { + readonly routes: readonly { readonly id: string; readonly path: string }[]; + readonly bodyLimits: Readonly>; + readonly responsePolicies: Readonly>; + }; + expect(studio.routes).toContainEqual(expect.objectContaining({ id: "home", path: "/" })); + expect(studio.bodyLimits).toEqual(expect.objectContaining({ + sessionJsonBytes: 16 * 1024, + jsonBytes: 256 * 1024, + imageBytes: 50 * 1024 * 1024, + archiveBytes: 32 * 1024 * 1024, + })); + expect(Object.keys(studio.responsePolicies).sort()).toEqual(["binary", "html", "json", "sse"]); + + const runtime = JSON.parse(await readFile( + join(output, "runtime", "v1", "cases", "semantics.json"), + "utf8", + )) as { + readonly recoveryCases: readonly unknown[]; + readonly stateTransitions: Readonly>; + }; + expect(runtime.recoveryCases.length).toBeGreaterThan(0); + expect(runtime.stateTransitions).toMatchObject({ + active: expect.arrayContaining(["paused", "restoring"]), + restoring: expect.arrayContaining(["restored-awaiting-exit"]), + }); + }); + + it("verifies checked-in contracts without rewriting them", async () => { + await expect(verifyGeneratedContracts(process.cwd())).resolves.toEqual({ + fileCount: 13, + verified: true, + }); + }); + + it("exposes build and verify package commands", async () => { + const packageJson = JSON.parse(await readFile("package.json", "utf8")) as { + readonly scripts?: Readonly>; + }; + expect(packageJson.scripts?.["contracts:build"]).toBe( + "tsx --tsconfig tsconfig.scripts.json scripts/contracts/build.ts", + ); + expect(packageJson.scripts?.["contracts:verify"]).toBe( + "tsx --tsconfig tsconfig.scripts.json scripts/contracts/verify.ts", + ); + }); +}); diff --git a/tsconfig.scripts.json b/tsconfig.scripts.json index 390e87f..08ed034 100644 --- a/tsconfig.scripts.json +++ b/tsconfig.scripts.json @@ -13,6 +13,15 @@ ], "@open-chatgpt-skin/theme-core": [ "./packages/theme-core/src/index.ts" + ], + "@open-chatgpt-skin/theme-studio-core": [ + "./packages/theme-studio-core/src/index.ts" + ], + "@open-chatgpt-skin/cdp-adapter": [ + "./packages/cdp-adapter/src/index.ts" + ], + "@open-chatgpt-skin/windows-runtime": [ + "./runtime/windows/src/index.ts" ] } }, From 046dbafe332b8582834fd72686fd0e56b3cf01d5 Mon Sep 17 00:00:00 2001 From: u2bo <285077078@qq.com> Date: Fri, 24 Jul 2026 17:06:20 +0800 Subject: [PATCH 03/23] test: add node compatibility baseline runner --- host/go/testdata/v0.2.0/data-root/corpus.json | 30 + host/go/testdata/v0.2.0/golden/node.json | 93 +++ package-lock.json | 1 + package.json | 2 + scripts/contracts/baseline-runner.ts | 735 ++++++++++++++++++ tests/contracts/data-compatibility.test.ts | 37 + tests/contracts/runtime-baseline.test.ts | 20 + tests/contracts/studio-baseline.test.ts | 19 + tests/contracts/theme-baseline.test.ts | 24 + 9 files changed, 961 insertions(+) create mode 100644 host/go/testdata/v0.2.0/data-root/corpus.json create mode 100644 host/go/testdata/v0.2.0/golden/node.json create mode 100644 scripts/contracts/baseline-runner.ts create mode 100644 tests/contracts/data-compatibility.test.ts create mode 100644 tests/contracts/runtime-baseline.test.ts create mode 100644 tests/contracts/studio-baseline.test.ts create mode 100644 tests/contracts/theme-baseline.test.ts diff --git a/host/go/testdata/v0.2.0/data-root/corpus.json b/host/go/testdata/v0.2.0/data-root/corpus.json new file mode 100644 index 0000000..e36bf2d --- /dev/null +++ b/host/go/testdata/v0.2.0/data-root/corpus.json @@ -0,0 +1,30 @@ +{ + "schemaVersion": 1, + "draft": { + "draftId": "00000000-0000-4000-8000-000000000301", + "revision": 6, + "dirty": true, + "past": 3, + "future": 0, + "savedRef": { + "id": "baseline-draft", + "version": "1.0.0" + } + }, + "personalTheme": { + "id": "baseline-personal", + "versions": ["1.0.0", "1.1.0"] + }, + "runtime": { + "sessionId": "00000000-0000-4000-8000-000000000302", + "status": "restored-awaiting-exit", + "theme": { + "id": "mountain-mist", + "version": "1.3.0" + } + }, + "trustedCacheSchemaVersion": 1, + "controllerLockSchemaVersion": 1, + "corruptRecordCount": 4, + "corruptStoreCount": 4 +} diff --git a/host/go/testdata/v0.2.0/golden/node.json b/host/go/testdata/v0.2.0/golden/node.json new file mode 100644 index 0000000..6839202 --- /dev/null +++ b/host/go/testdata/v0.2.0/golden/node.json @@ -0,0 +1,93 @@ +{ + "implementation": "node", + "suites": [ + { + "implementation": "node", + "suite": "studio", + "result": { + "protocolVersion": 2, + "sessionStatus": 204, + "bootstrapStatus": 200, + "unauthenticatedStatus": 401, + "securityHeaderCount": 5, + "eventKind": "runtime-status" + } + }, + { + "implementation": "node", + "suite": "runtime", + "result": { + "protocolVersion": 1, + "frameLimitBytes": 65536, + "transportSecurityVerified": true, + "status": "stopped", + "launchStatus": "active", + "replayed": true, + "conflictingRequestCode": "RUNTIME_CONTROL_UNAVAILABLE" + } + }, + { + "implementation": "node", + "suite": "theme", + "result": { + "builtins": [ + "future-idol-cyan", + "glacier-aurora", + "mountain-mist", + "rose-carpet-star", + "yua-mikami-starlight" + ], + "migratedVersions": [ + 1, + 2, + 3, + 4 + ], + "archiveRoundTrips": 5, + "archiveNegativeCode": "ARCHIVE_ENTRY_UNSAFE", + "futureVersionCode": "THEME_SCHEMA_VERSION_UNSUPPORTED", + "v4Capabilities": { + "welcomeLocales": [ + "en", + "zh-CN" + ], + "displayFont": true, + "profileAvatar": true, + "suggestionIcons": 4, + "compositionLayers": 4 + } + } + }, + { + "implementation": "node", + "suite": "data", + "result": { + "draft": { + "schemaVersion": 1, + "dirty": true, + "revision": 6, + "past": 3, + "future": 0, + "savedRef": { + "id": "baseline-draft", + "version": "1.0.0" + }, + "savedDraftWasClean": true, + "undoExposedRedo": true, + "redoConsumedFuture": true + }, + "personalVersions": 2, + "runtimeTerminalStatus": "restored-awaiting-exit", + "trustedCacheSchemaVersion": 1, + "controllerLockSchemaVersion": 1, + "corruptRecordsRejected": 4, + "corruptStoresRejected": 4, + "sensitiveValuesFound": false + } + } + ], + "review": { + "status": "reviewed", + "knownNodeBugs": [] + } +} diff --git a/package-lock.json b/package-lock.json index 144da0b..8cab6d7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "devDependencies": { "@types/node": "^22.20.1", "@types/ws": "^8.18.1", + "fflate": "^0.8.3", "linkedom": "^0.18.12", "sharp": "^0.34.5", "tsx": "^4.23.1", diff --git a/package.json b/package.json index 5e9ae63..7c17411 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "go:baseline:verify": "tsx --tsconfig tsconfig.scripts.json scripts/contracts/verify-baseline.ts", "contracts:build": "tsx --tsconfig tsconfig.scripts.json scripts/contracts/build.ts", "contracts:verify": "tsx --tsconfig tsconfig.scripts.json scripts/contracts/verify.ts", + "contracts:baseline": "tsx --tsconfig tsconfig.scripts.json scripts/contracts/baseline-runner.ts", "typecheck": "tsc -b --pretty false", "test": "vitest run", "test:watch": "vitest", @@ -50,6 +51,7 @@ "devDependencies": { "@types/node": "^22.20.1", "@types/ws": "^8.18.1", + "fflate": "^0.8.3", "linkedom": "^0.18.12", "sharp": "^0.34.5", "tsx": "^4.23.1", diff --git a/scripts/contracts/baseline-runner.ts b/scripts/contracts/baseline-runner.ts new file mode 100644 index 0000000..f3964cb --- /dev/null +++ b/scripts/contracts/baseline-runner.ts @@ -0,0 +1,735 @@ +import { execFile } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import { access, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; +import { promisify } from "node:util"; +import { + loadThemeCatalog, + loadThemeDirectory, + packTheme, + ThemeStore, + unpackTheme, + validateThemeBundle, + type ValidatedThemeBundle, +} from "../../packages/theme-core/src/index.js"; +import { + parseThemeDocument, + type ThemeDocument, +} from "../../packages/theme-schema/src/index.js"; +import { + STUDIO_SECURITY_HEADERS, +} from "../../packages/theme-studio-core/src/index.js"; +import { + CONTROL_MAX_FRAME_BYTES, + ControlRequestSchema, + ControlResponseSchema, + ControllerLock, + ControllerLockRecordSchema, + createRuntimePaths, + decodeControlFrame, + encodeControlFrame, + controlEndpointForIdentity, + PowerShellWindowsProvider, + RuntimeControlDispatcher, + RuntimeStateStore, + RuntimeSessionStateSchema, + sendControlRequest, + startSecureControlServer, + TrustedInstallStore, + TrustedCodexInstallSchema, + type ControlRequest, + type RecentRequest, + type RuntimeController, + type RuntimeStateStore, + type RuntimeStatusView, +} from "../../runtime/windows/src/index.js"; +import { + DraftRecordSchema, + startThemeStudioServer, + ThemeStudioWorkspace, +} from "../../runtime/theme-studio-service/src/index.js"; +import { strToU8, zipSync } from "fflate"; + +export type BaselineImplementation = "node" | "go"; +export type BaselineSuite = "studio" | "runtime" | "theme" | "data"; + +export interface BaselineSuiteResult { + readonly implementation: BaselineImplementation; + readonly suite: BaselineSuite; + readonly result: Readonly>; +} + +export class BaselineRunnerError extends Error { + constructor(public readonly code: string, message: string) { + super(message); + this.name = "BaselineRunnerError"; + } +} + +const execFileAsync = promisify(execFile); + +const stoppedStatus: RuntimeStatusView = { + status: "stopped", + controllerAvailable: true, + selectedTheme: null, + appliedTheme: null, + skinApplied: false, + packageVersion: null, + operation: null, + nextAction: "None", +}; + +function activeStatus(themeId: string, version: string): RuntimeStatusView { + return { + ...stoppedStatus, + status: "active", + selectedTheme: { id: themeId, version }, + appliedTheme: { id: themeId, version }, + skinApplied: true, + packageVersion: "26.707.12708.0", + }; +} + +async function runStudioSuite(): Promise> { + const token = "a".repeat(64); + const server = await startThemeStudioServer({ + studioVersion: "0.2.0", + runtimeStatus: async () => stoppedStatus, + indexHtml: "OpenChatGPTSkin baseline", + newToken: () => token, + }); + try { + const unauthenticated = await fetch(`${server.origin}/api/bootstrap`); + const exchange = await fetch(`${server.origin}/api/session`, { + method: "POST", + headers: { "Content-Type": "application/json", Origin: server.origin }, + body: JSON.stringify({ token }), + }); + const cookie = exchange.headers.get("set-cookie")?.split(";", 1)[0]; + if (!cookie) throw new Error("Studio baseline session cookie is missing"); + const bootstrap = await fetch(`${server.origin}/api/bootstrap`, { + headers: { Cookie: cookie }, + }); + const bootstrapBody = await bootstrap.json() as { readonly protocolVersion?: unknown }; + const events = await fetch(`${server.origin}/api/events`, { + headers: { Cookie: cookie }, + }); + const reader = events.body?.getReader(); + if (!reader) throw new Error("Studio baseline SSE body is missing"); + const first = await reader.read(); + await reader.cancel(); + const eventText = new TextDecoder().decode(first.value); + const dataLine = eventText.split("\n").find((line) => line.startsWith("data: ")); + if (!dataLine) throw new Error("Studio baseline SSE event is missing"); + const event = JSON.parse(dataLine.slice("data: ".length)) as { readonly kind?: unknown }; + + return { + protocolVersion: bootstrapBody.protocolVersion, + sessionStatus: exchange.status, + bootstrapStatus: bootstrap.status, + unauthenticatedStatus: unauthenticated.status, + securityHeaderCount: STUDIO_SECURITY_HEADERS.filter((header) => + bootstrap.headers.has(header) + ).length, + eventKind: event.kind, + }; + } finally { + await server.close(); + } +} + +function runtimeController(): RuntimeController { + const controller = { + status: async () => stoppedStatus, + launch: async (id: string, _requestId: string, version = "1.3.0") => + activeStatus(id, version), + switchTheme: async (id: string, _requestId: string, version = "1.3.0") => + activeStatus(id, version), + pause: async () => ({ ...stoppedStatus, status: "paused" as const }), + resume: async () => activeStatus("mountain-mist", "1.3.0"), + restore: async () => ({ ...stoppedStatus, status: "restored-awaiting-exit" as const }), + startExitMonitoring: () => {}, + }; + return controller as unknown as RuntimeController; +} + +function runtimeState(): RuntimeStateStore { + const recentRequests: RecentRequest[] = []; + return { + read: async () => ({ recentRequests }), + appendRecentRequest: async (record: RecentRequest) => { + if (!recentRequests.some(({ requestId }) => requestId === record.requestId)) { + recentRequests.push(record); + } + return true; + }, + } as unknown as RuntimeStateStore; +} + +function baselineControlPlatform(): "win32" | "darwin" { + if (process.platform === "win32" || process.platform === "darwin") { + return process.platform; + } + if (process.platform === "linux") { + // Linux CI exercises the same UID, chmod(0600), dev/inode and stream + // framing contract through a Unix-domain socket. Native macOS proof remains + // a separate Gate A requirement and is never inferred from this corpus run. + return "darwin"; + } + throw new BaselineRunnerError( + "NODE_BASELINE_TRANSPORT_UNAVAILABLE", + `The Node baseline control transport does not support ${process.platform}`, + ); +} + +async function runRuntimeSuite(): Promise> { + const dispatcher = new RuntimeControlDispatcher(runtimeController(), runtimeState()); + const platform = baselineControlPlatform(); + const identity = platform === "win32" + ? await new PowerShellWindowsProvider().currentUserSid() + : (() => { + const uid = process.getuid?.(); + if (!Number.isInteger(uid) || uid! < 0) { + throw new BaselineRunnerError( + "NODE_BASELINE_TRANSPORT_UNAVAILABLE", + "The Node baseline Unix socket identity is unavailable", + ); + } + return `uid:${uid}`; + })(); + const endpoint = controlEndpointForIdentity(identity, platform); + const control = await startSecureControlServer({ + platform, + userIdentity: identity, + dispatch: (request) => dispatcher.dispatch(request), + }); + const dispatch = async (value: unknown) => sendControlRequest({ + sid: identity, + endpoint, + request: ControlRequestSchema.parse(value), + }); + const statusRequest: ControlRequest = { + protocolVersion: 1, + requestId: "00000000-0000-4000-8000-000000000201", + command: "status", + params: {}, + }; + const launchRequest: ControlRequest = { + protocolVersion: 1, + requestId: "00000000-0000-4000-8000-000000000202", + command: "launch", + params: { themeId: "mountain-mist" }, + }; + try { + const status = ControlResponseSchema.parse(await dispatch(statusRequest)); + const launched = ControlResponseSchema.parse(await dispatch(launchRequest)); + const replayed = ControlResponseSchema.parse(await dispatch(launchRequest)); + const conflicting = ControlResponseSchema.parse(await dispatch({ + ...launchRequest, + command: "pause", + params: {}, + })); + if (!status.ok || !launched.ok || !replayed.ok || conflicting.ok) { + throw new Error("Runtime baseline responses have unexpected success states"); + } + return { + protocolVersion: status.protocolVersion, + frameLimitBytes: CONTROL_MAX_FRAME_BYTES, + transportSecurityVerified: control.securityVerified, + status: status.result.status, + launchStatus: launched.result.status, + replayed: JSON.stringify(replayed) === JSON.stringify(launched), + conflictingRequestCode: conflicting.error.code, + }; + } finally { + await control.close(); + } +} + +function legacyTheme(theme: ThemeDocument, version: 1 | 2 | 3): unknown { + const value = structuredClone(theme) as Record; + value.schemaVersion = version; + delete value.home; + delete value.composition; + delete value.interfaceImages; + const typography = value.typography as Record; + for (const key of [ + "displayFamily", + "displayFontAssetKey", + "displaySize", + "displayWeight", + "displayLineHeight", + "displayLetterSpacing", + ]) delete typography[key]; + if (version <= 2) { + const assets = value.assets as Record; + delete assets.profileAvatar; + delete assets.suggestionIcons; + delete assets.projectIcons; + } + if (version === 1) { + const colors = value.colors as Record; + for (const key of ["textSecondary", "link", "inputText", "placeholder", "codeText"]) { + delete colors[key]; + } + } + return value; +} + +function errorCode(error: unknown): string { + return error && typeof error === "object" && "code" in error && + typeof (error as { readonly code?: unknown }).code === "string" + ? (error as { readonly code: string }).code + : error instanceof Error + ? error.name + : "UNKNOWN"; +} + +async function rejectsWithCode( + operation: () => Promise, + expectedCode: string, +): Promise { + try { + await operation(); + return false; + } catch (error) { + const actual = errorCode(error); + if (actual !== expectedCode) { + throw new Error(`Expected ${expectedCode}, received ${actual}`); + } + return true; + } +} + +async function runThemeSuite(workspaceRoot: string): Promise> { + const themesRoot = join(workspaceRoot, "themes"); + const catalog = await loadThemeCatalog(themesRoot); + const bundles: ValidatedThemeBundle[] = []; + for (const entry of catalog.builtins) { + bundles.push(await loadThemeDirectory(join(themesRoot, ...entry.path.split("/")))); + } + let archiveRoundTrips = 0; + for (const bundle of bundles) { + const unpacked = await unpackTheme(packTheme(bundle)); + if (unpacked.theme.id !== bundle.theme.id || unpacked.theme.version !== bundle.theme.version) { + throw new Error(`Theme archive identity changed: ${bundle.theme.id}`); + } + archiveRoundTrips += 1; + } + const migrationBase = bundles.find(({ theme }) => theme.id === "mountain-mist")?.theme; + if (!migrationBase) throw new Error("Theme migration baseline is missing mountain-mist"); + const migratedVersions = ([1, 2, 3] as const).map((version) => { + const parsed = parseThemeDocument(legacyTheme(migrationBase, version)); + if (parsed.schemaVersion !== 4) throw new Error(`Theme v${version} migration failed`); + return version; + }); + parseThemeDocument(migrationBase); + migratedVersions.push(4); + + let archiveNegativeCode = ""; + try { + await unpackTheme(zipSync({ "../secret.txt": strToU8("secret") })); + } catch (error) { + archiveNegativeCode = errorCode(error); + } + let futureVersionCode = ""; + try { + parseThemeDocument({ ...migrationBase, schemaVersion: 99 }); + } catch (error) { + futureVersionCode = errorCode(error); + } + const v4 = bundles.find(({ theme }) => theme.id === "yua-mikami-starlight")?.theme; + if (!v4) throw new Error("Theme v4 capability baseline is missing"); + return { + builtins: bundles.map(({ theme }) => theme.id), + migratedVersions, + archiveRoundTrips, + archiveNegativeCode, + futureVersionCode, + v4Capabilities: { + welcomeLocales: Object.keys(v4.home?.welcome.localized ?? {}).sort(), + displayFont: Boolean(v4.typography.displayFontAssetKey), + profileAvatar: Boolean(v4.assets.profileAvatar), + suggestionIcons: Object.keys(v4.assets.suggestionIcons ?? {}).length, + compositionLayers: v4.composition.layers.length, + }, + }; +} + +interface DataCorpus { + readonly schemaVersion: 1; + readonly draft: { + readonly draftId: string; + readonly revision: number; + readonly dirty: boolean; + readonly past: number; + readonly future: number; + readonly savedRef: { readonly id: string; readonly version: string }; + }; + readonly personalTheme: { readonly id: string; readonly versions: readonly string[] }; + readonly runtime: { + readonly sessionId: string; + readonly status: "restored-awaiting-exit"; + readonly theme: { readonly id: string; readonly version: string }; + }; + readonly trustedCacheSchemaVersion: 1; + readonly controllerLockSchemaVersion: 1; + readonly corruptRecordCount: number; + readonly corruptStoreCount: number; +} + +async function runDataSuite(workspaceRoot: string): Promise> { + const corpusPath = join( + workspaceRoot, + "host", "go", "testdata", "v0.2.0", "data-root", "corpus.json", + ); + const corpusText = await readFile(corpusPath, "utf8"); + const corpus = JSON.parse(corpusText) as DataCorpus; + const bundle = await loadThemeDirectory(join(workspaceRoot, "themes", "builtin", "mountain-mist")); + const timestamp = "2026-07-24T00:00:00.000Z"; + const temporaryRoot = await mkdtemp(join(tmpdir(), "openchatgptskin-data-corpus-")); + let personalVersions = 0; + let draftRecord!: ReturnType; + let savedDraftWasClean = false; + let undoExposedRedo = false; + let redoConsumedFuture = false; + let persistedRuntime!: ReturnType; + let persistedTrusted!: ReturnType; + let lockRecord!: ReturnType; + let corruptStoresRejected = 0; + try { + const paths = createRuntimePaths(temporaryRoot, workspaceRoot); + let firstId = true; + const workspace = new ThemeStudioWorkspace({ + paths, + runtimeStatus: async () => stoppedStatus, + applyRuntimeTheme: async (ref) => activeStatus(ref.id, ref.version), + restoreRuntimeTheme: async () => stoppedStatus, + now: () => timestamp, + newId: () => { + if (firstId) { + firstId = false; + return corpus.draft.draftId; + } + return randomUUID(); + }, + }); + await workspace.initialize(); + const source = (await workspace.listThemes()).themes.find(({ ref }) => + ref.id === bundle.theme.id && ref.version === bundle.theme.version + ); + if (!source) throw new Error("Data compatibility source theme is missing"); + let draft = await workspace.createDraft({ + source: { source: source.source, ref: source.ref }, + themeId: corpus.draft.savedRef.id, + name: "Baseline dataRoot draft", + }); + draft = await workspace.updateDraft({ + draftId: draft.draftId, + expectedRevision: draft.revision, + theme: { ...draft.theme, name: "Baseline dataRoot draft v2" }, + }); + draft = await workspace.updateDraft({ + draftId: draft.draftId, + expectedRevision: draft.revision, + theme: { ...draft.theme, description: "reviewed baseline draft" }, + }); + const undone = await workspace.undo({ + draftId: draft.draftId, + expectedRevision: draft.revision, + }); + undoExposedRedo = undone.redoAvailable; + const redone = await workspace.redo({ + draftId: undone.draftId, + expectedRevision: undone.revision, + }); + redoConsumedFuture = !redone.redoAvailable; + const saved = await workspace.saveVersion({ + draftId: redone.draftId, + expectedRevision: redone.revision, + }); + savedDraftWasClean = !saved.draft.dirty && saved.ref.id === corpus.draft.savedRef.id; + draft = await workspace.updateDraft({ + draftId: saved.draft.draftId, + expectedRevision: saved.draft.revision, + theme: { ...saved.draft.theme, description: "dirty after reviewed save" }, + }); + const draftPath = join(paths.themeStudioDraftDirectory, draft.draftId, "draft.json"); + draftRecord = DraftRecordSchema.parse(JSON.parse(await readFile(draftPath, "utf8"))); + if (draftRecord.revision !== corpus.draft.revision || + draftRecord.dirty !== corpus.draft.dirty || + draftRecord.past.length !== corpus.draft.past || + draftRecord.future.length !== corpus.draft.future || + draftRecord.savedRef?.id !== corpus.draft.savedRef.id || + draftRecord.savedRef?.version !== corpus.draft.savedRef.version || + !savedDraftWasClean || !undoExposedRedo || !redoConsumedFuture) { + throw new Error("Data compatibility draft lifecycle changed"); + } + + const store = new ThemeStore(paths.themeStoreDirectory); + for (const version of corpus.personalTheme.versions) { + const theme = parseThemeDocument({ + ...bundle.theme, + id: corpus.personalTheme.id, + version, + }); + await store.install(validateThemeBundle(theme, bundle.files)); + } + personalVersions = (await store.list()).filter(({ id }) => id === corpus.personalTheme.id).length; + + const runtime = RuntimeSessionStateSchema.parse({ + schemaVersion: 2, + sessionId: corpus.runtime.sessionId, + status: corpus.runtime.status, + runtime: { pid: process.pid, startedAt: timestamp }, + codex: { + rootPid: process.pid, + startedAt: timestamp, + executablePath: "C:/Program Files/WindowsApps/OpenAI.Codex/app/ChatGPT.exe", + packageRoot: "C:/Program Files/WindowsApps/OpenAI.Codex", + packageVersion: "26.707.12708.0", + }, + cdp: { host: "127.0.0.1", port: 55123 }, + adapter: { id: "baseline", version: 1 }, + selectedTheme: corpus.runtime.theme, + appliedTheme: null, + skinApplied: false, + pendingOperation: null, + recentRequests: [], + createdAt: timestamp, + updatedAt: timestamp, + }); + const stateStore = new RuntimeStateStore(paths.sessionFile); + await stateStore.write(runtime); + persistedRuntime = RuntimeSessionStateSchema.parse(await stateStore.read()); + + const trusted = TrustedCodexInstallSchema.parse({ + schemaVersion: corpus.trustedCacheSchemaVersion, + packageRoot: "C:/Program Files/WindowsApps/OpenAI.Codex", + entryPath: "C:/Program Files/WindowsApps/OpenAI.Codex/app/ChatGPT.exe", + identityName: "OpenAI.Codex", + packageVersion: "26.707.12708.0", + packagePublisher: "CN=50BDFD77-8903-4850-9FFE-6E8522F64D5B", + appId: "App", + entryRelativePath: "app/ChatGPT.exe", + entryPoint: "Windows.FullTrustApplication", + packageSignatureStatus: "Valid", + packageSignerCommonName: "50BDFD77-8903-4850-9FFE-6E8522F64D5B", + catalogSignatureStatus: "Valid", + catalogSignerCommonName: "50BDFD77-8903-4850-9FFE-6E8522F64D5B", + entryBlockMapValid: true, + resourceSignatureStatus: "Valid", + resourceSignerCommonName: "OpenAI OpCo, LLC", + verifiedAt: timestamp, + }); + const trustedStore = new TrustedInstallStore(paths.installCache); + await trustedStore.write(trusted); + persistedTrusted = TrustedCodexInstallSchema.parse(await trustedStore.read()); + + const lock = await ControllerLock.acquire( + paths.controllerLockFile, + { pid: process.pid, startedAt: timestamp }, + async (pid) => pid === process.pid ? timestamp : null, + ); + lockRecord = ControllerLockRecordSchema.parse(JSON.parse( + await readFile(paths.controllerLockFile, "utf8"), + )); + await lock.release(); + + await writeFile(draftPath, "{}\n", "utf8"); + if (await rejectsWithCode( + () => workspace.openDraft(draft.draftId), + "STUDIO_DRAFT_INVALID", + )) corruptStoresRejected += 1; + await writeFile(paths.sessionFile, "{}\n", "utf8"); + if (await rejectsWithCode( + () => stateStore.read(), + "RUNTIME_SESSION_STALE", + )) corruptStoresRejected += 1; + await writeFile(paths.installCache, "{}\n", "utf8"); + if (await trustedStore.read() === null) corruptStoresRejected += 1; + await writeFile(paths.controllerLockFile, "{}\n", "utf8"); + if (await rejectsWithCode( + () => ControllerLock.acquire( + paths.controllerLockFile, + { pid: process.pid, startedAt: timestamp }, + async () => null, + ), + "RUNTIME_SESSION_STALE", + )) corruptStoresRejected += 1; + } finally { + await rm(temporaryRoot, { recursive: true, force: true }); + } + + const corruptRecordsRejected = [ + DraftRecordSchema.safeParse({ ...draftRecord, schemaVersion: 9 }), + RuntimeSessionStateSchema.safeParse({ ...persistedRuntime, skinApplied: true }), + TrustedCodexInstallSchema.safeParse({ ...persistedTrusted, identityName: "Unknown.Product" }), + ControllerLockRecordSchema.safeParse({ ...lockRecord, startedAt: "invalid" }), + ].filter(({ success }) => !success).length; + const sensitiveValuesFound = /(?:[a-z]:[\\/]Users[\\/]|\/Users\/|\/home\/|token|conversation)/i + .test(corpusText); + if (corruptRecordsRejected !== corpus.corruptRecordCount) { + throw new Error("Data compatibility corrupt-record count changed"); + } + if (corruptStoresRejected !== corpus.corruptStoreCount) { + throw new Error("Data compatibility corrupt-store count changed"); + } + return { + draft: { + schemaVersion: draftRecord.schemaVersion, + dirty: draftRecord.dirty, + revision: draftRecord.revision, + past: draftRecord.past.length, + future: draftRecord.future.length, + savedRef: draftRecord.savedRef, + savedDraftWasClean, + undoExposedRedo, + redoConsumedFuture, + }, + personalVersions, + runtimeTerminalStatus: persistedRuntime.status, + trustedCacheSchemaVersion: persistedTrusted.schemaVersion, + controllerLockSchemaVersion: lockRecord.schemaVersion, + corruptRecordsRejected, + corruptStoresRejected, + sensitiveValuesFound, + }; +} + +async function runGoSuite( + workspaceRoot: string, + suite: BaselineSuite, +): Promise> { + const executable = process.env.OPENCHATGPTSKIN_GO_HOST ?? join( + workspaceRoot, + "host", + "go", + "bin", + process.platform === "win32" ? "OpenChatGPTSkin.exe" : "OpenChatGPTSkin", + ); + try { + await access(executable); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + throw new BaselineRunnerError( + "GO_BASELINE_IMPLEMENTATION_UNAVAILABLE", + "The Go baseline implementation is not available yet", + ); + } + throw error; + } + const corpusRoot = join(workspaceRoot, "host", "go", "testdata", "v0.2.0"); + const { stdout } = await execFileAsync(executable, [ + "contract-baseline", + "--suite", + suite, + "--corpus-root", + corpusRoot, + ], { encoding: "utf8", windowsHide: true }); + const value = JSON.parse(stdout) as unknown; + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new BaselineRunnerError( + "GO_BASELINE_RESULT_INVALID", + "The Go baseline implementation returned an invalid result", + ); + } + return value as Record; +} + +export async function runBaselineSuite( + workspaceRootInput: string, + implementation: BaselineImplementation, + suite: BaselineSuite, +): Promise { + const workspaceRoot = resolve(workspaceRootInput); + const result = implementation === "go" + ? await runGoSuite(workspaceRoot, suite) + : suite === "studio" + ? await runStudioSuite() + : suite === "runtime" + ? await runRuntimeSuite() + : suite === "theme" + ? await runThemeSuite(workspaceRoot) + : await runDataSuite(workspaceRoot); + return { implementation, suite, result }; +} + +export async function runBaselineCorpus( + workspaceRoot: string, + implementation: BaselineImplementation, +): Promise<{ + readonly implementation: BaselineImplementation; + readonly suites: readonly BaselineSuiteResult[]; + readonly review: { + readonly status: "reviewed"; + readonly knownNodeBugs: readonly string[]; + }; +}> { + const suites: BaselineSuiteResult[] = []; + for (const suite of ["studio", "runtime", "theme", "data"] as const) { + suites.push(await runBaselineSuite(workspaceRoot, implementation, suite)); + } + const result = { + implementation, + suites, + review: { status: "reviewed" as const, knownNodeBugs: [] as readonly string[] }, + }; + if (implementation === "node") { + const goldenPath = join( + workspaceRoot, + "host", "go", "testdata", "v0.2.0", "golden", "node.json", + ); + const golden = JSON.parse(await readFile(goldenPath, "utf8")) as unknown; + if (JSON.stringify(result) !== JSON.stringify(golden)) { + throw new BaselineRunnerError( + "NODE_BASELINE_GOLDEN_MISMATCH", + "The Node v0.2.0 observable result differs from the reviewed golden result", + ); + } + } + return result; +} + +function parseArguments(arguments_: readonly string[]): { + readonly implementation: BaselineImplementation; + readonly suite?: BaselineSuite; +} { + let implementation: BaselineImplementation | undefined; + let suite: BaselineSuite | undefined; + for (let index = 0; index < arguments_.length; index += 1) { + const argument = arguments_[index]; + if (argument === "--implementation") { + const value = arguments_[++index]; + if (value !== "node" && value !== "go") throw new Error("--implementation must be node or go"); + implementation = value; + } else if (argument === "--suite") { + const value = arguments_[++index]; + if (value !== "studio" && value !== "runtime" && value !== "theme" && value !== "data") { + throw new Error("--suite must be studio, runtime, theme, or data"); + } + suite = value; + } else { + throw new Error(`Unknown baseline runner argument: ${argument}`); + } + } + if (!implementation) throw new Error("--implementation is required"); + return suite === undefined ? { implementation } : { implementation, suite }; +} + +const invokedPath = process.argv[1] ? pathToFileURL(resolve(process.argv[1])).href : ""; +if (invokedPath === import.meta.url) { + try { + const options = parseArguments(process.argv.slice(2)); + const result = options.suite + ? await runBaselineSuite(process.cwd(), options.implementation, options.suite) + : await runBaselineCorpus(process.cwd(), options.implementation); + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + } catch (error) { + const code = error instanceof BaselineRunnerError ? error.code : "BASELINE_RUNNER_FAILED"; + process.stderr.write(`${JSON.stringify({ error: { code, message: error instanceof Error ? error.message : String(error) } }, null, 2)}\n`); + process.exitCode = 1; + } +} diff --git a/tests/contracts/data-compatibility.test.ts b/tests/contracts/data-compatibility.test.ts new file mode 100644 index 0000000..1ada2bc --- /dev/null +++ b/tests/contracts/data-compatibility.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; +import { runBaselineSuite } from "../../scripts/contracts/baseline-runner.js"; + +describe("Node data compatibility corpus", () => { + it("replays isolated drafts, versions, Runtime terminal state, cache, lock, and corruption", async () => { + await expect(runBaselineSuite(process.cwd(), "node", "data")).resolves.toMatchObject({ + suite: "data", + implementation: "node", + result: { + draft: { + schemaVersion: 1, + dirty: true, + revision: 6, + past: 3, + future: 0, + savedRef: { id: "baseline-draft", version: "1.0.0" }, + savedDraftWasClean: true, + undoExposedRedo: true, + redoConsumedFuture: true, + }, + personalVersions: 2, + runtimeTerminalStatus: "restored-awaiting-exit", + trustedCacheSchemaVersion: 1, + controllerLockSchemaVersion: 1, + corruptRecordsRejected: 4, + corruptStoresRejected: 4, + sensitiveValuesFound: false, + }, + }); + }); + + it("fails explicitly when the requested implementation is unavailable", async () => { + await expect(runBaselineSuite(process.cwd(), "go", "data")).rejects.toMatchObject({ + code: "GO_BASELINE_IMPLEMENTATION_UNAVAILABLE", + }); + }); +}); diff --git a/tests/contracts/runtime-baseline.test.ts b/tests/contracts/runtime-baseline.test.ts new file mode 100644 index 0000000..d2a08b2 --- /dev/null +++ b/tests/contracts/runtime-baseline.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import { runBaselineSuite } from "../../scripts/contracts/baseline-runner.js"; + +describe("Node Runtime baseline corpus", () => { + it("replays framed requests through the real dispatcher", async () => { + await expect(runBaselineSuite(process.cwd(), "node", "runtime")).resolves.toMatchObject({ + suite: "runtime", + implementation: "node", + result: { + protocolVersion: 1, + frameLimitBytes: 65536, + transportSecurityVerified: true, + status: "stopped", + launchStatus: "active", + replayed: true, + conflictingRequestCode: "RUNTIME_CONTROL_UNAVAILABLE", + }, + }); + }); +}); diff --git a/tests/contracts/studio-baseline.test.ts b/tests/contracts/studio-baseline.test.ts new file mode 100644 index 0000000..de0eb36 --- /dev/null +++ b/tests/contracts/studio-baseline.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vitest"; +import { runBaselineSuite } from "../../scripts/contracts/baseline-runner.js"; + +describe("Node Studio baseline corpus", () => { + it("replays authentication, headers, bootstrap, and SSE over real loopback HTTP", async () => { + await expect(runBaselineSuite(process.cwd(), "node", "studio")).resolves.toMatchObject({ + suite: "studio", + implementation: "node", + result: { + protocolVersion: 2, + sessionStatus: 204, + bootstrapStatus: 200, + unauthenticatedStatus: 401, + securityHeaderCount: 5, + eventKind: "runtime-status", + }, + }); + }); +}); diff --git a/tests/contracts/theme-baseline.test.ts b/tests/contracts/theme-baseline.test.ts new file mode 100644 index 0000000..02a7e54 --- /dev/null +++ b/tests/contracts/theme-baseline.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import { runBaselineSuite } from "../../scripts/contracts/baseline-runner.js"; + +describe("Node Theme baseline corpus", () => { + it("replays built-ins, migrations, v4 capabilities, and ocskin cases", async () => { + await expect(runBaselineSuite(process.cwd(), "node", "theme")).resolves.toMatchObject({ + suite: "theme", + implementation: "node", + result: { + builtins: [ + "future-idol-cyan", + "glacier-aurora", + "mountain-mist", + "rose-carpet-star", + "yua-mikami-starlight", + ], + migratedVersions: [1, 2, 3, 4], + archiveRoundTrips: 5, + archiveNegativeCode: "ARCHIVE_ENTRY_UNSAFE", + futureVersionCode: "THEME_SCHEMA_VERSION_UNSUPPORTED", + }, + }); + }); +}); From dc1bac567019e75404148de4e681d7b0b494ca48 Mon Sep 17 00:00:00 2001 From: u2bo <285077078@qq.com> Date: Fri, 24 Jul 2026 18:14:40 +0800 Subject: [PATCH 04/23] feat: complete go host feasibility spike --- .github/workflows/go-host-spike.yml | 72 +++ contracts/baseline/v0.2.0/go-spike-sizes.json | 115 ++++ host/go/cmd/openchatgptskin/main.go | 11 + host/go/go.mod | 14 + host/go/go.sum | 10 + host/go/internal/app/app_test.go | 74 +++ host/go/internal/app/command.go | 48 ++ host/go/internal/app/controller.go | 219 +++++++ .../go/internal/app/controller_darwin_test.go | 18 + host/go/internal/app/controller_test.go | 100 +++ .../internal/app/controller_windows_test.go | 45 ++ host/go/internal/app/platform_darwin.go | 45 ++ host/go/internal/app/platform_windows.go | 57 ++ host/go/internal/app/run.go | 111 ++++ host/go/internal/app/runtime.go | 136 +++++ host/go/internal/app/studio.go | 41 ++ host/go/internal/control/control_test.go | 215 +++++++ host/go/internal/control/dispatcher.go | 169 ++++++ host/go/internal/control/framing.go | 70 +++ host/go/internal/control/transport.go | 133 ++++ host/go/internal/control/types.go | 28 + host/go/internal/image/pipeline.go | 303 ++++++++++ host/go/internal/image/pipeline_test.go | 183 ++++++ host/go/internal/image/replace_darwin.go | 9 + host/go/internal/image/replace_windows.go | 21 + host/go/internal/persistence/lock.go | 126 ++++ host/go/internal/persistence/lock_test.go | 75 +++ .../platform/macos/transport_darwin.go | 79 +++ .../platform/macos/transport_darwin_test.go | 78 +++ .../platform/windows/transport_windows.go | 109 ++++ .../windows/transport_windows_test.go | 41 ++ host/go/testdata/images/cases.json | 11 + package.json | 4 + scripts/release/accept-go-spike.ts | 13 + scripts/release/build-go-spike.ts | 18 + scripts/release/go-spike.ts | 569 ++++++++++++++++++ scripts/release/merge-go-spike.ts | 17 + tests/go-spike.test.ts | 82 +++ tests/workspace.test.ts | 2 +- 39 files changed, 3470 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/go-host-spike.yml create mode 100644 contracts/baseline/v0.2.0/go-spike-sizes.json create mode 100644 host/go/cmd/openchatgptskin/main.go create mode 100644 host/go/go.mod create mode 100644 host/go/go.sum create mode 100644 host/go/internal/app/app_test.go create mode 100644 host/go/internal/app/command.go create mode 100644 host/go/internal/app/controller.go create mode 100644 host/go/internal/app/controller_darwin_test.go create mode 100644 host/go/internal/app/controller_test.go create mode 100644 host/go/internal/app/controller_windows_test.go create mode 100644 host/go/internal/app/platform_darwin.go create mode 100644 host/go/internal/app/platform_windows.go create mode 100644 host/go/internal/app/run.go create mode 100644 host/go/internal/app/runtime.go create mode 100644 host/go/internal/app/studio.go create mode 100644 host/go/internal/control/control_test.go create mode 100644 host/go/internal/control/dispatcher.go create mode 100644 host/go/internal/control/framing.go create mode 100644 host/go/internal/control/transport.go create mode 100644 host/go/internal/control/types.go create mode 100644 host/go/internal/image/pipeline.go create mode 100644 host/go/internal/image/pipeline_test.go create mode 100644 host/go/internal/image/replace_darwin.go create mode 100644 host/go/internal/image/replace_windows.go create mode 100644 host/go/internal/persistence/lock.go create mode 100644 host/go/internal/persistence/lock_test.go create mode 100644 host/go/internal/platform/macos/transport_darwin.go create mode 100644 host/go/internal/platform/macos/transport_darwin_test.go create mode 100644 host/go/internal/platform/windows/transport_windows.go create mode 100644 host/go/internal/platform/windows/transport_windows_test.go create mode 100644 host/go/testdata/images/cases.json create mode 100644 scripts/release/accept-go-spike.ts create mode 100644 scripts/release/build-go-spike.ts create mode 100644 scripts/release/go-spike.ts create mode 100644 scripts/release/merge-go-spike.ts create mode 100644 tests/go-spike.test.ts diff --git a/.github/workflows/go-host-spike.yml b/.github/workflows/go-host-spike.yml new file mode 100644 index 0000000..4996900 --- /dev/null +++ b/.github/workflows/go-host-spike.yml @@ -0,0 +1,72 @@ +name: Go Host Feasibility Spike + +on: + workflow_dispatch: + +permissions: + contents: read + +jobs: + native-spike: + strategy: + fail-fast: false + matrix: + include: + - id: windows-x64 + runner: windows-latest + - id: macos-arm64 + runner: macos-15 + - id: macos-x64 + runner: macos-15-intel + runs-on: ${{ matrix.runner }} + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version: "22.18.0" + cache: npm + - uses: actions/setup-go@0a12ed9d6a96ab950c8f026ed9f722fe0da7ef32 + with: + go-version: "1.25.5" + cache-dependency-path: host/go/go.sum + - run: npm ci + - run: npm run go:spike:test + - if: runner.os == 'Windows' + shell: pwsh + run: choco install innosetup --version 6.7.1 --allow-downgrade --no-progress --yes + - run: npm run go:spike:package -- --output "${{ runner.temp }}/go-spike" --native-only + - run: npm run go:spike:acceptance -- --output "${{ runner.temp }}/go-spike" + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: go-host-spike-${{ matrix.id }} + path: ${{ runner.temp }}/go-spike/* + if-no-files-found: error + + combine-native-evidence: + needs: native-spike + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version: "22.18.0" + cache: npm + - run: npm ci + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 + with: + name: go-host-spike-windows-x64 + path: ${{ runner.temp }}/inputs/windows + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 + with: + name: go-host-spike-macos-arm64 + path: ${{ runner.temp }}/inputs/macos-arm64 + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 + with: + name: go-host-spike-macos-x64 + path: ${{ runner.temp }}/inputs/macos-x64 + - run: npm run go:spike:merge -- --input "${{ runner.temp }}/inputs/windows" --input "${{ runner.temp }}/inputs/macos-arm64" --input "${{ runner.temp }}/inputs/macos-x64" --output "${{ runner.temp }}/combined" + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: go-host-spike-combined + path: ${{ runner.temp }}/combined/* + if-no-files-found: error diff --git a/contracts/baseline/v0.2.0/go-spike-sizes.json b/contracts/baseline/v0.2.0/go-spike-sizes.json new file mode 100644 index 0000000..35d0cde --- /dev/null +++ b/contracts/baseline/v0.2.0/go-spike-sizes.json @@ -0,0 +1,115 @@ +{ + "schemaVersion": 1, + "candidateVersion": "0.3.0-alpha.1", + "measuredAt": "2026-07-24", + "toolchain": { + "go": "1.25.5", + "cgo": false, + "buildTags": ["nodynamic"], + "sidecars": [] + }, + "imageDecision": { + "selected": "gen2brain-webp-wasm2go-nodynamic-plus-internal-pipeline", + "capabilities": [ + "jpeg-png-webp-decode", + "exif-orientation", + "alpha-preservation", + "center-crop", + "catmull-rom-resize", + "webp-encode", + "bounded-input", + "atomic-output" + ], + "candidates": [ + { + "id": "gen2brain-webp-codec-only", + "kind": "libwebp-only", + "license": "MIT", + "cgo": false, + "sidecars": false, + "decision": "rejected", + "reason": "codec supports WebP decode/encode but has no crop, resize, input policy, or atomic file pipeline" + }, + { + "id": "bimg-libvips", + "kind": "complete-native-pipeline", + "license": "MIT plus LGPL-2.1-or-later libvips", + "cgo": true, + "sidecars": true, + "decision": "rejected", + "reason": "requires cross-platform CGO and libvips distribution, recreating the current native sidecar cost" + }, + { + "id": "gen2brain-webp-wasm2go-nodynamic-plus-internal-pipeline", + "kind": "complete-cgo-free-pipeline", + "license": "MIT plus BSD-3-Clause golang.org/x/image", + "cgo": false, + "sidecars": false, + "decision": "selected", + "reason": "complete reviewed corpus with deterministic CGo-free three-target builds" + } + ] + }, + "targets": [ + { + "target": "windows-x64", + "executableBytes": 6620672, + "stageBytes": 11548401, + "baselineStageBytes": 115070815 + }, + { + "target": "macos-arm64", + "executableBytes": 5968498, + "stageBytes": 10896227, + "baselineStageBytes": 136974297 + }, + { + "target": "macos-x64", + "executableBytes": 6405696, + "stageBytes": 11333423, + "baselineStageBytes": 141958550 + } + ], + "artifacts": [ + { + "kind": "windows-x64-zip", + "bytes": 7340127, + "sha256": "2716bae351a1c095243015d8d9ba673d514b9b689ca0127fe60552e1a5afa195", + "baselineBytes": 44744668, + "nativeVerified": true + }, + { + "kind": "windows-x64-setup", + "bytes": 8612015, + "sha256": "287c1482ea71abfe6359e9a7137b418593dfe5f85c5bf47b4909e5954aac9db4", + "baselineBytes": 34083496, + "nativeVerified": true + }, + { + "kind": "macos-arm64-tar.gz", + "bytes": 6945173, + "sha256": "0c5072a99ea9e5edf71a0926a3d2a9a3f08617297e69e718edaec55ef66d6f42", + "baselineBytes": 48772737, + "nativeVerified": false + }, + { + "kind": "macos-x64-tar.gz", + "bytes": 7150596, + "sha256": "9f43d9e80234e26a7713631ee24e4abad7f494a1ec8cddc199e64e2962fa5a74", + "baselineBytes": 51253278, + "nativeVerified": false + } + ], + "security": { + "scanner": "govulncheck", + "reachableThirdPartyVulnerabilities": 0, + "reachableStandardLibraryVulnerabilities": 10, + "blockingRemediation": "upgrade Go toolchain from 1.25.5 to at least 1.25.12 and rerun" + }, + "nativeEvidenceComplete": false, + "blockingEvidence": [ + "macOS ARM64 Unix socket round-trip and DMG require a native macOS run", + "macOS x64 Unix socket round-trip and DMG require a native macOS run", + "Go 1.25.5 standard library vulnerability findings require toolchain upgrade" + ] +} diff --git a/host/go/cmd/openchatgptskin/main.go b/host/go/cmd/openchatgptskin/main.go new file mode 100644 index 0000000..591fd5b --- /dev/null +++ b/host/go/cmd/openchatgptskin/main.go @@ -0,0 +1,11 @@ +package main + +import ( + "os" + + "github.com/u2bo/OpenChatGPTSkin/host/go/internal/app" +) + +func main() { + os.Exit(app.Main(os.Args[1:], os.Stdout, os.Stderr)) +} diff --git a/host/go/go.mod b/host/go/go.mod new file mode 100644 index 0000000..0342941 --- /dev/null +++ b/host/go/go.mod @@ -0,0 +1,14 @@ +module github.com/u2bo/OpenChatGPTSkin/host/go + +go 1.25.0 + +toolchain go1.25.5 + +require ( + github.com/Microsoft/go-winio v0.6.2 + github.com/gen2brain/webp v0.6.4 + golang.org/x/image v0.32.0 + golang.org/x/sys v0.10.0 +) + +require github.com/ebitengine/purego v0.10.1 // indirect diff --git a/host/go/go.sum b/host/go/go.sum new file mode 100644 index 0000000..1dda9fb --- /dev/null +++ b/host/go/go.sum @@ -0,0 +1,10 @@ +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/ebitengine/purego v0.10.1 h1:dewVBCBT2GaMu1SrNTYxQhgQBethzfhiwvZiLGP/qyY= +github.com/ebitengine/purego v0.10.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/gen2brain/webp v0.6.4 h1:SUDdmxADOAiPQ+5ylNmuHhuYf2dOi0KgKZHL5vpVCNU= +github.com/gen2brain/webp v0.6.4/go.mod h1:iGWMaCSw7t3I/Cv9llzEKmpnR36S8lS8VL/ZVjxU0JE= +golang.org/x/image v0.32.0 h1:6lZQWq75h7L5IWNk0r+SCpUJ6tUVd3v4ZHnbRKLkUDQ= +golang.org/x/image v0.32.0/go.mod h1:/R37rrQmKXtO6tYXAjtDLwQgFLHmhW+V6ayXlxzP2Pc= +golang.org/x/sys v0.10.0 h1:SqMFp9UcQJZa+pmYuAKjd9xq1f0j5rLcDIk0mj4qAsA= +golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/host/go/internal/app/app_test.go b/host/go/internal/app/app_test.go new file mode 100644 index 0000000..eb428fd --- /dev/null +++ b/host/go/internal/app/app_test.go @@ -0,0 +1,74 @@ +package app + +import ( + "context" + "encoding/json" + "net/http" + "testing" +) + +func TestParseDefaultsToStudio(t *testing.T) { + command, err := Parse(nil) + if err != nil { + t.Fatal(err) + } + if command.Role != RoleStudio { + t.Fatalf("role = %q, want %q", command.Role, RoleStudio) + } +} + +func TestParseRejectsUnknownRole(t *testing.T) { + _, err := Parse([]string{"unknown"}) + if err == nil || ErrorCode(err) != "CLI_ARGUMENT_INVALID" { + t.Fatalf("error = %v, want CLI_ARGUMENT_INVALID", err) + } +} + +func TestRuntimeRejectsMissingAndCommandSpecificThemeOptions(t *testing.T) { + dataRoot := t.TempDir() + _, err := runRuntime(context.Background(), []string{"launch", "--data-root", dataRoot}) + if err == nil || ErrorCode(err) != "CLI_ARGUMENT_INVALID" { + t.Fatalf("missing theme error = %v", err) + } + _, err = runRuntime(context.Background(), []string{ + "status", "--data-root", dataRoot, "--theme", "mountain-mist", + }) + if err == nil || ErrorCode(err) != "CLI_ARGUMENT_INVALID" { + t.Fatalf("status theme error = %v", err) + } +} + +func TestStudioHealthUsesLoopbackAndHasNoBusinessFallback(t *testing.T) { + studio, err := StartStudio(context.Background()) + if err != nil { + t.Fatal(err) + } + defer studio.Close() + + response, err := http.Get(studio.Origin + "/health") + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + t.Fatalf("health status = %d", response.StatusCode) + } + var body struct { + Role string `json:"role"` + } + if err := json.NewDecoder(response.Body).Decode(&body); err != nil { + t.Fatal(err) + } + if body.Role != "studio" { + t.Fatalf("health role = %q", body.Role) + } + + missing, err := http.Get(studio.Origin + "/api/themes") + if err != nil { + t.Fatal(err) + } + defer missing.Body.Close() + if missing.StatusCode != http.StatusNotFound { + t.Fatalf("business fallback status = %d, want 404", missing.StatusCode) + } +} diff --git a/host/go/internal/app/command.go b/host/go/internal/app/command.go new file mode 100644 index 0000000..eec4961 --- /dev/null +++ b/host/go/internal/app/command.go @@ -0,0 +1,48 @@ +package app + +import ( + "errors" + "fmt" +) + +type Role string + +const ( + RoleStudio Role = "studio" + RoleController Role = "controller" + RoleRuntime Role = "runtime" + RoleContract Role = "contract-baseline" +) + +type Command struct { + Role Role + Args []string +} + +type commandError struct { + code string + message string +} + +func (err commandError) Error() string { return err.message } + +func ErrorCode(err error) string { + var value commandError + if errors.As(err, &value) { + return value.code + } + return "INTERNAL" +} + +func Parse(arguments []string) (Command, error) { + if len(arguments) == 0 { + return Command{Role: RoleStudio}, nil + } + role := Role(arguments[0]) + switch role { + case RoleStudio, RoleController, RoleRuntime, RoleContract: + return Command{Role: role, Args: append([]string(nil), arguments[1:]...)}, nil + default: + return Command{}, commandError{code: "CLI_ARGUMENT_INVALID", message: fmt.Sprintf("unknown role: %s", arguments[0])} + } +} diff --git a/host/go/internal/app/controller.go b/host/go/internal/app/controller.go new file mode 100644 index 0000000..c8c9813 --- /dev/null +++ b/host/go/internal/app/controller.go @@ -0,0 +1,219 @@ +package app + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net" + "os" + "path/filepath" + "sync" + "time" + + "github.com/u2bo/OpenChatGPTSkin/host/go/internal/control" + "github.com/u2bo/OpenChatGPTSkin/host/go/internal/persistence" +) + +const maxStartupLineBytes = 8 * 1024 + +type startupHandshake struct { + OK bool `json:"ok"` + StartupID string `json:"startupId"` + Endpoint string `json:"endpoint,omitempty"` + ErrorCode string `json:"errorCode,omitempty"` +} + +type controllerModel struct { + mu sync.RWMutex + status string + themeID string + version string + terminal bool +} + +func newControllerDispatcher() (*control.Dispatcher, *controllerModel) { + model := &controllerModel{status: "stopped"} + dispatcher := control.NewDispatcher(func(_ context.Context, request control.Request) (control.Result, error) { + if request.Command == "status" { + model.mu.RLock() + defer model.mu.RUnlock() + return control.Result{"status": model.status, "themeId": model.themeID, "themeVersion": model.version}, nil + } + model.mu.Lock() + defer model.mu.Unlock() + switch request.Command { + case "launch", "switch", "resume": + params := control.ThemeParameters{} + if request.Command != "resume" { + var err error + params, err = control.DecodeThemeParameters(request) + if err != nil { + return nil, err + } + } + if params.ThemeID != "" { + model.themeID = params.ThemeID + } + if params.ThemeVersion != "" { + model.version = params.ThemeVersion + } + model.status = "active" + case "pause": + model.status = "paused" + case "restore": + model.status = "stopped" + model.themeID, model.version = "", "" + model.terminal = true + } + return control.Result{"status": model.status, "themeId": model.themeID, "themeVersion": model.version}, nil + }) + return dispatcher, model +} + +func (model *controllerModel) isTerminal() bool { + model.mu.RLock() + defer model.mu.RUnlock() + return model.terminal +} + +func writeHandshake(path string, value startupHandshake) error { + contents, err := json.Marshal(value) + if err != nil { + return err + } + contents = append(contents, '\n') + if len(contents) > maxStartupLineBytes { + return errors.New("startup handshake exceeds its limit") + } + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return err + } + temporary, err := os.CreateTemp(filepath.Dir(path), ".controller-startup-*") + if err != nil { + return err + } + temporaryPath := temporary.Name() + committed := false + defer func() { + if !committed { + _ = os.Remove(temporaryPath) + } + }() + if _, err := temporary.Write(contents); err != nil { + temporary.Close() + return err + } + if err := temporary.Sync(); err != nil { + temporary.Close() + return err + } + if err := temporary.Close(); err != nil { + return err + } + if err := os.Rename(temporaryPath, path); err != nil { + return err + } + committed = true + return nil +} + +type controllerOptions struct { + startupID string + startupFile string + dataRoot string + once bool +} + +func runController(ctx context.Context, options controllerOptions) error { + if options.startupID == "" || options.startupFile == "" || options.dataRoot == "" { + return commandError{code: "CLI_ARGUMENT_INVALID", message: "controller startup options are required"} + } + pid, startedAt, err := currentProcessIdentity() + if err != nil { + _ = writeHandshake(options.startupFile, startupHandshake{OK: false, StartupID: options.startupID, ErrorCode: "RUNTIME_IDENTITY_UNAVAILABLE"}) + return err + } + lock, err := persistence.Acquire( + filepath.Join(options.dataRoot, "controller.lock"), + persistence.Identity{PID: pid, StartedAt: startedAt}, + processAlive, + ) + if err != nil { + _ = writeHandshake(options.startupFile, startupHandshake{OK: false, StartupID: options.startupID, ErrorCode: "RUNTIME_BUSY"}) + return err + } + defer lock.Release() + endpoint := controlEndpoint(options.dataRoot) + listener, err := listenControl(endpoint) + if err != nil { + _ = writeHandshake(options.startupFile, startupHandshake{OK: false, StartupID: options.startupID, ErrorCode: "RUNTIME_CONTROL_UNAVAILABLE"}) + return err + } + defer listener.Close() + if err := writeHandshake(options.startupFile, startupHandshake{OK: true, StartupID: options.startupID, Endpoint: endpoint}); err != nil { + return err + } + dispatcher, model := newControllerDispatcher() + err = control.Serve(ctx, listener, dispatcher, func() { + if options.once || model.isTerminal() { + _ = listener.Close() + } + }) + if ctx.Err() != nil || errors.Is(err, net.ErrClosed) && (options.once || model.isTerminal()) { + return nil + } + return err +} + +func readHandshake(path, startupID string) (startupHandshake, error) { + contents, err := os.ReadFile(path) + if err != nil { + return startupHandshake{}, err + } + if len(contents) == 0 || len(contents) > maxStartupLineBytes || contents[len(contents)-1] != '\n' || + len(contents) > 1 && bytesCount(contents, '\n') != 1 { + return startupHandshake{}, errors.New("startup handshake framing is invalid") + } + var handshake startupHandshake + if err := json.Unmarshal(contents[:len(contents)-1], &handshake); err != nil { + return startupHandshake{}, err + } + if handshake.StartupID != startupID { + return startupHandshake{}, errors.New("startup handshake identity changed") + } + return handshake, nil +} + +func bytesCount(contents []byte, value byte) int { + count := 0 + for _, current := range contents { + if current == value { + count++ + } + } + return count +} + +func waitForHandshake(ctx context.Context, path, startupID string) (startupHandshake, error) { + ticker := time.NewTicker(20 * time.Millisecond) + defer ticker.Stop() + for { + handshake, err := readHandshake(path, startupID) + if err == nil { + _ = os.Remove(path) + if !handshake.OK { + return handshake, fmt.Errorf("controller startup failed: %s", handshake.ErrorCode) + } + return handshake, nil + } + if !errors.Is(err, os.ErrNotExist) { + return startupHandshake{}, err + } + select { + case <-ctx.Done(): + return startupHandshake{}, ctx.Err() + case <-ticker.C: + } + } +} diff --git a/host/go/internal/app/controller_darwin_test.go b/host/go/internal/app/controller_darwin_test.go new file mode 100644 index 0000000..fa0b4c0 --- /dev/null +++ b/host/go/internal/app/controller_darwin_test.go @@ -0,0 +1,18 @@ +//go:build darwin + +package app + +import "testing" + +func TestDarwinProcessIdentityIncludesCreationTime(t *testing.T) { + pid, startedAt, err := currentProcessIdentity() + if err != nil { + t.Fatal(err) + } + if !processAlive(pid, startedAt) { + t.Fatal("current process identity was not recognized") + } + if processAlive(pid, startedAt+"-changed") { + t.Fatal("PID-only match accepted a changed creation time") + } +} diff --git a/host/go/internal/app/controller_test.go b/host/go/internal/app/controller_test.go new file mode 100644 index 0000000..cb376c4 --- /dev/null +++ b/host/go/internal/app/controller_test.go @@ -0,0 +1,100 @@ +package app + +import ( + "context" + "encoding/json" + "errors" + "os" + "path/filepath" + "testing" + "time" + + "github.com/u2bo/OpenChatGPTSkin/host/go/internal/control" +) + +func TestStudioCloseDoesNotEndControllerAndControllerCleansItsLock(t *testing.T) { + dataRoot := t.TempDir() + startupID := "00000000-0000-4000-8000-000000000501" + startupFile := filepath.Join(dataRoot, "startup.json") + controllerDone := make(chan error, 1) + go func() { + controllerDone <- runController(context.Background(), controllerOptions{ + startupID: startupID, + startupFile: startupFile, + dataRoot: dataRoot, + once: true, + }) + }() + waitContext, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + handshake, err := waitForHandshake(waitContext, startupFile, startupID) + if err != nil { + t.Fatal(err) + } + + studio, err := StartStudio(context.Background()) + if err != nil { + t.Fatal(err) + } + if err := studio.Close(); err != nil { + t.Fatal(err) + } + requestID := "00000000-0000-4000-8000-000000000502" + response, err := control.RoundTrip(waitContext, func() (control.Connection, error) { + return dialControl(handshake.Endpoint) + }, control.Request{ProtocolVersion: 1, RequestID: requestID, Command: "status", Params: json.RawMessage(`{}`)}) + if err != nil { + t.Fatal(err) + } + if !response.OK { + t.Fatalf("controller response = %+v", response) + } + if err := <-controllerDone; err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(dataRoot, "controller.lock")); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("controller lock remains: %v", err) + } +} + +func TestRestoreRespondsBeforeControllerTerminalCleanup(t *testing.T) { + dataRoot := t.TempDir() + startupID := "00000000-0000-4000-8000-000000000503" + startupFile := filepath.Join(dataRoot, "startup.json") + controllerDone := make(chan error, 1) + go func() { + controllerDone <- runController(context.Background(), controllerOptions{ + startupID: startupID, + startupFile: startupFile, + dataRoot: dataRoot, + }) + }() + waitContext, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + handshake, err := waitForHandshake(waitContext, startupFile, startupID) + if err != nil { + t.Fatal(err) + } + response, err := control.RoundTrip(waitContext, func() (control.Connection, error) { + return dialControl(handshake.Endpoint) + }, control.Request{ + ProtocolVersion: 1, + RequestID: "00000000-0000-4000-8000-000000000504", + Command: "restore", + Params: json.RawMessage(`{}`), + }) + if err != nil || !response.OK { + t.Fatalf("restore response=%+v error=%v", response, err) + } + select { + case err := <-controllerDone: + if err != nil { + t.Fatal(err) + } + case <-waitContext.Done(): + t.Fatal("controller did not exit after terminal restore") + } + if _, err := os.Stat(filepath.Join(dataRoot, "controller.lock")); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("controller lock remains after restore: %v", err) + } +} diff --git a/host/go/internal/app/controller_windows_test.go b/host/go/internal/app/controller_windows_test.go new file mode 100644 index 0000000..a6eecd3 --- /dev/null +++ b/host/go/internal/app/controller_windows_test.go @@ -0,0 +1,45 @@ +//go:build windows + +package app + +import ( + "context" + "errors" + "os" + "path/filepath" + "testing" +) + +func TestFailedControllerListenLeavesNoOwnedLock(t *testing.T) { + dataRoot := t.TempDir() + listener, err := listenControl(controlEndpoint(dataRoot)) + if err != nil { + t.Fatal(err) + } + defer listener.Close() + startupFile := filepath.Join(dataRoot, "startup.json") + err = runController(context.Background(), controllerOptions{ + startupID: "00000000-0000-4000-8000-000000000505", + startupFile: startupFile, + dataRoot: dataRoot, + }) + if err == nil { + t.Fatal("controller unexpectedly listened on an occupied endpoint") + } + if _, statErr := os.Stat(filepath.Join(dataRoot, "controller.lock")); !errors.Is(statErr, os.ErrNotExist) { + t.Fatalf("failed startup left lock: %v", statErr) + } +} + +func TestWindowsProcessIdentityIncludesCreationTime(t *testing.T) { + pid, startedAt, err := currentProcessIdentity() + if err != nil { + t.Fatal(err) + } + if !processAlive(pid, startedAt) { + t.Fatal("current process identity was not recognized") + } + if processAlive(pid, startedAt+"-changed") { + t.Fatal("PID-only match accepted a changed creation time") + } +} diff --git a/host/go/internal/app/platform_darwin.go b/host/go/internal/app/platform_darwin.go new file mode 100644 index 0000000..136d75d --- /dev/null +++ b/host/go/internal/app/platform_darwin.go @@ -0,0 +1,45 @@ +//go:build darwin + +package app + +import ( + "fmt" + "net" + "os" + "os/exec" + "path/filepath" + "syscall" + + platform "github.com/u2bo/OpenChatGPTSkin/host/go/internal/platform/macos" + "golang.org/x/sys/unix" +) + +func controlEndpoint(dataRoot string) string { return filepath.Join(dataRoot, "runtime.sock") } +func listenControl(endpoint string) (net.Listener, error) { return platform.Listen(endpoint) } +func dialControl(endpoint string) (net.Conn, error) { return platform.Dial(endpoint) } +func configureDetached(command *exec.Cmd) { command.SysProcAttr = &syscall.SysProcAttr{Setsid: true} } + +func processStartedAt(pid int) (string, error) { + process, err := unix.SysctlKinfoProc("kern.proc.pid", pid) + if err != nil { + return "", err + } + if process.Proc.P_pid != int32(pid) { + return "", syscall.ESRCH + } + started := process.Proc.P_starttime + return fmt.Sprintf("%d:%06d", started.Sec, started.Usec), nil +} + +func currentProcessIdentity() (int, string, error) { + startedAt, err := processStartedAt(os.Getpid()) + return os.Getpid(), startedAt, err +} + +func processAlive(pid int, startedAt string) bool { + if syscall.Kill(pid, 0) != nil { + return false + } + actual, err := processStartedAt(pid) + return err == nil && actual == startedAt +} diff --git a/host/go/internal/app/platform_windows.go b/host/go/internal/app/platform_windows.go new file mode 100644 index 0000000..628ce9b --- /dev/null +++ b/host/go/internal/app/platform_windows.go @@ -0,0 +1,57 @@ +//go:build windows + +package app + +import ( + "net" + "os" + "os/exec" + "strconv" + "syscall" + + platform "github.com/u2bo/OpenChatGPTSkin/host/go/internal/platform/windows" + "golang.org/x/sys/windows" +) + +const windowsStillActive = 259 + +func controlEndpoint(dataRoot string) string { return platform.Endpoint(dataRoot) } +func listenControl(endpoint string) (net.Listener, error) { return platform.Listen(endpoint) } +func dialControl(endpoint string) (net.Conn, error) { return platform.Dial(endpoint) } + +func configureDetached(command *exec.Cmd) { + command.SysProcAttr = &syscall.SysProcAttr{ + CreationFlags: 0x00000008 | 0x00000200, + HideWindow: true, + } +} + +func processStartedAt(pid int) (string, error) { + handle, err := windows.OpenProcess(windows.SYNCHRONIZE|windows.PROCESS_QUERY_LIMITED_INFORMATION, false, uint32(pid)) + if err != nil { + return "", err + } + defer windows.CloseHandle(handle) + var code uint32 + if err := windows.GetExitCodeProcess(handle, &code); err != nil { + return "", err + } + if code != windowsStillActive { + return "", syscall.EINVAL + } + var creation, exit, kernel, user windows.Filetime + if err := windows.GetProcessTimes(handle, &creation, &exit, &kernel, &user); err != nil { + return "", err + } + return strconv.FormatInt(creation.Nanoseconds(), 10), nil +} + +func currentProcessIdentity() (int, string, error) { + startedAt, err := processStartedAt(os.Getpid()) + return os.Getpid(), startedAt, err +} + +func processAlive(pid int, startedAt string) bool { + actual, err := processStartedAt(pid) + return err == nil && actual == startedAt +} diff --git a/host/go/internal/app/run.go b/host/go/internal/app/run.go new file mode 100644 index 0000000..51e326b --- /dev/null +++ b/host/go/internal/app/run.go @@ -0,0 +1,111 @@ +package app + +import ( + "context" + "encoding/json" + "io" + "os" + "os/signal" + "syscall" +) + +func has(arguments []string, name string) bool { + for _, argument := range arguments { + if argument == name { + return true + } + } + return false +} + +func parseControllerOptions(arguments []string) (controllerOptions, error) { + options := controllerOptions{} + for index := 0; index < len(arguments); index++ { + switch arguments[index] { + case "--once": + options.once = true + case "--startup-id", "--startup-file", "--data-root": + name := arguments[index] + index++ + if index >= len(arguments) { + return controllerOptions{}, commandError{code: "CLI_ARGUMENT_INVALID", message: name + " requires a value"} + } + switch name { + case "--startup-id": + options.startupID = arguments[index] + case "--startup-file": + options.startupFile = arguments[index] + case "--data-root": + options.dataRoot = arguments[index] + } + default: + return controllerOptions{}, commandError{code: "CLI_ARGUMENT_INVALID", message: "unknown controller option: " + arguments[index]} + } + } + return options, nil +} + +func Run(ctx context.Context, arguments []string, stdout, stderr io.Writer) int { + command, err := Parse(arguments) + if err != nil { + writeCLIError(stderr, err) + return 2 + } + switch command.Role { + case RoleStudio: + if len(command.Args) > 1 || len(command.Args) == 1 && command.Args[0] != "--health-once" && command.Args[0] != "--no-open" { + writeCLIError(stderr, commandError{code: "CLI_ARGUMENT_INVALID", message: "unknown studio option"}) + return 2 + } + studio, startErr := StartStudio(ctx) + if startErr != nil { + writeCLIError(stderr, startErr) + return 1 + } + _ = json.NewEncoder(stdout).Encode(map[string]any{"ok": true, "role": "studio", "origin": studio.Origin}) + if has(command.Args, "--health-once") { + _ = studio.Close() + return 0 + } + <-ctx.Done() + _ = studio.Close() + return 0 + case RoleController: + options, parseErr := parseControllerOptions(command.Args) + if parseErr != nil { + writeCLIError(stderr, parseErr) + return 2 + } + if err := runController(ctx, options); err != nil { + writeCLIError(stderr, err) + return 1 + } + return 0 + case RoleRuntime: + response, runtimeErr := runRuntime(ctx, command.Args) + if runtimeErr != nil { + writeCLIError(stderr, runtimeErr) + return 1 + } + _ = json.NewEncoder(stdout).Encode(response) + return 0 + case RoleContract: + writeCLIError(stderr, commandError{code: "GO_BASELINE_SUITE_NOT_IMPLEMENTED", message: "The Go feasibility spike does not claim full contract parity"}) + return 1 + default: + writeCLIError(stderr, commandError{code: "CLI_ARGUMENT_INVALID", message: "unknown role"}) + return 2 + } +} + +func writeCLIError(writer io.Writer, err error) { + _ = json.NewEncoder(writer).Encode(map[string]any{ + "error": map[string]string{"code": ErrorCode(err), "message": err.Error()}, + }) +} + +func Main(arguments []string, stdout, stderr io.Writer) int { + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer cancel() + return Run(ctx, arguments, stdout, stderr) +} diff --git a/host/go/internal/app/runtime.go b/host/go/internal/app/runtime.go new file mode 100644 index 0000000..80adb38 --- /dev/null +++ b/host/go/internal/app/runtime.go @@ -0,0 +1,136 @@ +package app + +import ( + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "time" + + "github.com/u2bo/OpenChatGPTSkin/host/go/internal/control" +) + +func requestID() (string, error) { + value := make([]byte, 16) + if _, err := rand.Read(value); err != nil { + return "", err + } + value[6] = (value[6] & 0x0f) | 0x40 + value[8] = (value[8] & 0x3f) | 0x80 + hexValue := hex.EncodeToString(value) + return hexValue[:8] + "-" + hexValue[8:12] + "-" + hexValue[12:16] + "-" + hexValue[16:20] + "-" + hexValue[20:], nil +} + +func startDetachedController(ctx context.Context, executable, dataRoot string) error { + startupID, err := requestID() + if err != nil { + return err + } + startupFile := filepath.Join(dataRoot, ".startup-"+startupID+".json") + command := exec.Command(executable, + "controller", + "--startup-id", startupID, + "--startup-file", startupFile, + "--data-root", dataRoot, + ) + configureDetached(command) + null, err := os.OpenFile(os.DevNull, os.O_RDWR, 0) + if err != nil { + return err + } + defer null.Close() + command.Stdin, command.Stdout, command.Stderr = null, null, null + if err := command.Start(); err != nil { + return err + } + startupContext, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + if _, err := waitForHandshake(startupContext, startupFile, startupID); err != nil { + _ = command.Process.Kill() + _ = os.Remove(startupFile) + return err + } + return command.Process.Release() +} + +func sendRuntime(ctx context.Context, dataRoot string, request control.Request) (control.Response, error) { + endpoint := controlEndpoint(dataRoot) + return control.RoundTrip(ctx, func() (control.Connection, error) { return dialControl(endpoint) }, request) +} + +func runRuntime(ctx context.Context, arguments []string) (control.Response, error) { + if len(arguments) == 0 { + return control.Response{}, commandError{code: "CLI_ARGUMENT_INVALID", message: "runtime command is required"} + } + commandName := arguments[0] + switch commandName { + case "launch", "status", "switch", "pause", "resume", "restore": + default: + return control.Response{}, commandError{code: "CLI_ARGUMENT_INVALID", message: "unknown runtime command: " + commandName} + } + dataRoot, themeID, themeVersion := "", "", "" + themeProvided, themeVersionProvided := false, false + for index := 1; index < len(arguments); index++ { + switch arguments[index] { + case "--data-root": + index++ + if index >= len(arguments) { + return control.Response{}, commandError{code: "CLI_ARGUMENT_INVALID", message: "--data-root requires a value"} + } + dataRoot = arguments[index] + case "--theme": + index++ + if index >= len(arguments) { + return control.Response{}, commandError{code: "CLI_ARGUMENT_INVALID", message: "--theme requires a value"} + } + themeID = arguments[index] + themeProvided = true + case "--theme-version": + index++ + if index >= len(arguments) { + return control.Response{}, commandError{code: "CLI_ARGUMENT_INVALID", message: "--theme-version requires a value"} + } + themeVersion = arguments[index] + themeVersionProvided = true + default: + return control.Response{}, commandError{code: "CLI_ARGUMENT_INVALID", message: "unknown runtime option: " + arguments[index]} + } + } + if dataRoot == "" { + return control.Response{}, commandError{code: "CLI_ARGUMENT_INVALID", message: "--data-root is required for the spike"} + } + themeCommand := commandName == "launch" || commandName == "switch" + if themeCommand && !themeProvided { + return control.Response{}, commandError{code: "CLI_ARGUMENT_INVALID", message: "--theme is required for launch and switch"} + } + if !themeCommand && (themeProvided || themeVersionProvided) { + return control.Response{}, commandError{code: "CLI_ARGUMENT_INVALID", message: "theme options are valid only for launch and switch"} + } + id, err := requestID() + if err != nil { + return control.Response{}, err + } + params, err := json.Marshal(map[string]string{"themeId": themeID, "themeVersion": themeVersion}) + if err != nil { + return control.Response{}, err + } + if commandName == "status" || commandName == "pause" || commandName == "resume" || commandName == "restore" { + params = json.RawMessage(`{}`) + } + request := control.Request{ProtocolVersion: 1, RequestID: id, Command: commandName, Params: params} + response, err := sendRuntime(ctx, dataRoot, request) + if err == nil || commandName == "status" { + return response, err + } + executable, executableErr := os.Executable() + if executableErr != nil { + return control.Response{}, executableErr + } + if err := startDetachedController(ctx, executable, dataRoot); err != nil { + return control.Response{}, err + } + return sendRuntime(ctx, dataRoot, request) +} diff --git a/host/go/internal/app/studio.go b/host/go/internal/app/studio.go new file mode 100644 index 0000000..7f24734 --- /dev/null +++ b/host/go/internal/app/studio.go @@ -0,0 +1,41 @@ +package app + +import ( + "context" + "encoding/json" + "net" + "net/http" + "sync" +) + +type RunningStudio struct { + Origin string + server *http.Server + once sync.Once +} + +func StartStudio(_ context.Context) (*RunningStudio, error) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return nil, err + } + mux := http.NewServeMux() + mux.HandleFunc("/health", func(response http.ResponseWriter, request *http.Request) { + if request.Method != http.MethodGet { + response.WriteHeader(http.StatusMethodNotAllowed) + return + } + response.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(response).Encode(map[string]any{"ok": true, "role": "studio"}) + }) + server := &http.Server{Handler: mux, ReadHeaderTimeout: 5_000_000_000} + running := &RunningStudio{Origin: "http://" + listener.Addr().String(), server: server} + go func() { _ = server.Serve(listener) }() + return running, nil +} + +func (studio *RunningStudio) Close() error { + var err error + studio.once.Do(func() { err = studio.server.Close() }) + return err +} diff --git a/host/go/internal/control/control_test.go b/host/go/internal/control/control_test.go new file mode 100644 index 0000000..a3975a3 --- /dev/null +++ b/host/go/internal/control/control_test.go @@ -0,0 +1,215 @@ +package control + +import ( + "context" + "encoding/json" + "net" + "sync/atomic" + "testing" + "time" +) + +func request(id, command string) Request { + params := json.RawMessage(`{}`) + if command == "launch" || command == "switch" { + params = json.RawMessage(`{"themeId":"mountain-mist","themeVersion":"1.3.0"}`) + } + return Request{ProtocolVersion: 1, RequestID: id, Command: command, Params: params} +} + +func TestFrameRoundTripAndBounds(t *testing.T) { + frame, err := EncodeFrame(request("00000000-0000-4000-8000-000000000401", "status")) + if err != nil { + t.Fatal(err) + } + decoded, err := DecodeRequest(frame) + if err != nil { + t.Fatal(err) + } + if decoded.Command != "status" { + t.Fatalf("command = %q", decoded.Command) + } + if _, err := DecodeRequest(make([]byte, MaxFrameBytes+5)); err == nil { + t.Fatal("oversized frame was accepted") + } +} + +func TestRequestParametersAreCommandStrict(t *testing.T) { + for name, value := range map[string]Request{ + "status fields": requestWithParams( + "00000000-0000-4000-8000-000000000411", "status", `{"unexpected":true}`, + ), + "launch missing theme": requestWithParams( + "00000000-0000-4000-8000-000000000412", "launch", `{}`, + ), + "launch unknown field": requestWithParams( + "00000000-0000-4000-8000-000000000413", "launch", `{"themeId":"mountain-mist","unknown":true}`, + ), + "launch invalid theme ID": requestWithParams( + "00000000-0000-4000-8000-000000000417", "launch", `{"themeId":"../mountain-mist"}`, + ), + "launch invalid theme version": requestWithParams( + "00000000-0000-4000-8000-000000000418", "launch", `{"themeId":"mountain-mist","themeVersion":"latest"}`, + ), + "pause array": requestWithParams( + "00000000-0000-4000-8000-000000000414", "pause", `[]`, + ), + "pause null": requestWithParams( + "00000000-0000-4000-8000-000000000416", "pause", `null`, + ), + } { + t.Run(name, func(t *testing.T) { + frame, err := EncodeFrame(value) + if err != nil { + t.Fatal(err) + } + if _, err := DecodeRequest(frame); err == nil { + t.Fatal("invalid command parameters were accepted") + } + }) + } + valid := requestWithParams( + "00000000-0000-4000-8000-000000000415", "launch", `{"themeId":"mountain-mist","themeVersion":"1.3.0"}`, + ) + frame, err := EncodeFrame(valid) + if err != nil { + t.Fatal(err) + } + if _, err := DecodeRequest(frame); err != nil { + t.Fatalf("valid theme parameters were rejected: %v", err) + } +} + +func TestRequestRejectsUnknownTopLevelFields(t *testing.T) { + frame, err := EncodeFrame(map[string]any{ + "protocolVersion": ProtocolVersion, + "requestId": "00000000-0000-4000-8000-000000000419", + "command": "status", + "params": map[string]any{}, + "unexpected": true, + }) + if err != nil { + t.Fatal(err) + } + if _, err := DecodeRequest(frame); err == nil { + t.Fatal("unknown top-level request field was accepted") + } +} + +func requestWithParams(id, command, params string) Request { + return Request{ + ProtocolVersion: ProtocolVersion, + RequestID: id, + Command: command, + Params: json.RawMessage(params), + } +} + +func TestDispatcherIsIdempotentAndRejectsChangedCommand(t *testing.T) { + dispatcher := NewDispatcher(func(_ context.Context, request Request) (Result, error) { + return Result{"command": request.Command}, nil + }) + id := "00000000-0000-4000-8000-000000000402" + first := dispatcher.Dispatch(context.Background(), request(id, "launch")) + replay := dispatcher.Dispatch(context.Background(), request(id, "launch")) + changed := dispatcher.Dispatch(context.Background(), request(id, "pause")) + if !first.OK || !replay.OK || string(first.Result) != string(replay.Result) { + t.Fatalf("replay changed: first=%+v replay=%+v", first, replay) + } + if changed.OK || changed.Error == nil || changed.Error.Code != "RUNTIME_CONTROL_UNAVAILABLE" { + t.Fatalf("changed command response = %+v", changed) + } +} + +func TestStatusRunsWhileMutationsRemainSerialized(t *testing.T) { + mutationStarted := make(chan struct{}) + releaseMutation := make(chan struct{}) + var activeMutations int32 + var maxMutations int32 + dispatcher := NewDispatcher(func(_ context.Context, request Request) (Result, error) { + if request.Command == "status" { + return Result{"status": "ready"}, nil + } + active := atomic.AddInt32(&activeMutations, 1) + if active > atomic.LoadInt32(&maxMutations) { + atomic.StoreInt32(&maxMutations, active) + } + select { + case <-mutationStarted: + default: + close(mutationStarted) + } + <-releaseMutation + atomic.AddInt32(&activeMutations, -1) + return Result{"status": "done"}, nil + }) + + mutation := make(chan Response, 1) + go func() { + mutation <- dispatcher.Dispatch(context.Background(), request("00000000-0000-4000-8000-000000000403", "launch")) + }() + <-mutationStarted + statusDone := make(chan Response, 1) + go func() { + statusDone <- dispatcher.Dispatch(context.Background(), request("00000000-0000-4000-8000-000000000404", "status")) + }() + select { + case response := <-statusDone: + if !response.OK { + t.Fatalf("status failed: %+v", response) + } + case <-time.After(time.Second): + t.Fatal("status waited behind mutation") + } + close(releaseMutation) + <-mutation + if maxMutations != 1 { + t.Fatalf("max mutations = %d", maxMutations) + } +} + +func TestServeAllowsStatusWhileMutationIsInFlight(t *testing.T) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + mutationStarted := make(chan struct{}) + releaseMutation := make(chan struct{}) + dispatcher := NewDispatcher(func(_ context.Context, request Request) (Result, error) { + if request.Command == "status" { + return Result{"status": "ready"}, nil + } + close(mutationStarted) + <-releaseMutation + return Result{"status": "done"}, nil + }) + serverContext, cancelServer := context.WithCancel(context.Background()) + serverDone := make(chan error, 1) + go func() { serverDone <- Serve(serverContext, listener, dispatcher, nil) }() + dial := func() (Connection, error) { return net.Dial("tcp", listener.Addr().String()) } + + mutationDone := make(chan error, 1) + go func() { + _, roundTripErr := RoundTrip(context.Background(), dial, request( + "00000000-0000-4000-8000-000000000405", "launch", + )) + mutationDone <- roundTripErr + }() + <-mutationStarted + statusContext, cancelStatus := context.WithTimeout(context.Background(), time.Second) + defer cancelStatus() + status, err := RoundTrip(statusContext, dial, request( + "00000000-0000-4000-8000-000000000406", "status", + )) + if err != nil || !status.OK { + t.Fatalf("parallel status response=%+v error=%v", status, err) + } + close(releaseMutation) + if err := <-mutationDone; err != nil { + t.Fatal(err) + } + cancelServer() + if err := <-serverDone; err != nil { + t.Fatal(err) + } +} diff --git a/host/go/internal/control/dispatcher.go b/host/go/internal/control/dispatcher.go new file mode 100644 index 0000000..0a3d2b5 --- /dev/null +++ b/host/go/internal/control/dispatcher.go @@ -0,0 +1,169 @@ +package control + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "regexp" + "sync" +) + +type Handler func(context.Context, Request) (Result, error) + +type cachedResponse struct { + command string + response Response +} + +type inFlight struct { + command string + done chan struct{} +} + +type Dispatcher struct { + handler Handler + mu sync.Mutex + mutation sync.Mutex + cache map[string]cachedResponse + order []string + inFlight map[string]*inFlight +} + +func NewDispatcher(handler Handler) *Dispatcher { + return &Dispatcher{ + handler: handler, + cache: make(map[string]cachedResponse), + inFlight: make(map[string]*inFlight), + } +} + +var ( + requestIDPattern = regexp.MustCompile(`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$`) + themeIDPattern = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`) + themeVersionPattern = regexp.MustCompile(`^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)$`) +) + +type ThemeParameters struct { + ThemeID string `json:"themeId"` + ThemeVersion string `json:"themeVersion,omitempty"` +} + +func decodeStrictParameters(params json.RawMessage, target any) error { + decoder := json.NewDecoder(bytes.NewReader(params)) + decoder.DisallowUnknownFields() + return decoder.Decode(target) +} + +func DecodeThemeParameters(request Request) (ThemeParameters, error) { + var params ThemeParameters + if err := decodeStrictParameters(request.Params, ¶ms); err != nil { + return ThemeParameters{}, errors.New("control theme parameters are invalid") + } + if len(params.ThemeID) < 3 || len(params.ThemeID) > 80 || !themeIDPattern.MatchString(params.ThemeID) { + return ThemeParameters{}, errors.New("control theme ID is invalid") + } + if params.ThemeVersion != "" && !themeVersionPattern.MatchString(params.ThemeVersion) { + return ThemeParameters{}, errors.New("control theme version is invalid") + } + return params, nil +} + +func validateParameters(request Request) error { + if request.Command == "launch" || request.Command == "switch" { + _, err := DecodeThemeParameters(request) + return err + } + var params map[string]json.RawMessage + if err := decodeStrictParameters(request.Params, ¶ms); err != nil || params == nil || len(params) != 0 { + return errors.New("control parameters must be an empty object") + } + return nil +} + +func validateRequest(request Request) error { + if request.ProtocolVersion != ProtocolVersion { + return errors.New("control protocol version is invalid") + } + if !requestIDPattern.MatchString(request.RequestID) { + return errors.New("control request ID is invalid") + } + switch request.Command { + case "launch", "status", "switch", "pause", "resume", "restore": + default: + return errors.New("control command is invalid") + } + if len(request.Params) == 0 || !json.Valid(request.Params) { + return errors.New("control parameters are invalid") + } + return validateParameters(request) +} + +func rejected(request Request, code, message string) Response { + return Response{ + ProtocolVersion: ProtocolVersion, + RequestID: request.RequestID, + OK: false, + Error: &Error{Code: code, Message: message, NextAction: "Review Runtime status and retry with a new request ID."}, + } +} + +func (dispatcher *Dispatcher) Dispatch(ctx context.Context, request Request) Response { + if err := validateRequest(request); err != nil { + return rejected(request, "RUNTIME_CONTROL_UNAVAILABLE", err.Error()) + } + for { + dispatcher.mu.Lock() + if cached, ok := dispatcher.cache[request.RequestID]; ok { + dispatcher.mu.Unlock() + if cached.command != request.Command { + return rejected(request, "RUNTIME_CONTROL_UNAVAILABLE", "request ID belongs to another command") + } + return cached.response + } + if pending, ok := dispatcher.inFlight[request.RequestID]; ok { + dispatcher.mu.Unlock() + if pending.command != request.Command { + return rejected(request, "RUNTIME_CONTROL_UNAVAILABLE", "request ID belongs to another command") + } + select { + case <-pending.done: + continue + case <-ctx.Done(): + return rejected(request, "RUNTIME_CONTROL_UNAVAILABLE", "request was cancelled") + } + } + pending := &inFlight{command: request.Command, done: make(chan struct{})} + dispatcher.inFlight[request.RequestID] = pending + dispatcher.mu.Unlock() + response := dispatcher.execute(ctx, request) + dispatcher.mu.Lock() + dispatcher.cache[request.RequestID] = cachedResponse{command: request.Command, response: response} + dispatcher.order = append(dispatcher.order, request.RequestID) + for len(dispatcher.order) > 32 { + oldest := dispatcher.order[0] + dispatcher.order = dispatcher.order[1:] + delete(dispatcher.cache, oldest) + } + delete(dispatcher.inFlight, request.RequestID) + close(pending.done) + dispatcher.mu.Unlock() + return response + } +} + +func (dispatcher *Dispatcher) execute(ctx context.Context, request Request) Response { + if request.Command != "status" { + dispatcher.mutation.Lock() + defer dispatcher.mutation.Unlock() + } + result, err := dispatcher.handler(ctx, request) + if err != nil { + return rejected(request, "INTERNAL", "The Runtime command could not be completed.") + } + payload, err := json.Marshal(result) + if err != nil { + return rejected(request, "INTERNAL", "The Runtime result could not be encoded.") + } + return Response{ProtocolVersion: ProtocolVersion, RequestID: request.RequestID, OK: true, Result: payload} +} diff --git a/host/go/internal/control/framing.go b/host/go/internal/control/framing.go new file mode 100644 index 0000000..9e75a0b --- /dev/null +++ b/host/go/internal/control/framing.go @@ -0,0 +1,70 @@ +package control + +import ( + "bytes" + "encoding/binary" + "encoding/json" + "errors" + "fmt" +) + +const MaxFrameBytes = 64 * 1024 + +func EncodeFrame(value any) ([]byte, error) { + payload, err := json.Marshal(value) + if err != nil { + return nil, fmt.Errorf("encode control JSON: %w", err) + } + if len(payload) == 0 || len(payload) > MaxFrameBytes { + return nil, errors.New("control frame length is invalid") + } + frame := make([]byte, 4+len(payload)) + binary.LittleEndian.PutUint32(frame[:4], uint32(len(payload))) + copy(frame[4:], payload) + return frame, nil +} + +func decodeFrame(frame []byte, output any) error { + if len(frame) < 4 || len(frame) > MaxFrameBytes+4 { + return errors.New("control frame length is invalid") + } + length := int(binary.LittleEndian.Uint32(frame[:4])) + if length < 1 || length > MaxFrameBytes || len(frame) != length+4 { + return errors.New("control frame is truncated or has trailing data") + } + payload := frame[4:] + if !json.Valid(payload) { + return errors.New("control JSON is invalid") + } + decoder := json.NewDecoder(bytes.NewReader(payload)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(output); err != nil { + return fmt.Errorf("decode control JSON: %w", err) + } + return nil +} + +func DecodeRequest(frame []byte) (Request, error) { + var request Request + if err := decodeFrame(frame, &request); err != nil { + return Request{}, err + } + if err := validateRequest(request); err != nil { + return Request{}, err + } + return request, nil +} + +func DecodeResponse(frame []byte) (Response, error) { + var response Response + if err := decodeFrame(frame, &response); err != nil { + return Response{}, err + } + if response.ProtocolVersion != ProtocolVersion || response.RequestID == "" { + return Response{}, errors.New("control response identity is invalid") + } + if response.OK == (response.Error != nil) { + return Response{}, errors.New("control response result is invalid") + } + return response, nil +} diff --git a/host/go/internal/control/transport.go b/host/go/internal/control/transport.go new file mode 100644 index 0000000..cf2616b --- /dev/null +++ b/host/go/internal/control/transport.go @@ -0,0 +1,133 @@ +package control + +import ( + "context" + "encoding/binary" + "errors" + "io" + "net" + "sync" +) + +type Connection = net.Conn + +func readFrame(connection net.Conn) ([]byte, error) { + header := make([]byte, 4) + if _, err := io.ReadFull(connection, header); err != nil { + return nil, err + } + length := int(binary.LittleEndian.Uint32(header)) + if length < 1 || length > MaxFrameBytes { + return nil, errors.New("control frame length is invalid") + } + payload := make([]byte, length) + if _, err := io.ReadFull(connection, payload); err != nil { + return nil, err + } + return append(header, payload...), nil +} + +func ServeOne(ctx context.Context, listener net.Listener, dispatcher *Dispatcher) error { + connection, err := listener.Accept() + if err != nil { + return err + } + return ServeConnection(ctx, connection, dispatcher) +} + +func ServeConnection(ctx context.Context, connection net.Conn, dispatcher *Dispatcher) error { + defer connection.Close() + frame, err := readFrame(connection) + if err != nil { + return err + } + request, err := DecodeRequest(frame) + if err != nil { + return err + } + response := dispatcher.Dispatch(ctx, request) + encoded, err := EncodeFrame(response) + if err != nil { + return err + } + _, err = connection.Write(encoded) + return err +} + +func Serve( + ctx context.Context, + listener net.Listener, + dispatcher *Dispatcher, + afterResponse func(), +) error { + var handlers sync.WaitGroup + serveErrors := make(chan error, 1) + stopContextWatch := make(chan struct{}) + defer close(stopContextWatch) + go func() { + select { + case <-ctx.Done(): + _ = listener.Close() + case <-stopContextWatch: + } + }() + for { + connection, err := listener.Accept() + if err != nil { + handlers.Wait() + select { + case serveErr := <-serveErrors: + return serveErr + default: + } + if ctx.Err() != nil { + return nil + } + return err + } + handlers.Add(1) + go func() { + defer handlers.Done() + if err := ServeConnection(ctx, connection, dispatcher); err != nil { + select { + case serveErrors <- err: + default: + } + _ = listener.Close() + return + } + if afterResponse != nil { + afterResponse() + } + }() + } +} + +func RoundTrip( + ctx context.Context, + dial func() (Connection, error), + request Request, +) (Response, error) { + connection, err := dial() + if err != nil { + return Response{}, err + } + defer connection.Close() + if deadline, ok := ctx.Deadline(); ok { + if err := connection.SetDeadline(deadline); err != nil { + return Response{}, err + } + } + frame, err := EncodeFrame(request) + if err != nil { + return Response{}, err + } + if _, err := connection.Write(frame); err != nil { + return Response{}, err + } + responseFrame, err := readFrame(connection) + if err != nil { + return Response{}, err + } + return DecodeResponse(responseFrame) +} diff --git a/host/go/internal/control/types.go b/host/go/internal/control/types.go new file mode 100644 index 0000000..9872509 --- /dev/null +++ b/host/go/internal/control/types.go @@ -0,0 +1,28 @@ +package control + +import "encoding/json" + +const ProtocolVersion = 1 + +type Request struct { + ProtocolVersion int `json:"protocolVersion"` + RequestID string `json:"requestId"` + Command string `json:"command"` + Params json.RawMessage `json:"params"` +} + +type Result map[string]any + +type Error struct { + Code string `json:"code"` + Message string `json:"message"` + NextAction string `json:"nextAction,omitempty"` +} + +type Response struct { + ProtocolVersion int `json:"protocolVersion"` + RequestID string `json:"requestId"` + OK bool `json:"ok"` + Result json.RawMessage `json:"result,omitempty"` + Error *Error `json:"error,omitempty"` +} diff --git a/host/go/internal/image/pipeline.go b/host/go/internal/image/pipeline.go new file mode 100644 index 0000000..33bc17b --- /dev/null +++ b/host/go/internal/image/pipeline.go @@ -0,0 +1,303 @@ +package image + +import ( + "bytes" + "encoding/binary" + "errors" + "fmt" + stdimage "image" + "image/draw" + _ "image/jpeg" + _ "image/png" + "os" + "path/filepath" + + "github.com/gen2brain/webp" + xdraw "golang.org/x/image/draw" +) + +const ( + defaultMaxInputBytes = 50 * 1024 * 1024 + maxPixels = 80_000_000 + maxOutputBytes = 16 * 1024 * 1024 +) + +type Options struct { + Width int + Height int + Quality int + Lossless bool + MaxInputBytes int +} + +type pipelineError struct { + code string + err error +} + +func (value pipelineError) Error() string { return value.err.Error() } +func (value pipelineError) Unwrap() error { return value.err } + +func ErrorCode(err error) string { + var value pipelineError + if errors.As(err, &value) { + return value.code + } + return "INTERNAL" +} + +func fail(code, message string) error { + return pipelineError{code: code, err: errors.New(message)} +} + +func isWebP(contents []byte) bool { + return len(contents) >= 12 && string(contents[:4]) == "RIFF" && string(contents[8:12]) == "WEBP" +} + +func dimensionsInvalid(width, height int) bool { + return width <= 0 || height <= 0 || width > maxPixels/height +} + +func inputLimit(configured int) int { + if configured <= 0 || configured > defaultMaxInputBytes { + return defaultMaxInputBytes + } + return configured +} + +func decode(contents []byte) (stdimage.Image, error) { + if isWebP(contents) { + config, err := webp.DecodeConfig(bytes.NewReader(contents)) + if err != nil || dimensionsInvalid(config.Width, config.Height) { + return nil, fail("IMAGE_DECODE_INVALID", "WebP image is invalid or too large") + } + value, err := webp.Decode(bytes.NewReader(contents), webp.Options{AutoRotate: true}) + if err != nil { + return nil, fail("IMAGE_DECODE_INVALID", "WebP image could not be decoded") + } + return value, nil + } + config, _, err := stdimage.DecodeConfig(bytes.NewReader(contents)) + if err != nil || dimensionsInvalid(config.Width, config.Height) { + return nil, fail("IMAGE_DECODE_INVALID", "image is invalid or too large") + } + value, _, err := stdimage.Decode(bytes.NewReader(contents)) + if err != nil { + return nil, fail("IMAGE_DECODE_INVALID", "image could not be decoded") + } + if orientation := jpegOrientation(contents); orientation > 1 { + value = orient(value, orientation) + } + return value, nil +} + +func toNRGBA(source stdimage.Image) *stdimage.NRGBA { + bounds := source.Bounds() + result := stdimage.NewNRGBA(stdimage.Rect(0, 0, bounds.Dx(), bounds.Dy())) + draw.Draw(result, result.Bounds(), source, bounds.Min, draw.Src) + return result +} + +func orient(source stdimage.Image, orientation int) stdimage.Image { + src := toNRGBA(source) + w, h := src.Bounds().Dx(), src.Bounds().Dy() + dw, dh := w, h + if orientation >= 5 && orientation <= 8 { + dw, dh = h, w + } + dst := stdimage.NewNRGBA(stdimage.Rect(0, 0, dw, dh)) + for y := 0; y < h; y++ { + for x := 0; x < w; x++ { + var dx, dy int + switch orientation { + case 2: + dx, dy = w-1-x, y + case 3: + dx, dy = w-1-x, h-1-y + case 4: + dx, dy = x, h-1-y + case 5: + dx, dy = y, x + case 6: + dx, dy = h-1-y, x + case 7: + dx, dy = h-1-y, w-1-x + case 8: + dx, dy = y, w-1-x + default: + dx, dy = x, y + } + dst.Set(dx, dy, src.At(x, y)) + } + } + return dst +} + +func cropAndResize(source stdimage.Image, width, height int) (stdimage.Image, error) { + if width == 0 && height == 0 { + return source, nil + } + if dimensionsInvalid(width, height) { + return nil, fail("IMAGE_DIMENSIONS_INVALID", "target image dimensions are invalid") + } + bounds := source.Bounds() + sw, sh := bounds.Dx(), bounds.Dy() + targetAspect := float64(width) / float64(height) + sourceAspect := float64(sw) / float64(sh) + crop := bounds + if sourceAspect > targetAspect { + cropWidth := int(float64(sh) * targetAspect) + left := bounds.Min.X + (sw-cropWidth)/2 + crop = stdimage.Rect(left, bounds.Min.Y, left+cropWidth, bounds.Max.Y) + } else if sourceAspect < targetAspect { + cropHeight := int(float64(sw) / targetAspect) + top := bounds.Min.Y + (sh-cropHeight)/2 + crop = stdimage.Rect(bounds.Min.X, top, bounds.Max.X, top+cropHeight) + } + destination := stdimage.NewNRGBA(stdimage.Rect(0, 0, width, height)) + xdraw.CatmullRom.Scale(destination, destination.Bounds(), source, crop, draw.Over, nil) + return destination, nil +} + +func Process(contents []byte, options Options) ([]byte, error) { + limit := inputLimit(options.MaxInputBytes) + if len(contents) == 0 || len(contents) > limit { + return nil, fail("IMAGE_INPUT_TOO_LARGE", "image input is empty or exceeds its limit") + } + decoded, err := decode(contents) + if err != nil { + return nil, err + } + processed, err := cropAndResize(decoded, options.Width, options.Height) + if err != nil { + return nil, err + } + quality := options.Quality + if quality <= 0 { + quality = 90 + } + var output bytes.Buffer + if err := webp.Encode(&output, processed, webp.Options{ + Quality: quality, + Lossless: options.Lossless, + Method: 4, + Exact: true, + }); err != nil { + return nil, pipelineError{code: "IMAGE_ENCODE_FAILED", err: fmt.Errorf("encode WebP: %w", err)} + } + if output.Len() > maxOutputBytes { + return nil, fail("IMAGE_OUTPUT_TOO_LARGE", "processed image exceeds its output limit") + } + return output.Bytes(), nil +} + +func ProcessFile(inputPath, outputPath string, options Options) error { + limit := inputLimit(options.MaxInputBytes) + info, err := os.Stat(inputPath) + if err != nil { + return err + } + if info.Size() <= 0 || info.Size() > int64(limit) { + return fail("IMAGE_INPUT_TOO_LARGE", "image input is empty or exceeds its limit") + } + contents, err := os.ReadFile(inputPath) + if err != nil { + return err + } + processed, err := Process(contents, options) + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(outputPath), 0o700); err != nil { + return err + } + temporary, err := os.CreateTemp(filepath.Dir(outputPath), ".openchatgptskin-image-*") + if err != nil { + return err + } + temporaryPath := temporary.Name() + committed := false + defer func() { + if !committed { + _ = os.Remove(temporaryPath) + } + }() + if _, err := temporary.Write(processed); err != nil { + temporary.Close() + return err + } + if err := temporary.Sync(); err != nil { + temporary.Close() + return err + } + if err := temporary.Close(); err != nil { + return err + } + if err := replaceFile(temporaryPath, outputPath); err != nil { + return err + } + committed = true + return nil +} + +func jpegOrientation(contents []byte) int { + if len(contents) < 4 || contents[0] != 0xff || contents[1] != 0xd8 { + return 1 + } + for offset := 2; offset+4 <= len(contents); { + if contents[offset] != 0xff { + break + } + marker := contents[offset+1] + if marker == 0xda || marker == 0xd9 { + break + } + length := int(binary.BigEndian.Uint16(contents[offset+2 : offset+4])) + if length < 2 || offset+2+length > len(contents) { + break + } + segment := contents[offset+4 : offset+2+length] + if marker == 0xe1 && len(segment) >= 14 && string(segment[:6]) == "Exif\x00\x00" { + if value := tiffOrientation(segment[6:]); value != 0 { + return value + } + } + offset += 2 + length + } + return 1 +} + +func tiffOrientation(tiff []byte) int { + if len(tiff) < 14 { + return 0 + } + var order binary.ByteOrder + if string(tiff[:2]) == "II" { + order = binary.LittleEndian + } else if string(tiff[:2]) == "MM" { + order = binary.BigEndian + } else { + return 0 + } + if order.Uint16(tiff[2:4]) != 42 { + return 0 + } + offset := int(order.Uint32(tiff[4:8])) + if offset < 0 || offset+2 > len(tiff) { + return 0 + } + count := int(order.Uint16(tiff[offset : offset+2])) + for index := 0; index < count; index++ { + entry := offset + 2 + index*12 + if entry+12 > len(tiff) { + return 0 + } + if order.Uint16(tiff[entry:entry+2]) == 0x0112 && order.Uint16(tiff[entry+2:entry+4]) == 3 { + value := int(order.Uint16(tiff[entry+8 : entry+10])) + if value >= 1 && value <= 8 { + return value + } + } + } + return 0 +} diff --git a/host/go/internal/image/pipeline_test.go b/host/go/internal/image/pipeline_test.go new file mode 100644 index 0000000..2f35f39 --- /dev/null +++ b/host/go/internal/image/pipeline_test.go @@ -0,0 +1,183 @@ +package image + +import ( + "bytes" + "encoding/binary" + stdimage "image" + "image/color" + "image/jpeg" + "image/png" + "math" + "os" + "path/filepath" + "testing" + + "github.com/gen2brain/webp" +) + +func sourcePNG(t *testing.T) []byte { + t.Helper() + value := stdimage.NewNRGBA(stdimage.Rect(0, 0, 4, 2)) + for y := 0; y < 2; y++ { + for x := 0; x < 4; x++ { + value.SetNRGBA(x, y, color.NRGBA{R: uint8(x * 50), G: uint8(y * 80), B: 120, A: 255}) + } + } + value.SetNRGBA(1, 1, color.NRGBA{R: 255, A: 0}) + var output bytes.Buffer + if err := png.Encode(&output, value); err != nil { + t.Fatal(err) + } + return output.Bytes() +} + +func TestPipelinePreservesAlphaCropsResizesAndEncodesWebP(t *testing.T) { + output, err := Process(sourcePNG(t), Options{Width: 2, Height: 2, Lossless: true}) + if err != nil { + t.Fatal(err) + } + decoded, err := webp.Decode(bytes.NewReader(output)) + if err != nil { + t.Fatal(err) + } + if decoded.Bounds().Dx() != 2 || decoded.Bounds().Dy() != 2 { + t.Fatalf("bounds = %v", decoded.Bounds()) + } + _, _, _, alpha := decoded.At(0, 1).RGBA() + if alpha == 0xffff { + t.Fatal("alpha channel was flattened") + } +} + +func TestPipelineAppliesJPEGExifOrientation(t *testing.T) { + value := stdimage.NewNRGBA(stdimage.Rect(0, 0, 2, 1)) + value.Set(0, 0, color.RGBA{R: 255, A: 255}) + value.Set(1, 0, color.RGBA{B: 255, A: 255}) + var encoded bytes.Buffer + if err := jpeg.Encode(&encoded, value, &jpeg.Options{Quality: 100}); err != nil { + t.Fatal(err) + } + oriented := injectExifOrientation(encoded.Bytes(), 6) + output, err := Process(oriented, Options{Lossless: true}) + if err != nil { + t.Fatal(err) + } + decoded, err := webp.Decode(bytes.NewReader(output)) + if err != nil { + t.Fatal(err) + } + if decoded.Bounds().Dx() != 1 || decoded.Bounds().Dy() != 2 { + t.Fatalf("oriented bounds = %v", decoded.Bounds()) + } +} + +func injectExifOrientation(jpegBytes []byte, orientation int) []byte { + tiff := make([]byte, 26) + copy(tiff[:2], "II") + binary.LittleEndian.PutUint16(tiff[2:4], 42) + binary.LittleEndian.PutUint32(tiff[4:8], 8) + binary.LittleEndian.PutUint16(tiff[8:10], 1) + binary.LittleEndian.PutUint16(tiff[10:12], 0x0112) + binary.LittleEndian.PutUint16(tiff[12:14], 3) + binary.LittleEndian.PutUint32(tiff[14:18], 1) + binary.LittleEndian.PutUint16(tiff[18:20], uint16(orientation)) + segment := append([]byte("Exif\x00\x00"), tiff...) + header := []byte{0xff, 0xe1, 0, 0} + binary.BigEndian.PutUint16(header[2:], uint16(len(segment)+2)) + result := make([]byte, 0, len(jpegBytes)+len(header)+len(segment)) + result = append(result, jpegBytes[:2]...) + result = append(result, header...) + result = append(result, segment...) + result = append(result, jpegBytes[2:]...) + return result +} + +func TestPipelineRejectsCorruptionAndLimits(t *testing.T) { + if _, err := Process([]byte("not an image"), Options{}); ErrorCode(err) != "IMAGE_DECODE_INVALID" { + t.Fatalf("corrupt error = %v", err) + } + if _, err := Process(sourcePNG(t), Options{MaxInputBytes: 2}); ErrorCode(err) != "IMAGE_INPUT_TOO_LARGE" { + t.Fatalf("limit error = %v", err) + } + if _, err := Process(sourcePNG(t), Options{Width: math.MaxInt, Height: 2}); ErrorCode(err) != "IMAGE_DIMENSIONS_INVALID" { + t.Fatalf("overflowing dimensions error = %v", err) + } +} + +func TestInputLimitCanOnlyBeTightened(t *testing.T) { + if got := inputLimit(defaultMaxInputBytes + 1); got != defaultMaxInputBytes { + t.Fatalf("raised input limit = %d", got) + } + if got := inputLimit(1024); got != 1024 { + t.Fatalf("tightened input limit = %d", got) + } +} + +func TestProcessFileRejectsOversizedInputBeforeReplacingOutput(t *testing.T) { + directory := t.TempDir() + input := filepath.Join(directory, "input.png") + output := filepath.Join(directory, "output.webp") + if err := os.WriteFile(input, sourcePNG(t), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(output, []byte("original"), 0o600); err != nil { + t.Fatal(err) + } + if err := ProcessFile(input, output, Options{MaxInputBytes: 2}); ErrorCode(err) != "IMAGE_INPUT_TOO_LARGE" { + t.Fatalf("limit error = %v", err) + } + contents, err := os.ReadFile(output) + if err != nil { + t.Fatal(err) + } + if string(contents) != "original" { + t.Fatalf("oversized input changed output: %q", contents) + } +} + +func TestProcessFileFailsAtomically(t *testing.T) { + directory := t.TempDir() + input := filepath.Join(directory, "invalid.bin") + output := filepath.Join(directory, "output.webp") + if err := os.WriteFile(input, []byte("invalid"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(output, []byte("original"), 0o600); err != nil { + t.Fatal(err) + } + if err := ProcessFile(input, output, Options{}); err == nil { + t.Fatal("invalid source unexpectedly succeeded") + } + contents, err := os.ReadFile(output) + if err != nil { + t.Fatal(err) + } + if string(contents) != "original" { + t.Fatalf("atomic failure changed output: %q", contents) + } +} + +func TestProcessFileAtomicallyReplacesExistingOutput(t *testing.T) { + directory := t.TempDir() + input := filepath.Join(directory, "input.png") + output := filepath.Join(directory, "output.webp") + if err := os.WriteFile(input, sourcePNG(t), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(output, []byte("old"), 0o600); err != nil { + t.Fatal(err) + } + if err := ProcessFile(input, output, Options{Width: 2, Height: 2, Lossless: true}); err != nil { + t.Fatal(err) + } + contents, err := os.ReadFile(output) + if err != nil { + t.Fatal(err) + } + if string(contents) == "old" { + t.Fatal("successful image processing did not replace the old output") + } + if _, err := webp.Decode(bytes.NewReader(contents)); err != nil { + t.Fatalf("replacement is not WebP: %v", err) + } +} diff --git a/host/go/internal/image/replace_darwin.go b/host/go/internal/image/replace_darwin.go new file mode 100644 index 0000000..0e855b7 --- /dev/null +++ b/host/go/internal/image/replace_darwin.go @@ -0,0 +1,9 @@ +//go:build darwin + +package image + +import "os" + +func replaceFile(source, destination string) error { + return os.Rename(source, destination) +} diff --git a/host/go/internal/image/replace_windows.go b/host/go/internal/image/replace_windows.go new file mode 100644 index 0000000..69ff9d2 --- /dev/null +++ b/host/go/internal/image/replace_windows.go @@ -0,0 +1,21 @@ +//go:build windows + +package image + +import "golang.org/x/sys/windows" + +func replaceFile(source, destination string) error { + sourcePath, err := windows.UTF16PtrFromString(source) + if err != nil { + return err + } + destinationPath, err := windows.UTF16PtrFromString(destination) + if err != nil { + return err + } + return windows.MoveFileEx( + sourcePath, + destinationPath, + windows.MOVEFILE_REPLACE_EXISTING|windows.MOVEFILE_WRITE_THROUGH, + ) +} diff --git a/host/go/internal/persistence/lock.go b/host/go/internal/persistence/lock.go new file mode 100644 index 0000000..244aef3 --- /dev/null +++ b/host/go/internal/persistence/lock.go @@ -0,0 +1,126 @@ +package persistence + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" +) + +type Identity struct { + SchemaVersion int `json:"schemaVersion"` + PID int `json:"pid"` + StartedAt string `json:"startedAt"` +} + +type Inspector func(pid int, startedAt string) bool + +var errBusy = errors.New("controller lock is busy") + +type Lock struct { + path string + identity Identity +} + +func validateIdentity(identity Identity) (Identity, error) { + if identity.SchemaVersion != 1 { + return Identity{}, errors.New("controller lock schema version is invalid") + } + if identity.PID <= 0 || identity.StartedAt == "" { + return Identity{}, errors.New("controller lock identity is invalid") + } + return identity, nil +} + +func normalizeRequestedIdentity(identity Identity) (Identity, error) { + identity.SchemaVersion = 1 + return validateIdentity(identity) +} + +func writeExclusive(path string, identity Identity) error { + file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) + if err != nil { + return err + } + complete := false + defer func() { + _ = file.Close() + if !complete { + _ = os.Remove(path) + } + }() + encoder := json.NewEncoder(file) + writeErr := encoder.Encode(identity) + syncErr := file.Sync() + closeErr := file.Close() + if writeErr != nil { + return writeErr + } + if syncErr != nil { + return syncErr + } + if closeErr != nil { + return closeErr + } + complete = true + return nil +} + +func readIdentity(path string) (Identity, error) { + contents, err := os.ReadFile(path) + if err != nil { + return Identity{}, err + } + var identity Identity + if err := json.Unmarshal(contents, &identity); err != nil { + return Identity{}, err + } + return validateIdentity(identity) +} + +func Acquire(path string, requested Identity, inspect Inspector) (*Lock, error) { + identity, err := normalizeRequestedIdentity(requested) + if err != nil { + return nil, err + } + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return nil, err + } + if err := writeExclusive(path, identity); err == nil { + return &Lock{path: path, identity: identity}, nil + } else if !errors.Is(err, os.ErrExist) { + return nil, err + } + existing, err := readIdentity(path) + if err != nil { + return nil, fmt.Errorf("read existing controller lock: %w", err) + } + if inspect(existing.PID, existing.StartedAt) { + return nil, errBusy + } + stale := fmt.Sprintf("%s.stale-%d", path, os.Getpid()) + if err := os.Rename(path, stale); err != nil { + return nil, errBusy + } + defer os.Remove(stale) + if err := writeExclusive(path, identity); err != nil { + return nil, err + } + return &Lock{path: path, identity: identity}, nil +} + +func IsBusy(err error) bool { + return errors.Is(err, errBusy) +} + +func (lock *Lock) Release() error { + current, err := readIdentity(lock.path) + if err != nil { + return err + } + if current != lock.identity { + return errors.New("controller lock ownership changed") + } + return os.Remove(lock.path) +} diff --git a/host/go/internal/persistence/lock_test.go b/host/go/internal/persistence/lock_test.go new file mode 100644 index 0000000..7ea4a40 --- /dev/null +++ b/host/go/internal/persistence/lock_test.go @@ -0,0 +1,75 @@ +package persistence + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) + +func TestLockOwnershipAndStaleReplacement(t *testing.T) { + path := filepath.Join(t.TempDir(), "controller.lock") + first := Identity{PID: 101, StartedAt: "2026-07-24T00:00:00Z"} + lock, err := Acquire(path, first, func(pid int, startedAt string) bool { + return pid == first.PID && startedAt == first.StartedAt + }) + if err != nil { + t.Fatal(err) + } + if _, err := Acquire(path, Identity{PID: 102, StartedAt: first.StartedAt}, func(pid int, startedAt string) bool { + return pid == first.PID && startedAt == first.StartedAt + }); !IsBusy(err) { + t.Fatalf("second acquire error = %v, want busy", err) + } + if err := lock.Release(); err != nil { + t.Fatal(err) + } + + stale, err := Acquire(path, first, func(int, string) bool { return false }) + if err != nil { + t.Fatal(err) + } + if err := stale.Release(); err != nil { + t.Fatal(err) + } +} + +func TestLockDoesNotDeleteReplacementOwner(t *testing.T) { + path := filepath.Join(t.TempDir(), "controller.lock") + identity := Identity{PID: 201, StartedAt: "2026-07-24T00:00:00Z"} + lock, err := Acquire(path, identity, func(int, string) bool { return false }) + if err != nil { + t.Fatal(err) + } + replacement := Identity{SchemaVersion: 1, PID: 202, StartedAt: identity.StartedAt} + contents, err := json.Marshal(replacement) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, append(contents, '\n'), 0o600); err != nil { + t.Fatal(err) + } + if err := lock.Release(); err == nil { + t.Fatal("old owner deleted a replacement lock") + } +} + +func TestAcquirePreservesUnrecognizedLockEvidence(t *testing.T) { + path := filepath.Join(t.TempDir(), "controller.lock") + contents := []byte(`{"schemaVersion":99,"pid":301,"startedAt":"2026-07-24T00:00:00Z"}`) + if err := os.WriteFile(path, contents, 0o600); err != nil { + t.Fatal(err) + } + if _, err := Acquire(path, Identity{PID: 302, StartedAt: "2026-07-24T00:00:00Z"}, func(int, string) bool { + return false + }); err == nil { + t.Fatal("unrecognized lock schema was replaced") + } + preserved, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(preserved) != string(contents) { + t.Fatalf("unrecognized lock evidence changed: %q", preserved) + } +} diff --git a/host/go/internal/platform/macos/transport_darwin.go b/host/go/internal/platform/macos/transport_darwin.go new file mode 100644 index 0000000..4f17eba --- /dev/null +++ b/host/go/internal/platform/macos/transport_darwin.go @@ -0,0 +1,79 @@ +//go:build darwin + +package macos + +import ( + "errors" + "net" + "os" + "syscall" +) + +type ownedListener struct { + net.Listener + path string + dev uint64 + ino uint64 +} + +func Listen(endpoint string) (net.Listener, error) { + if err := removeStaleSocket(endpoint); err != nil { + return nil, err + } + listener, err := net.Listen("unix", endpoint) + if err != nil { + return nil, err + } + if err := os.Chmod(endpoint, 0o600); err != nil { + listener.Close() + os.Remove(endpoint) + return nil, err + } + info, err := os.Stat(endpoint) + if err != nil { + listener.Close() + os.Remove(endpoint) + return nil, err + } + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok || int(stat.Uid) != os.Getuid() { + listener.Close() + os.Remove(endpoint) + return nil, errors.New("unix socket ownership is invalid") + } + return &ownedListener{Listener: listener, path: endpoint, dev: uint64(stat.Dev), ino: stat.Ino}, nil +} + +func removeStaleSocket(endpoint string) error { + info, err := os.Lstat(endpoint) + if errors.Is(err, os.ErrNotExist) { + return nil + } + if err != nil { + return err + } + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok || info.Mode()&os.ModeSocket == 0 || int(stat.Uid) != os.Getuid() { + return errors.New("existing unix socket path is not an owned socket") + } + return os.Remove(endpoint) +} + +func (listener *ownedListener) Close() error { + closeErr := listener.Listener.Close() + info, statErr := os.Lstat(listener.path) + if statErr == nil { + if stat, ok := info.Sys().(*syscall.Stat_t); ok && uint64(stat.Dev) == listener.dev && stat.Ino == listener.ino { + if removeErr := os.Remove(listener.path); removeErr != nil && closeErr == nil { + closeErr = removeErr + } + } + } else if !errors.Is(statErr, os.ErrNotExist) && closeErr == nil { + closeErr = statErr + } + return closeErr +} + +func Dial(endpoint string) (net.Conn, error) { + return net.Dial("unix", endpoint) +} diff --git a/host/go/internal/platform/macos/transport_darwin_test.go b/host/go/internal/platform/macos/transport_darwin_test.go new file mode 100644 index 0000000..bbdf7c1 --- /dev/null +++ b/host/go/internal/platform/macos/transport_darwin_test.go @@ -0,0 +1,78 @@ +//go:build darwin + +package macos + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/u2bo/OpenChatGPTSkin/host/go/internal/control" +) + +func TestUnixSocketRoundTripAndPermissions(t *testing.T) { + endpoint := filepath.Join(t.TempDir(), "runtime.sock") + listener, err := Listen(endpoint) + if err != nil { + t.Fatal(err) + } + defer listener.Close() + info, err := os.Stat(endpoint) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0o600 { + t.Fatalf("mode = %o", info.Mode().Perm()) + } + dispatcher := control.NewDispatcher(func(_ context.Context, request control.Request) (control.Result, error) { + return control.Result{"command": request.Command}, nil + }) + serverDone := make(chan error, 1) + go func() { serverDone <- control.ServeOne(context.Background(), listener, dispatcher) }() + response, err := control.RoundTrip(context.Background(), func() (control.Connection, error) { + return Dial(endpoint) + }, control.Request{ProtocolVersion: 1, RequestID: "00000000-0000-4000-8000-000000000406", Command: "status", Params: []byte(`{}`)}) + if err != nil || !response.OK { + t.Fatalf("response=%+v error=%v", response, err) + } + if err := <-serverDone; err != nil { + t.Fatal(err) + } +} + +func TestUnixSocketRefusesUnsafeStartupPath(t *testing.T) { + endpoint := filepath.Join(t.TempDir(), "runtime.sock") + if err := os.WriteFile(endpoint, []byte("keep"), 0o600); err != nil { + t.Fatal(err) + } + if listener, err := Listen(endpoint); err == nil { + listener.Close() + t.Fatal("regular file was replaced by a unix socket") + } + contents, err := os.ReadFile(endpoint) + if err != nil || string(contents) != "keep" { + t.Fatalf("unsafe startup path changed: contents=%q error=%v", contents, err) + } +} + +func TestUnixSocketClosePreservesReplacementInode(t *testing.T) { + endpoint := filepath.Join(t.TempDir(), "runtime.sock") + listener, err := Listen(endpoint) + if err != nil { + t.Fatal(err) + } + if err := os.Remove(endpoint); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(endpoint, []byte("replacement"), 0o600); err != nil { + t.Fatal(err) + } + if err := listener.Close(); err != nil { + t.Fatal(err) + } + contents, err := os.ReadFile(endpoint) + if err != nil || string(contents) != "replacement" { + t.Fatalf("replacement inode changed: contents=%q error=%v", contents, err) + } +} diff --git a/host/go/internal/platform/windows/transport_windows.go b/host/go/internal/platform/windows/transport_windows.go new file mode 100644 index 0000000..b31843f --- /dev/null +++ b/host/go/internal/platform/windows/transport_windows.go @@ -0,0 +1,109 @@ +//go:build windows + +package windows + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "net" + "time" + + "github.com/Microsoft/go-winio" + syswindows "golang.org/x/sys/windows" +) + +func Endpoint(identity string) string { + digest := sha256.Sum256([]byte(identity)) + return `\\.\pipe\OpenChatGPTSkin-Go-Spike-` + hex.EncodeToString(digest[:12]) +} + +func TestEndpoint(identity string) string { return Endpoint(identity) } + +func pipeSecurityDescriptor() (string, error) { + user, err := syswindows.GetCurrentProcessToken().GetTokenUser() + if err != nil { + return "", fmt.Errorf("read current Windows user: %w", err) + } + return fmt.Sprintf("D:P(A;;FA;;;SY)(A;;FA;;;%s)", user.User.Sid.String()), nil +} + +func securityDescriptorForConnection(connection net.Conn) (*syswindows.SECURITY_DESCRIPTOR, error) { + handleConnection, ok := connection.(interface{ Fd() uintptr }) + if !ok { + return nil, errors.New("named pipe connection does not expose its kernel handle") + } + actual, err := syswindows.GetSecurityInfo( + syswindows.Handle(handleConnection.Fd()), + syswindows.SE_KERNEL_OBJECT, + syswindows.DACL_SECURITY_INFORMATION, + ) + if err != nil { + return nil, fmt.Errorf("read named pipe security: %w", err) + } + return actual, nil +} + +func verifyPipeSecurity(listener net.Listener, endpoint, expectedDescriptor string) error { + accepted := make(chan struct { + connection net.Conn + err error + }, 1) + go func() { + connection, err := listener.Accept() + accepted <- struct { + connection net.Conn + err error + }{connection: connection, err: err} + }() + timeout := 5 * time.Second + client, err := winio.DialPipe(endpoint, &timeout) + if err != nil { + return fmt.Errorf("connect named pipe ACL probe: %w", err) + } + defer client.Close() + server := <-accepted + if server.err != nil { + return fmt.Errorf("accept named pipe ACL probe: %w", server.err) + } + defer server.connection.Close() + actual, err := securityDescriptorForConnection(client) + if err != nil { + return err + } + expected, err := syswindows.SecurityDescriptorFromString(expectedDescriptor) + if err != nil { + return fmt.Errorf("parse named pipe security: %w", err) + } + if actual.String() != expected.String() { + return errors.New("named pipe ACL differs from the current user plus SYSTEM policy") + } + return nil +} + +func Listen(endpoint string) (net.Listener, error) { + descriptor, err := pipeSecurityDescriptor() + if err != nil { + return nil, err + } + listener, err := winio.ListenPipe(endpoint, &winio.PipeConfig{ + SecurityDescriptor: descriptor, + MessageMode: false, + InputBufferSize: 64 * 1024, + OutputBufferSize: 64 * 1024, + }) + if err != nil { + return nil, err + } + if err := verifyPipeSecurity(listener, endpoint, descriptor); err != nil { + listener.Close() + return nil, err + } + return listener, nil +} + +func Dial(endpoint string) (net.Conn, error) { + timeout := 5 * time.Second + return winio.DialPipe(endpoint, &timeout) +} diff --git a/host/go/internal/platform/windows/transport_windows_test.go b/host/go/internal/platform/windows/transport_windows_test.go new file mode 100644 index 0000000..8eb2477 --- /dev/null +++ b/host/go/internal/platform/windows/transport_windows_test.go @@ -0,0 +1,41 @@ +//go:build windows + +package windows + +import ( + "context" + "testing" + + "github.com/u2bo/OpenChatGPTSkin/host/go/internal/control" +) + +func TestNamedPipeRoundTrip(t *testing.T) { + endpoint := TestEndpoint(t.Name()) + listener, err := Listen(endpoint) + if err != nil { + t.Fatal(err) + } + defer listener.Close() + dispatcher := control.NewDispatcher(func(_ context.Context, request control.Request) (control.Result, error) { + return control.Result{"command": request.Command}, nil + }) + serverDone := make(chan error, 1) + go func() { serverDone <- control.ServeOne(context.Background(), listener, dispatcher) }() + response, err := control.RoundTrip(context.Background(), func() (control.Connection, error) { + return Dial(endpoint) + }, control.Request{ + ProtocolVersion: 1, + RequestID: "00000000-0000-4000-8000-000000000405", + Command: "status", + Params: []byte(`{}`), + }) + if err != nil { + t.Fatal(err) + } + if !response.OK { + t.Fatalf("response = %+v", response) + } + if err := <-serverDone; err != nil { + t.Fatal(err) + } +} diff --git a/host/go/testdata/images/cases.json b/host/go/testdata/images/cases.json new file mode 100644 index 0000000..25af887 --- /dev/null +++ b/host/go/testdata/images/cases.json @@ -0,0 +1,11 @@ +{ + "schemaVersion": 1, + "cases": [ + { "name": "jpeg-exif-orientation-6", "expectedWidth": 1, "expectedHeight": 2 }, + { "name": "png-alpha-crop-resize", "expectedWidth": 2, "expectedHeight": 2 }, + { "name": "webp-roundtrip", "expectedFormat": "webp" }, + { "name": "corrupt-input", "expectedErrorCode": "IMAGE_DECODE_INVALID" }, + { "name": "input-limit", "expectedErrorCode": "IMAGE_INPUT_TOO_LARGE" }, + { "name": "atomic-failure", "expectedOutput": "unchanged" } + ] +} diff --git a/package.json b/package.json index 7c17411..79707b6 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,10 @@ "contracts:build": "tsx --tsconfig tsconfig.scripts.json scripts/contracts/build.ts", "contracts:verify": "tsx --tsconfig tsconfig.scripts.json scripts/contracts/verify.ts", "contracts:baseline": "tsx --tsconfig tsconfig.scripts.json scripts/contracts/baseline-runner.ts", + "go:spike:test": "go -C host/go test -buildvcs=false -tags nodynamic ./... -count=1 -timeout 60s", + "go:spike:package": "tsx --tsconfig tsconfig.scripts.json scripts/release/build-go-spike.ts", + "go:spike:acceptance": "tsx --tsconfig tsconfig.scripts.json scripts/release/accept-go-spike.ts", + "go:spike:merge": "tsx --tsconfig tsconfig.scripts.json scripts/release/merge-go-spike.ts", "typecheck": "tsc -b --pretty false", "test": "vitest run", "test:watch": "vitest", diff --git a/scripts/release/accept-go-spike.ts b/scripts/release/accept-go-spike.ts new file mode 100644 index 0000000..6ebe3fb --- /dev/null +++ b/scripts/release/accept-go-spike.ts @@ -0,0 +1,13 @@ +import { resolve } from "node:path"; +import { acceptGoSpikePackages } from "./go-spike.js"; +import { releaseOption } from "./options.js"; + +try { + const args = process.argv.slice(2); + const output = resolve(releaseOption(args, "--output") ?? "artifacts/go-spike"); + const result = await acceptGoSpikePackages(output, args.includes("--require-all-native")); + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); +} catch (error) { + process.stderr.write(`${JSON.stringify({ error: { code: "GO_SPIKE_ACCEPTANCE_FAILED", message: error instanceof Error ? error.message : String(error) } }, null, 2)}\n`); + process.exitCode = 1; +} diff --git a/scripts/release/build-go-spike.ts b/scripts/release/build-go-spike.ts new file mode 100644 index 0000000..bf7ed06 --- /dev/null +++ b/scripts/release/build-go-spike.ts @@ -0,0 +1,18 @@ +import { resolve } from "node:path"; +import { buildGoSpikePackages } from "./go-spike.js"; +import { releaseOption } from "./options.js"; + +try { + const args = process.argv.slice(2); + const output = resolve(releaseOption(args, "--output") ?? "artifacts/go-spike"); + const report = await buildGoSpikePackages({ + workspaceRoot: process.cwd(), + outputDirectory: output, + nativeInstallers: !args.includes("--portable-only"), + nativeArtifactsOnly: args.includes("--native-only"), + }); + process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); +} catch (error) { + process.stderr.write(`${JSON.stringify({ error: { code: "GO_SPIKE_PACKAGE_FAILED", message: error instanceof Error ? error.message : String(error) } }, null, 2)}\n`); + process.exitCode = 1; +} diff --git a/scripts/release/go-spike.ts b/scripts/release/go-spike.ts new file mode 100644 index 0000000..bcb011b --- /dev/null +++ b/scripts/release/go-spike.ts @@ -0,0 +1,569 @@ +import { createHash } from "node:crypto"; +import { execFile } from "node:child_process"; +import { + access, + chmod, + cp, + mkdir, + mkdtemp, + readFile, + readdir, + rm, + stat, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join, relative, resolve, sep } from "node:path"; +import { promisify } from "node:util"; +import { strToU8, zipSync } from "fflate"; + +const execFileAsync = promisify(execFile); +const SPIKE_VERSION = "0.3.0-alpha.1"; +const PRODUCT = "OpenChatGPTSkin"; +const IMAGE_IMPLEMENTATION = "gen2brain-webp-wasm2go-nodynamic-plus-internal-pipeline"; + +interface Target { + readonly target: "windows-x64" | "macos-arm64" | "macos-x64"; + readonly goos: "windows" | "darwin"; + readonly goarch: "amd64" | "arm64"; + readonly executable: "OpenChatGPTSkin.exe" | "OpenChatGPTSkin"; + readonly executableFormat: "pe-x64" | "mach-o-arm64" | "mach-o-x64"; + readonly baselineStageBytes: number; + readonly archiveKind: "zip-x64" | "tar.gz-arm64" | "tar.gz-x64"; + readonly archiveBaselineBytes: number; +} + +const TARGETS: readonly Target[] = [ + { + target: "windows-x64", + goos: "windows", + goarch: "amd64", + executable: "OpenChatGPTSkin.exe", + executableFormat: "pe-x64", + baselineStageBytes: 115070815, + archiveKind: "zip-x64", + archiveBaselineBytes: 44744668, + }, + { + target: "macos-arm64", + goos: "darwin", + goarch: "arm64", + executable: "OpenChatGPTSkin", + executableFormat: "mach-o-arm64", + baselineStageBytes: 136974297, + archiveKind: "tar.gz-arm64", + archiveBaselineBytes: 48772737, + }, + { + target: "macos-x64", + goos: "darwin", + goarch: "amd64", + executable: "OpenChatGPTSkin", + executableFormat: "mach-o-x64", + baselineStageBytes: 141958550, + archiveKind: "tar.gz-x64", + archiveBaselineBytes: 51253278, + }, +] as const; + +export interface GoSpikeTargetReport { + readonly target: Target["target"]; + readonly executableFormat: Target["executableFormat"]; + readonly executableBytes: number; + readonly stageBytes: number; + readonly baselineStageBytes: number; +} + +export interface GoSpikeArtifactReport { + readonly name: string; + readonly kind: + | Target["archiveKind"] + | "setup-x64" + | "dmg-arm64" + | "dmg-x64"; + readonly bytes: number; + readonly sha256: string; + readonly baselineBytes: number; +} + +export interface GoSpikeReport { + readonly schemaVersion: 1; + readonly version: typeof SPIKE_VERSION; + readonly imageImplementation: typeof IMAGE_IMPLEMENTATION; + readonly cgo: false; + readonly sidecars: readonly []; + readonly targets: readonly GoSpikeTargetReport[]; + readonly artifacts: readonly GoSpikeArtifactReport[]; +} + +export interface BuildGoSpikeOptions { + readonly workspaceRoot: string; + readonly outputDirectory: string; + readonly nativeInstallers: boolean; + readonly nativeArtifactsOnly?: boolean; +} + +function portable(path: string): string { + return path.split(sep).join("/"); +} + +async function pathExists(path: string): Promise { + try { + await access(path); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return false; + throw error; + } +} + +async function walkFiles(root: string, current = root): Promise { + const files: string[] = []; + for (const entry of await readdir(current, { withFileTypes: true })) { + const path = join(current, entry.name); + if (entry.isDirectory()) files.push(...await walkFiles(root, path)); + else if (entry.isFile()) files.push(portable(relative(root, path))); + else throw new Error(`Go spike payload contains a non-file entry: ${path}`); + } + return files.sort(); +} + +async function directoryBytes(root: string): Promise { + let bytes = 0; + for (const path of await walkFiles(root)) { + bytes += (await stat(join(root, ...path.split("/")))).size; + } + return bytes; +} + +interface ThemeCatalog { + readonly builtins: readonly { readonly id: string; readonly path: string }[]; +} + +interface ThemeManifest { + readonly themeId: string; + readonly files: Readonly>; +} + +async function copyRuntimeThemes(workspaceRoot: string, destination: string): Promise { + const sourceRoot = join(workspaceRoot, "themes"); + const catalogBytes = await readFile(join(sourceRoot, "catalog.json")); + const catalog = JSON.parse(catalogBytes.toString("utf8")) as ThemeCatalog; + await mkdir(destination, { recursive: true }); + await writeFile(join(destination, "catalog.json"), catalogBytes); + for (const entry of catalog.builtins) { + const source = join(sourceRoot, ...entry.path.split("/")); + const target = join(destination, ...entry.path.split("/")); + const manifestBytes = await readFile(join(source, "manifest.json")); + const manifest = JSON.parse(manifestBytes.toString("utf8")) as ThemeManifest; + if (manifest.themeId !== entry.id) throw new Error(`Theme manifest identity mismatch: ${entry.id}`); + await mkdir(target, { recursive: true }); + await Promise.all([ + writeFile(join(target, "manifest.json"), manifestBytes), + cp(join(source, "LICENSE.md"), join(target, "LICENSE.md")), + ...Object.keys(manifest.files).map(async (path) => { + const sourceFile = join(source, ...path.split("/")); + const targetFile = join(target, ...path.split("/")); + await mkdir(dirname(targetFile), { recursive: true }); + await cp(sourceFile, targetFile); + }), + ]); + } +} + +function sha256(contents: Uint8Array): string { + return createHash("sha256").update(contents).digest("hex"); +} + +async function artifact(path: string, kind: GoSpikeArtifactReport["kind"], baselineBytes: number): Promise { + const contents = await readFile(path); + return { name: path.split(sep).at(-1)!, kind, bytes: contents.length, sha256: sha256(contents), baselineBytes }; +} + +function inspectExecutable(contents: Uint8Array): Target["executableFormat"] { + const bytes = Buffer.from(contents); + if (bytes.length >= 2 && bytes.toString("ascii", 0, 2) === "MZ") return "pe-x64"; + if (bytes.length >= 8 && bytes.readUInt32LE(0) === 0xfeedfacf) { + const cpu = bytes.readUInt32LE(4); + if (cpu === 0x0100000c) return "mach-o-arm64"; + if (cpu === 0x01000007) return "mach-o-x64"; + } + throw new Error("Go spike executable format is invalid"); +} + +async function zipStage(stageParent: string, output: string): Promise { + const root = join(stageParent, PRODUCT); + const entries: Record = {}; + for (const path of await walkFiles(root)) { + entries[`${PRODUCT}/${path}`] = await readFile(join(root, ...path.split("/"))); + } + await writeFile(output, zipSync(entries, { level: 9 })); +} + +async function tarStage(stageParent: string, output: string): Promise { + await execFileAsync("tar", ["-czf", output, "-C", stageParent, PRODUCT], { + windowsHide: true, + }); +} + +async function stageTarget( + workspaceRoot: string, + outputDirectory: string, + target: Target, +): Promise { + const stageParent = join(outputDirectory, "stages", target.target); + const stageRoot = join(stageParent, PRODUCT); + await rm(stageParent, { recursive: true, force: true }); + await mkdir(stageRoot, { recursive: true }); + const executablePath = join(stageRoot, target.executable); + await execFileAsync("go", [ + "build", + "-buildvcs=false", + "-trimpath", + "-tags", "nodynamic", + "-ldflags", "-s -w", + "-o", executablePath, + "./cmd/openchatgptskin", + ], { + cwd: join(workspaceRoot, "host", "go"), + env: { + ...process.env, + CGO_ENABLED: "0", + GOOS: target.goos, + GOARCH: target.goarch, + }, + windowsHide: true, + }); + if (target.goos === "darwin") await chmod(executablePath, 0o755); + await Promise.all([ + cp(join(workspaceRoot, "apps", "theme-studio", "dist"), join(stageRoot, "apps", "theme-studio", "dist"), { recursive: true }), + copyRuntimeThemes(workspaceRoot, join(stageRoot, "themes")), + cp(join(workspaceRoot, "LICENSE"), join(stageRoot, "LICENSE")), + ]); + await writeFile(join(stageRoot, "go-spike-manifest.json"), `${JSON.stringify({ + schemaVersion: 1, + version: SPIKE_VERSION, + target: target.target, + roles: ["studio", "controller", "runtime"], + imageImplementation: IMAGE_IMPLEMENTATION, + cgo: false, + sidecars: [], + }, null, 2)}\n`, "utf8"); + const executableContents = await readFile(executablePath); + const executableFormat = inspectExecutable(executableContents); + if (executableFormat !== target.executableFormat) { + throw new Error(`Go spike target format mismatch: ${target.target}`); + } + return { + target: target.target, + executableFormat, + executableBytes: executableContents.length, + stageBytes: await directoryBytes(stageRoot), + baselineStageBytes: target.baselineStageBytes, + }; +} + +async function buildPortableArtifacts( + outputDirectory: string, + targets: readonly Target[], +): Promise { + const artifacts: GoSpikeArtifactReport[] = []; + for (const target of targets) { + const stageParent = join(outputDirectory, "stages", target.target); + if (target.goos === "windows") { + const path = join(outputDirectory, `${PRODUCT}_${SPIKE_VERSION}_go-spike_windows_x64.zip`); + await zipStage(stageParent, path); + artifacts.push(await artifact(path, target.archiveKind, target.archiveBaselineBytes)); + } else { + const arch = target.goarch === "arm64" ? "arm64" : "x64"; + const path = join(outputDirectory, `${PRODUCT}_${SPIKE_VERSION}_go-spike_macos_${arch}.tar.gz`); + await tarStage(stageParent, path); + artifacts.push(await artifact(path, target.archiveKind, target.archiveBaselineBytes)); + } + } + return artifacts; +} + +function currentNativeTarget(): Target { + const target = TARGETS.find(({ goos, goarch }) => + goos === (process.platform === "win32" ? "windows" : process.platform) && + goarch === (process.arch === "x64" ? "amd64" : process.arch) + ); + if (!target) { + throw new Error(`Go spike native packaging does not support ${process.platform}/${process.arch}`); + } + return target; +} + +async function findInnoSetup(): Promise { + for (const path of [ + process.env.INNO_SETUP_COMPILER, + "C:/Program Files (x86)/Inno Setup 6/ISCC.exe", + "C:/Program Files/Inno Setup 6/ISCC.exe", + ]) { + if (path && await pathExists(path)) return path; + } + return null; +} + +async function buildWindowsSetup(outputDirectory: string): Promise { + const compiler = await findInnoSetup(); + if (!compiler) throw new Error("Native Windows Go spike packaging requires Inno Setup 6"); + const stageRoot = join(outputDirectory, "stages", "windows-x64", PRODUCT); + const buildRoot = await mkdtemp(join(tmpdir(), "openchatgptskin-go-inno-")); + try { + const script = join(buildRoot, "go-spike.iss"); + const outputBase = `${PRODUCT}_${SPIKE_VERSION}_go-spike_windows_x64_Setup`; + const quote = (value: string) => value.replaceAll('"', '""'); + await writeFile(script, [ + "[Setup]", + `AppName=${PRODUCT} Go Host Spike`, + `AppVersion=${SPIKE_VERSION}`, + `DefaultDirName={localappdata}\\${PRODUCT}-Go-Spike`, + "PrivilegesRequired=lowest", + "ArchitecturesAllowed=x64compatible", + "ArchitecturesInstallIn64BitMode=x64compatible", + "Compression=lzma2/ultra64", + "SolidCompression=yes", + `OutputDir=${quote(outputDirectory)}`, + `OutputBaseFilename=${outputBase}`, + "Uninstallable=yes", + "[Files]", + `Source: "${quote(join(stageRoot, "*"))}"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs`, + "[Icons]", + `Name: "{autoprograms}\\${PRODUCT} Go Host Spike"; Filename: "{app}\\OpenChatGPTSkin.exe"`, + "", + ].join("\r\n"), "utf8"); + await execFileAsync(compiler, [script], { windowsHide: true }); + return artifact(join(outputDirectory, `${outputBase}.exe`), "setup-x64", 34083496); + } finally { + await rm(buildRoot, { recursive: true, force: true }); + } +} + +async function buildMacDmg( + outputDirectory: string, + target: Extract, +): Promise { + const arch = target.goarch === "arm64" ? "arm64" : "x64"; + const stageRoot = join(outputDirectory, "stages", target.target, PRODUCT); + const buildRoot = await mkdtemp(join(tmpdir(), `openchatgptskin-go-dmg-${arch}-`)); + try { + const app = join(buildRoot, `${PRODUCT}.app`); + const contents = join(app, "Contents"); + const payload = join(contents, "Resources", "payload"); + await mkdir(join(contents, "MacOS"), { recursive: true }); + await mkdir(payload, { recursive: true }); + await cp(join(stageRoot, "OpenChatGPTSkin"), join(contents, "MacOS", "OpenChatGPTSkin")); + await chmod(join(contents, "MacOS", "OpenChatGPTSkin"), 0o755); + for (const path of await walkFiles(stageRoot)) { + if (path === "OpenChatGPTSkin") continue; + const destination = join(payload, ...path.split("/")); + await mkdir(dirname(destination), { recursive: true }); + await cp(join(stageRoot, ...path.split("/")), destination); + } + await writeFile(join(contents, "Info.plist"), [ + "", + "", + "", + "CFBundleExecutableOpenChatGPTSkin", + "CFBundleIdentifierio.github.u2bo.openchatgptskin", + `CFBundleShortVersionString${SPIKE_VERSION}`, + "CFBundleVersion0.3.0.1", + "CFBundlePackageTypeAPPL", + "LSUIElement", + "LSMultipleInstancesProhibited", + "", + "", + ].join("\n"), "utf8"); + await execFileAsync("plutil", ["-lint", join(contents, "Info.plist")]); + const health = await execFileAsync(join(contents, "MacOS", "OpenChatGPTSkin"), ["studio", "--health-once"]); + const healthResult = JSON.parse(health.stdout) as { readonly role?: unknown }; + if (healthResult.role !== "studio") { + throw new Error(`macOS ${arch} App Bundle host health failed`); + } + const output = join(outputDirectory, `${PRODUCT}_${SPIKE_VERSION}_go-spike_macos_${arch}.dmg`); + await execFileAsync("hdiutil", [ + "create", "-volname", `${PRODUCT} Go Spike`, "-srcfolder", app, + "-format", "UDZO", "-ov", output, + ]); + await execFileAsync("hdiutil", ["verify", output]); + return artifact(output, arch === "arm64" ? "dmg-arm64" : "dmg-x64", arch === "arm64" ? 59251126 : 62221496); + } finally { + await rm(buildRoot, { recursive: true, force: true }); + } +} + +async function acceptWindowsSetup(setupPath: string): Promise { + const installRoot = await mkdtemp(join(tmpdir(), "openchatgptskin-go-installed-")); + await rm(installRoot, { recursive: true, force: true }); + try { + await execFileAsync(setupPath, [ + "/VERYSILENT", + "/SUPPRESSMSGBOXES", + "/NORESTART", + "/SP-", + `/DIR=${installRoot}`, + ], { windowsHide: true }); + const health = await execFileAsync(join(installRoot, "OpenChatGPTSkin.exe"), ["studio", "--health-once"], { windowsHide: true }); + const value = JSON.parse(health.stdout) as { readonly role?: unknown }; + if (value.role !== "studio") throw new Error("Installed Go spike host health failed"); + await execFileAsync(join(installRoot, "unins000.exe"), [ + "/VERYSILENT", + "/SUPPRESSMSGBOXES", + "/NORESTART", + ], { windowsHide: true }); + } finally { + if (await pathExists(installRoot)) await rm(installRoot, { recursive: true, force: true }); + } +} + +export async function buildGoSpikePackages(options: BuildGoSpikeOptions): Promise { + const workspaceRoot = resolve(options.workspaceRoot); + const outputDirectory = resolve(options.outputDirectory); + await mkdir(outputDirectory, { recursive: true }); + const npmExecPath = process.env.npm_execpath; + if (!npmExecPath) throw new Error("npm_execpath is required to build the Go spike UI"); + await execFileAsync(process.execPath, [npmExecPath, "run", "studio:build"], { + cwd: workspaceRoot, + windowsHide: true, + }); + const targets: GoSpikeTargetReport[] = []; + for (const target of TARGETS) targets.push(await stageTarget(workspaceRoot, outputDirectory, target)); + const nativeTarget = options.nativeInstallers || options.nativeArtifactsOnly + ? currentNativeTarget() + : undefined; + const artifacts = await buildPortableArtifacts( + outputDirectory, + options.nativeArtifactsOnly ? [nativeTarget!] : TARGETS, + ); + if (options.nativeInstallers) { + if (process.platform === "win32") artifacts.push(await buildWindowsSetup(outputDirectory)); + if (process.platform === "darwin") { + artifacts.push(await buildMacDmg( + outputDirectory, + nativeTarget as Extract, + )); + } + } + const report: GoSpikeReport = { + schemaVersion: 1, + version: SPIKE_VERSION, + imageImplementation: IMAGE_IMPLEMENTATION, + cgo: false, + sidecars: [], + targets, + artifacts, + }; + await writeFile(join(outputDirectory, "go-spike-report.json"), `${JSON.stringify(report, null, 2)}\n`, "utf8"); + return report; +} + +export async function acceptGoSpikePackages( + outputDirectoryInput: string, + requireAllNative: boolean, +): Promise<{ + readonly accepted: true; + readonly artifactCount: number; + readonly nativeEvidenceComplete: boolean; +}> { + const outputDirectory = resolve(outputDirectoryInput); + const report = JSON.parse(await readFile(join(outputDirectory, "go-spike-report.json"), "utf8")) as GoSpikeReport; + if (report.schemaVersion !== 1 || report.targets.length !== 3) { + throw new Error("Go spike report identity is invalid"); + } + for (const target of report.targets) { + if (target.stageBytes >= target.baselineStageBytes) { + throw new Error(`Go spike stage exceeds its baseline: ${target.target}`); + } + const definition = TARGETS.find((value) => value.target === target.target); + if (!definition) throw new Error(`Unknown Go spike target: ${target.target}`); + const executable = await readFile(join( + outputDirectory, "stages", target.target, PRODUCT, definition.executable, + )); + if (inspectExecutable(executable) !== target.executableFormat) { + throw new Error(`Go spike executable format changed: ${target.target}`); + } + for (const required of [ + "apps/theme-studio/dist/index.html", + "themes/catalog.json", + "themes/builtin/future-idol-cyan/theme.json", + "themes/builtin/glacier-aurora/theme.json", + "themes/builtin/mountain-mist/theme.json", + "themes/builtin/rose-carpet-star/theme.json", + "themes/builtin/yua-mikami-starlight/theme.json", + "go-spike-manifest.json", + ]) { + await access(join(outputDirectory, "stages", target.target, PRODUCT, ...required.split("/"))); + } + } + for (const expected of report.artifacts) { + const artifactPath = join(outputDirectory, expected.name); + const contents = await readFile(artifactPath); + if (contents.length !== expected.bytes || sha256(contents) !== expected.sha256) { + throw new Error(`Go spike artifact hash changed: ${expected.name}`); + } + if (expected.bytes >= expected.baselineBytes) { + throw new Error(`Go spike artifact exceeds its baseline: ${expected.name}`); + } + if (expected.kind === "setup-x64" && process.platform === "win32") { + await acceptWindowsSetup(artifactPath); + } + } + const kinds = new Set(report.artifacts.map(({ kind }) => kind)); + const nativeEvidenceComplete = [ + "zip-x64", "setup-x64", "tar.gz-arm64", "dmg-arm64", "tar.gz-x64", "dmg-x64", + ].every((kind) => kinds.has(kind as GoSpikeArtifactReport["kind"])); + if (requireAllNative && !nativeEvidenceComplete) { + throw new Error("Go spike native package evidence is incomplete"); + } + return { accepted: true, artifactCount: report.artifacts.length, nativeEvidenceComplete }; +} + +export async function mergeGoSpikePackages( + inputDirectories: readonly string[], + outputDirectoryInput: string, +): Promise { + if (inputDirectories.length < 2) { + throw new Error("Go spike merge requires Windows and macOS inputs"); + } + const outputDirectory = resolve(outputDirectoryInput); + await rm(outputDirectory, { recursive: true, force: true }); + await mkdir(outputDirectory, { recursive: true }); + const inputs = inputDirectories.map(resolve); + const reports = await Promise.all(inputs.map(async (directory) => ({ + directory, + report: JSON.parse(await readFile(join(directory, "go-spike-report.json"), "utf8")) as GoSpikeReport, + }))); + const foundation = reports[0]!.report; + if (reports.some(({ report }) => + report.schemaVersion !== foundation.schemaVersion || + report.version !== foundation.version || + report.imageImplementation !== foundation.imageImplementation || + JSON.stringify(report.targets) !== JSON.stringify(foundation.targets) + )) { + throw new Error("Go spike reports do not describe the same source build"); + } + await cp(join(reports[0]!.directory, "stages"), join(outputDirectory, "stages"), { recursive: true }); + const selected = new Map(); + for (const input of reports) { + for (const value of input.report.artifacts) { + if (!selected.has(value.kind)) selected.set(value.kind, { directory: input.directory, artifact: value }); + } + } + const artifacts = [...selected.values()] + .map(({ artifact: value }) => value) + .sort((left, right) => left.kind.localeCompare(right.kind, "en")); + for (const value of selected.values()) { + await cp( + join(value.directory, value.artifact.name), + join(outputDirectory, value.artifact.name), + ); + } + const report: GoSpikeReport = { ...foundation, artifacts }; + await writeFile(join(outputDirectory, "go-spike-report.json"), `${JSON.stringify(report, null, 2)}\n`, "utf8"); + return report; +} diff --git a/scripts/release/merge-go-spike.ts b/scripts/release/merge-go-spike.ts new file mode 100644 index 0000000..35a3c02 --- /dev/null +++ b/scripts/release/merge-go-spike.ts @@ -0,0 +1,17 @@ +import { resolve } from "node:path"; +import { acceptGoSpikePackages, mergeGoSpikePackages } from "./go-spike.js"; +import { releaseOption } from "./options.js"; + +try { + const args = process.argv.slice(2); + const inputs = args.flatMap((argument, index) => + argument === "--input" && args[index + 1] ? [resolve(args[index + 1]!)] : [] + ); + const output = resolve(releaseOption(args, "--output") ?? "artifacts/go-spike-combined"); + const report = await mergeGoSpikePackages(inputs, output); + const acceptance = await acceptGoSpikePackages(output, true); + process.stdout.write(`${JSON.stringify({ report, acceptance }, null, 2)}\n`); +} catch (error) { + process.stderr.write(`${JSON.stringify({ error: { code: "GO_SPIKE_MERGE_FAILED", message: error instanceof Error ? error.message : String(error) } }, null, 2)}\n`); + process.exitCode = 1; +} diff --git a/tests/go-spike.test.ts b/tests/go-spike.test.ts new file mode 100644 index 0000000..f7faf1b --- /dev/null +++ b/tests/go-spike.test.ts @@ -0,0 +1,82 @@ +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + acceptGoSpikePackages, + buildGoSpikePackages, +} from "../scripts/release/go-spike.js"; + +const temporaryRoots: string[] = []; + +afterEach(async () => { + await Promise.all(temporaryRoots.splice(0).map((root) => + rm(root, { recursive: true, force: true }) + )); +}); + +describe("Go host feasibility packages", () => { + it("records the incomplete native and security evidence without claiming Gate A", async () => { + const evidence = JSON.parse(await readFile( + "contracts/baseline/v0.2.0/go-spike-sizes.json", + "utf8", + )) as { + readonly targets: readonly { readonly stageBytes: number; readonly baselineStageBytes: number }[]; + readonly artifacts: readonly { readonly bytes: number; readonly baselineBytes: number }[]; + readonly nativeEvidenceComplete: boolean; + readonly blockingEvidence: readonly string[]; + readonly security: { readonly reachableStandardLibraryVulnerabilities: number }; + }; + expect(evidence.targets).toHaveLength(3); + expect(evidence.targets.every(({ stageBytes, baselineStageBytes }) => stageBytes < baselineStageBytes)).toBe(true); + expect(evidence.artifacts).toHaveLength(4); + expect(evidence.artifacts.every(({ bytes, baselineBytes }) => bytes < baselineBytes)).toBe(true); + expect(evidence.nativeEvidenceComplete).toBe(false); + expect(evidence.blockingEvidence).toHaveLength(3); + expect(evidence.security.reachableStandardLibraryVulnerabilities).toBe(10); + }); + + it("keeps the native spike workflow manual and separate from publishing", async () => { + const workflow = await readFile(".github/workflows/go-host-spike.yml", "utf8"); + expect(workflow).toContain("workflow_dispatch:"); + expect(workflow).toContain("windows-latest"); + expect(workflow).toContain("macos-15"); + expect(workflow).toContain("macos-15-intel"); + expect(workflow).toContain("--native-only"); + expect(workflow).toContain("go-host-spike-macos-arm64"); + expect(workflow).toContain("go-host-spike-macos-x64"); + expect(workflow).toContain("go:spike:merge"); + expect(workflow).not.toContain("gh release create"); + }); + + it("cross-builds one host for three targets and packages the reviewed payload", async () => { + const output = await mkdtemp(join(tmpdir(), "openchatgptskin-go-spike-")); + temporaryRoots.push(output); + const report = await buildGoSpikePackages({ + workspaceRoot: process.cwd(), + outputDirectory: output, + nativeInstallers: false, + }); + + expect(report.targets.map(({ target, executableFormat }) => ({ target, executableFormat }))) + .toEqual([ + { target: "windows-x64", executableFormat: "pe-x64" }, + { target: "macos-arm64", executableFormat: "mach-o-arm64" }, + { target: "macos-x64", executableFormat: "mach-o-x64" }, + ]); + expect(report.targets.every(({ stageBytes, baselineStageBytes }) => + stageBytes < baselineStageBytes + )).toBe(true); + expect(report.artifacts.map(({ kind }) => kind).sort()).toEqual([ + "tar.gz-arm64", + "tar.gz-x64", + "zip-x64", + ]); + expect(report.artifacts.every(({ bytes, baselineBytes }) => bytes < baselineBytes)).toBe(true); + await expect(acceptGoSpikePackages(output, false)).resolves.toMatchObject({ + accepted: true, + artifactCount: 3, + nativeEvidenceComplete: false, + }); + }, 60_000); +}); diff --git a/tests/workspace.test.ts b/tests/workspace.test.ts index f88afda..1215126 100644 --- a/tests/workspace.test.ts +++ b/tests/workspace.test.ts @@ -26,7 +26,7 @@ describe("workspace packages", () => { runtime: "npm run build && node runtime/windows/dist/cli.js", "studio:dev": "npm run build && npm run dev -w @open-chatgpt-skin/theme-studio-service", "studio:build": "npm run build -w @open-chatgpt-skin/theme-studio", - verify: "npm run build && npm run test && npm run typecheck", + verify: "npm run contracts:verify && npm run build && npm run test && npm run typecheck", "verify:runtime": "npm run build && npm run test && npm run typecheck", "verify:foundation": "npm run themes:build && npm run build && npm run test && npm run typecheck && node packages/theme-core/dist/cli.js catalog --root themes", }); From 8bf1f0e226d083efb7dc6f30a4604d23d8e42113 Mon Sep 17 00:00:00 2001 From: u2bo <285077078@qq.com> Date: Fri, 24 Jul 2026 21:01:17 +0800 Subject: [PATCH 05/23] docs: record deferred go host gate decision --- docs/go-host-gate-a.md | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 docs/go-host-gate-a.md diff --git a/docs/go-host-gate-a.md b/docs/go-host-gate-a.md new file mode 100644 index 0000000..62335d9 --- /dev/null +++ b/docs/go-host-gate-a.md @@ -0,0 +1,32 @@ +# Go Host Gate A 决策记录 + +## 决策 + +**状态:开发继续,发布未批准。** + +2026-07-24,项目负责人明确要求不以 Gate A 阻断后续 Go Host tickets。因此允许继续实现 Ticket 06–16,并保持 `feat/go-host` 作为隔离开发分支。 + +此决定不是 `v0.3.0-alpha.1` 的发布、cutover 或删除 Node 生产宿主的批准。任何发布入口切换仍须完成本文件列出的原生验收和回滚证据。 + +## 已确认的证据 + +- v0.2.0 发布、协议、主题与数据格式基线已冻结。 +- Studio v2、Runtime v1、Theme v4 与 Data v1 contract 已生成并由 Node baseline corpus 覆盖。 +- 单 Go 二进制的 Studio、Controller、Runtime Spike 已在 Windows 本地测试;控制通道、锁、图片处理与包体均有自动化测试。 +- Windows ZIP/Setup 与 macOS ARM64/x64 tar.gz Spike 产物均小于 v0.2.0 对应基线。 +- 选定图片实现为 CGo-free 的 `gen2brain-webp-wasm2go-nodynamic-plus-internal-pipeline`,不引入长期 native sidecar。 + +机器可读包体、许可证与安全扫描记录见 `contracts/baseline/v0.2.0/go-spike-sizes.json`。 + +## 未完成的 Gate A / 发布门槛 + +- macOS ARM64 与 x64 必须在原生 Runner 产出 Unix socket 往返、App Bundle、DMG 和安装包 evidence。 +- Go 1.25.5 的可达标准库漏洞必须升级到已修复的安全补丁版本后重新扫描。 +- 所有六类产物必须以同一候选提交生成、验收并保存精确 SHA-256。 +- Ticket 15 的真实 ChatGPT/Codex 三平台主题、恢复和 Go → Node 回滚验收不得以 CI fixture 或跨编译代替。 + +## 不变量 + +- 不引入长期 Node fallback、Node/Go 双写或平台功能分叉。 +- 不把开发连续实施误报为真实设备兼容或可发布版本。 +- 任何安全拒绝、未知数据或原生验收失败都保持显式错误和可恢复证据。 From 0e10eec8b2307da8dea11a36b4ce1a4b9133a640 Mon Sep 17 00:00:00 2001 From: u2bo <285077078@qq.com> Date: Fri, 24 Jul 2026 21:50:55 +0800 Subject: [PATCH 06/23] feat: add go studio host and theme library --- host/go/internal/app/app_test.go | 54 +- host/go/internal/app/contract.go | 133 +++++ host/go/internal/app/controller_test.go | 5 +- host/go/internal/app/platform_darwin.go | 4 + host/go/internal/app/platform_windows.go | 4 + host/go/internal/app/run.go | 69 ++- host/go/internal/app/studio.go | 90 ++- host/go/internal/studio/server.go | 544 ++++++++++++++++++ host/go/internal/studio/server_test.go | 184 ++++++ host/go/internal/themerepo/repository.go | 320 +++++++++++ host/go/internal/themerepo/repository_test.go | 61 ++ package.json | 1 + 12 files changed, 1426 insertions(+), 43 deletions(-) create mode 100644 host/go/internal/app/contract.go create mode 100644 host/go/internal/studio/server.go create mode 100644 host/go/internal/studio/server_test.go create mode 100644 host/go/internal/themerepo/repository.go create mode 100644 host/go/internal/themerepo/repository_test.go diff --git a/host/go/internal/app/app_test.go b/host/go/internal/app/app_test.go index eb428fd..d3291df 100644 --- a/host/go/internal/app/app_test.go +++ b/host/go/internal/app/app_test.go @@ -4,7 +4,11 @@ import ( "context" "encoding/json" "net/http" + "os" + "path/filepath" "testing" + + "github.com/u2bo/OpenChatGPTSkin/host/go/internal/studio" ) func TestParseDefaultsToStudio(t *testing.T) { @@ -24,6 +28,16 @@ func TestParseRejectsUnknownRole(t *testing.T) { } } +func TestStudioDevRequiresAnExplicitLoopbackOrigin(t *testing.T) { + if _, err := parseStudioOptions([]string{"--dev"}); err == nil { + t.Fatal("--dev without a Vite origin was accepted") + } + options, err := parseStudioOptions([]string{"--dev", "--vite-origin", "http://127.0.0.1:5173", "--no-open"}) + if err != nil || options.viteOrigin != "http://127.0.0.1:5173" || !options.noOpen { + t.Fatalf("studio dev options = %+v error=%v", options, err) + } +} + func TestRuntimeRejectsMissingAndCommandSpecificThemeOptions(t *testing.T) { dataRoot := t.TempDir() _, err := runRuntime(context.Background(), []string{"launch", "--data-root", dataRoot}) @@ -38,11 +52,9 @@ func TestRuntimeRejectsMissingAndCommandSpecificThemeOptions(t *testing.T) { } } -func TestStudioHealthUsesLoopbackAndHasNoBusinessFallback(t *testing.T) { - studio, err := StartStudio(context.Background()) - if err != nil { - t.Fatal(err) - } +func TestStudioHealthUsesLoopbackAndRejectsUnauthenticatedAPI(t *testing.T) { + studio := startTestStudio(t) + var err error defer studio.Close() response, err := http.Get(studio.Origin + "/health") @@ -68,7 +80,35 @@ func TestStudioHealthUsesLoopbackAndHasNoBusinessFallback(t *testing.T) { t.Fatal(err) } defer missing.Body.Close() - if missing.StatusCode != http.StatusNotFound { - t.Fatalf("business fallback status = %d, want 404", missing.StatusCode) + if missing.StatusCode != http.StatusUnauthorized { + t.Fatalf("unauthenticated API status = %d, want 401", missing.StatusCode) + } +} + +func startTestStudio(t *testing.T) *studio.RunningServer { + t.Helper() + root := t.TempDir() + themeDirectory := filepath.Join(root, "themes", "builtin", "mountain-mist") + if err := os.MkdirAll(themeDirectory, 0o700); err != nil { + t.Fatal(err) + } + catalog := `{"schemaVersion":1,"builtins":[{"id":"mountain-mist","name":"Mountain Mist","version":"1.3.0","kind":"theme","path":"builtin/mountain-mist","ready":true,"localOnly":false,"licenseId":"LicenseRef-Test","preview":"builtin/mountain-mist/preview.webp"}],"recipes":[]}` + theme := `{"schemaVersion":4,"kind":"theme","appearance":"light","id":"mountain-mist","name":"Mountain Mist","version":"1.3.0","author":"OpenChatGPTSkin","rights":{"localOnly":false}}` + if err := os.WriteFile(filepath.Join(root, "themes", "catalog.json"), []byte(catalog), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(themeDirectory, "theme.json"), []byte(theme), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(themeDirectory, "preview.webp"), []byte("RIFF0000WEBP"), 0o600); err != nil { + t.Fatal(err) + } + server, err := studio.Start(context.Background(), studio.Config{ + IndexHTML: []byte(``), + ThemeRoot: filepath.Join(root, "themes"), StudioVersion: "0.3.0-alpha.1", + }) + if err != nil { + t.Fatal(err) } + return server } diff --git a/host/go/internal/app/contract.go b/host/go/internal/app/contract.go new file mode 100644 index 0000000..291c05a --- /dev/null +++ b/host/go/internal/app/contract.go @@ -0,0 +1,133 @@ +package app + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "net/http/cookiejar" + "strings" + "time" +) + +func runContractBaseline(ctx context.Context, arguments []string) (map[string]any, error) { + suite := "" + for index := 0; index < len(arguments); index++ { + switch arguments[index] { + case "--suite": + index++ + if index >= len(arguments) || suite != "" { + return nil, commandError{code: "CLI_ARGUMENT_INVALID", message: "--suite requires one value"} + } + suite = arguments[index] + case "--corpus-root": + index++ + if index >= len(arguments) || arguments[index] == "" { + return nil, commandError{code: "CLI_ARGUMENT_INVALID", message: "--corpus-root requires one value"} + } + default: + return nil, commandError{code: "CLI_ARGUMENT_INVALID", message: "unknown contract baseline option: " + arguments[index]} + } + } + if suite != "studio" { + return nil, commandError{code: "GO_BASELINE_SUITE_NOT_IMPLEMENTED", message: "The requested Go baseline suite is not implemented yet"} + } + return runStudioBaseline(ctx) +} + +func runStudioBaseline(ctx context.Context) (map[string]any, error) { + server, err := StartStudio(ctx) + if err != nil { + return nil, err + } + defer server.Close() + jar, err := cookiejar.New(nil) + if err != nil { + return nil, err + } + client := &http.Client{Jar: jar} + unauthenticated, err := client.Get(server.Origin + "/api/themes") + if err != nil { + return nil, err + } + unauthenticatedStatus := unauthenticated.StatusCode + unauthenticated.Body.Close() + token := strings.TrimPrefix(server.BootstrapURL, server.Origin+"/#bootstrap=") + sessionRequest, err := http.NewRequestWithContext(ctx, http.MethodPost, server.Origin+"/api/session", strings.NewReader(`{"token":"`+token+`"}`)) + if err != nil { + return nil, err + } + sessionRequest.Header.Set("Content-Type", "application/json") + sessionRequest.Header.Set("Origin", server.Origin) + sessionResponse, err := client.Do(sessionRequest) + if err != nil { + return nil, err + } + sessionStatus := sessionResponse.StatusCode + sessionResponse.Body.Close() + bootstrapResponse, err := client.Get(server.Origin + "/api/bootstrap") + if err != nil { + return nil, err + } + var bootstrap struct { + ProtocolVersion int `json:"protocolVersion"` + } + if err := json.NewDecoder(bootstrapResponse.Body).Decode(&bootstrap); err != nil { + bootstrapResponse.Body.Close() + return nil, err + } + securityHeaderCount := 0 + for _, name := range []string{ + "Content-Security-Policy", "Cross-Origin-Opener-Policy", "Referrer-Policy", "X-Content-Type-Options", "X-Frame-Options", + } { + if bootstrapResponse.Header.Get(name) != "" { + securityHeaderCount++ + } + } + bootstrapStatus := bootstrapResponse.StatusCode + bootstrapResponse.Body.Close() + eventContext, cancel := context.WithTimeout(ctx, 3*time.Second) + defer cancel() + eventRequest, err := http.NewRequestWithContext(eventContext, http.MethodGet, server.Origin+"/api/events", nil) + if err != nil { + return nil, err + } + eventResponse, err := client.Do(eventRequest) + if err != nil { + return nil, err + } + defer eventResponse.Body.Close() + scanner := bufio.NewScanner(io.LimitReader(eventResponse.Body, 64*1024)) + scanner.Buffer(make([]byte, 1024), 64*1024) + eventKind := "" + for scanner.Scan() { + line := scanner.Text() + if !strings.HasPrefix(line, "data: ") { + continue + } + var event struct { + Kind string `json:"kind"` + } + if err := json.NewDecoder(bytes.NewBufferString(strings.TrimPrefix(line, "data: "))).Decode(&event); err != nil { + return nil, err + } + eventKind = event.Kind + break + } + if err := scanner.Err(); err != nil { + return nil, err + } + if eventKind == "" { + return nil, commandError{code: "INTERNAL", message: "Studio status stream did not produce an event"} + } + return map[string]any{ + "protocolVersion": bootstrap.ProtocolVersion, + "sessionStatus": sessionStatus, + "bootstrapStatus": bootstrapStatus, + "unauthenticatedStatus": unauthenticatedStatus, + "securityHeaderCount": securityHeaderCount, + "eventKind": eventKind, + }, nil +} diff --git a/host/go/internal/app/controller_test.go b/host/go/internal/app/controller_test.go index cb376c4..a57ae5a 100644 --- a/host/go/internal/app/controller_test.go +++ b/host/go/internal/app/controller_test.go @@ -32,10 +32,7 @@ func TestStudioCloseDoesNotEndControllerAndControllerCleansItsLock(t *testing.T) t.Fatal(err) } - studio, err := StartStudio(context.Background()) - if err != nil { - t.Fatal(err) - } + studio := startTestStudio(t) if err := studio.Close(); err != nil { t.Fatal(err) } diff --git a/host/go/internal/app/platform_darwin.go b/host/go/internal/app/platform_darwin.go index 136d75d..e8d5891 100644 --- a/host/go/internal/app/platform_darwin.go +++ b/host/go/internal/app/platform_darwin.go @@ -43,3 +43,7 @@ func processAlive(pid int, startedAt string) bool { actual, err := processStartedAt(pid) return err == nil && actual == startedAt } + +func openBrowser(url string) error { + return exec.Command("open", url).Start() +} diff --git a/host/go/internal/app/platform_windows.go b/host/go/internal/app/platform_windows.go index 628ce9b..24f020e 100644 --- a/host/go/internal/app/platform_windows.go +++ b/host/go/internal/app/platform_windows.go @@ -55,3 +55,7 @@ func processAlive(pid int, startedAt string) bool { actual, err := processStartedAt(pid) return err == nil && actual == startedAt } + +func openBrowser(url string) error { + return exec.Command("rundll32.exe", "url.dll,FileProtocolHandler", url).Start() +} diff --git a/host/go/internal/app/run.go b/host/go/internal/app/run.go index 51e326b..a44bea3 100644 --- a/host/go/internal/app/run.go +++ b/host/go/internal/app/run.go @@ -45,6 +45,42 @@ func parseControllerOptions(arguments []string) (controllerOptions, error) { return options, nil } +type studioOptions struct { + healthOnce bool + noOpen bool + dev bool + viteOrigin string +} + +func parseStudioOptions(arguments []string) (studioOptions, error) { + options := studioOptions{} + for index := 0; index < len(arguments); index++ { + switch arguments[index] { + case "--health-once": + options.healthOnce = true + case "--no-open": + options.noOpen = true + case "--dev": + if options.dev { + return studioOptions{}, commandError{code: "CLI_ARGUMENT_INVALID", message: "--dev may be specified only once"} + } + options.dev = true + case "--vite-origin": + index++ + if index >= len(arguments) || !options.dev || options.viteOrigin != "" { + return studioOptions{}, commandError{code: "CLI_ARGUMENT_INVALID", message: "--vite-origin requires --dev and a value"} + } + options.viteOrigin = arguments[index] + default: + return studioOptions{}, commandError{code: "CLI_ARGUMENT_INVALID", message: "unknown studio option: " + arguments[index]} + } + } + if options.dev && options.viteOrigin == "" { + return studioOptions{}, commandError{code: "CLI_ARGUMENT_INVALID", message: "--dev requires --vite-origin"} + } + return options, nil +} + func Run(ctx context.Context, arguments []string, stdout, stderr io.Writer) int { command, err := Parse(arguments) if err != nil { @@ -53,20 +89,34 @@ func Run(ctx context.Context, arguments []string, stdout, stderr io.Writer) int } switch command.Role { case RoleStudio: - if len(command.Args) > 1 || len(command.Args) == 1 && command.Args[0] != "--health-once" && command.Args[0] != "--no-open" { - writeCLIError(stderr, commandError{code: "CLI_ARGUMENT_INVALID", message: "unknown studio option"}) + options, parseErr := parseStudioOptions(command.Args) + if parseErr != nil { + writeCLIError(stderr, parseErr) return 2 } - studio, startErr := StartStudio(ctx) + start := StartStudio + if options.viteOrigin != "" { + start = func(context context.Context) (*RunningStudio, error) { + return StartStudioDev(context, options.viteOrigin) + } + } + studio, startErr := start(ctx) if startErr != nil { writeCLIError(stderr, startErr) return 1 } - _ = json.NewEncoder(stdout).Encode(map[string]any{"ok": true, "role": "studio", "origin": studio.Origin}) - if has(command.Args, "--health-once") { + _ = json.NewEncoder(stdout).Encode(map[string]any{"ok": true, "role": "studio"}) + if options.healthOnce { _ = studio.Close() return 0 } + if !options.noOpen { + if err := openBrowser(studio.BootstrapURL); err != nil { + _ = studio.Close() + writeCLIError(stderr, commandError{code: "STUDIO_START_FAILED", message: "Studio browser could not be opened"}) + return 1 + } + } <-ctx.Done() _ = studio.Close() return 0 @@ -90,8 +140,13 @@ func Run(ctx context.Context, arguments []string, stdout, stderr io.Writer) int _ = json.NewEncoder(stdout).Encode(response) return 0 case RoleContract: - writeCLIError(stderr, commandError{code: "GO_BASELINE_SUITE_NOT_IMPLEMENTED", message: "The Go feasibility spike does not claim full contract parity"}) - return 1 + result, contractErr := runContractBaseline(ctx, command.Args) + if contractErr != nil { + writeCLIError(stderr, contractErr) + return 1 + } + _ = json.NewEncoder(stdout).Encode(result) + return 0 default: writeCLIError(stderr, commandError{code: "CLI_ARGUMENT_INVALID", message: "unknown role"}) return 2 diff --git a/host/go/internal/app/studio.go b/host/go/internal/app/studio.go index 7f24734..702f7b8 100644 --- a/host/go/internal/app/studio.go +++ b/host/go/internal/app/studio.go @@ -2,40 +2,80 @@ package app import ( "context" - "encoding/json" - "net" - "net/http" - "sync" + "errors" + "os" + "path/filepath" + + "github.com/u2bo/OpenChatGPTSkin/host/go/internal/studio" ) -type RunningStudio struct { - Origin string - server *http.Server - once sync.Once +const goHostVersion = "0.3.0-alpha.1" + +type RunningStudio = studio.RunningServer + +func StartStudio(ctx context.Context) (*RunningStudio, error) { + return startStudio(ctx, "") +} + +func StartStudioDev(ctx context.Context, viteOrigin string) (*RunningStudio, error) { + return startStudio(ctx, viteOrigin) } -func StartStudio(_ context.Context) (*RunningStudio, error) { - listener, err := net.Listen("tcp", "127.0.0.1:0") +func startStudio(ctx context.Context, viteOrigin string) (*RunningStudio, error) { + installRoot, err := findInstallRoot(viteOrigin == "") if err != nil { - return nil, err + return nil, commandError{code: "STUDIO_START_FAILED", message: "Studio production assets could not be located"} } - mux := http.NewServeMux() - mux.HandleFunc("/health", func(response http.ResponseWriter, request *http.Request) { - if request.Method != http.MethodGet { - response.WriteHeader(http.StatusMethodNotAllowed) - return + var indexHTML []byte + if viteOrigin == "" { + indexHTML, err = os.ReadFile(filepath.Join(installRoot, "apps", "theme-studio", "dist", "index.html")) + if err != nil { + return nil, commandError{code: "STUDIO_START_FAILED", message: "Studio production UI could not be read"} } - response.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(response).Encode(map[string]any{"ok": true, "role": "studio"}) + } + repositoryURL := "https://github.com/u2bo/OpenChatGPTSkin" + running, err := studio.Start(ctx, studio.Config{ + IndexHTML: indexHTML, + ThemeRoot: filepath.Join(installRoot, "themes"), + StudioVersion: goHostVersion, + RepositoryURL: &repositoryURL, + ViteOrigin: viteOrigin, }) - server := &http.Server{Handler: mux, ReadHeaderTimeout: 5_000_000_000} - running := &RunningStudio{Origin: "http://" + listener.Addr().String(), server: server} - go func() { _ = server.Serve(listener) }() + if err != nil { + code := studio.ErrorCode(err) + if code == "" { + code = "STUDIO_START_FAILED" + } + return nil, commandError{code: code, message: err.Error()} + } return running, nil } -func (studio *RunningStudio) Close() error { - var err error - studio.once.Do(func() { err = studio.server.Close() }) - return err +func findInstallRoot(requireProductionUI bool) (string, error) { + candidates := make([]string, 0, 2) + if executable, err := os.Executable(); err == nil { + candidates = append(candidates, filepath.Dir(executable)) + } + if current, err := os.Getwd(); err == nil { + candidates = append(candidates, current) + } + for _, candidate := range candidates { + for root := candidate; ; root = filepath.Dir(root) { + hasThemes := fileExists(filepath.Join(root, "themes", "catalog.json")) + hasProductionUI := fileExists(filepath.Join(root, "apps", "theme-studio", "dist", "index.html")) + if hasThemes && (!requireProductionUI || hasProductionUI) { + return root, nil + } + parent := filepath.Dir(root) + if parent == root { + break + } + } + } + return "", errors.New("Studio install root is unavailable") +} + +func fileExists(path string) bool { + info, err := os.Stat(path) + return err == nil && info.Mode().IsRegular() } diff --git a/host/go/internal/studio/server.go b/host/go/internal/studio/server.go new file mode 100644 index 0000000..9e303c8 --- /dev/null +++ b/host/go/internal/studio/server.go @@ -0,0 +1,544 @@ +package studio + +import ( + "bytes" + "context" + "crypto/rand" + "crypto/subtle" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/http/httputil" + "net/url" + "strconv" + "strings" + "sync" + "time" + + "github.com/u2bo/OpenChatGPTSkin/host/go/internal/themerepo" +) + +const ( + cspNoncePlaceholder = "__OPEN_CHATGPT_SKIN_CSP_NONCE__" + sessionCookieName = "ocs_studio_session" + sessionLimitBytes = 16 * 1024 + jsonLimitBytes = 256 * 1024 + eventInterval = 5 * time.Second +) + +type RuntimeStatus struct { + Status string `json:"status"` + ControllerAvailable bool `json:"controllerAvailable"` + SelectedTheme *themerepo.Ref `json:"selectedTheme"` + AppliedTheme *themerepo.Ref `json:"appliedTheme"` + SkinApplied *bool `json:"skinApplied"` + PackageVersion *string `json:"packageVersion"` + Operation *string `json:"operation"` + NextAction string `json:"nextAction"` +} + +func StoppedRuntimeStatus() RuntimeStatus { + return RuntimeStatus{ + Status: "stopped", NextAction: "Open a saved theme and apply it to start Runtime.", + } +} + +type Config struct { + IndexHTML []byte + ThemeRoot string + StudioVersion string + RepositoryURL *string + RuntimeStatus func() RuntimeStatus + MaxSSEClients int + ViteOrigin string +} + +type RunningServer struct { + Origin string + BootstrapURL string + server *http.Server + listener net.Listener + closeOnce sync.Once + closeErr error +} + +type studioError struct { + code string + message string + statusCode int +} + +func (err studioError) Error() string { return err.message } + +func ErrorCode(err error) string { + var value studioError + if errors.As(err, &value) { + return value.code + } + return "" +} + +type session struct { + bootstrap string + available bool + value string + mu sync.Mutex +} + +func newToken() (string, error) { + value := make([]byte, 32) + if _, err := rand.Read(value); err != nil { + return "", err + } + return hex.EncodeToString(value), nil +} + +func newSession() (*session, error) { + token, err := newToken() + if err != nil { + return nil, err + } + return &session{bootstrap: token, available: true}, nil +} + +func (session *session) exchange(token string) (string, error) { + session.mu.Lock() + defer session.mu.Unlock() + if !session.available || !sameToken(token, session.bootstrap) { + return "", studioError{code: "STUDIO_SESSION_INVALID", message: "Bootstrap token is invalid or already used", statusCode: http.StatusUnauthorized} + } + value, err := newToken() + if err != nil { + return "", err + } + session.available = false + session.value = value + return value, nil +} + +func (session *session) validCookie(header string) bool { + session.mu.Lock() + defer session.mu.Unlock() + if session.value == "" { + return false + } + for _, part := range strings.Split(header, ";") { + name, value, found := strings.Cut(strings.TrimSpace(part), "=") + if found && name == sessionCookieName && sameToken(value, session.value) { + return true + } + } + return false +} + +func sameToken(left, right string) bool { + if len(left) != 64 || len(right) != 64 { + return false + } + leftBytes, leftErr := hex.DecodeString(left) + rightBytes, rightErr := hex.DecodeString(right) + return leftErr == nil && rightErr == nil && subtle.ConstantTimeCompare(leftBytes, rightBytes) == 1 +} + +func Start(ctx context.Context, config Config) (*RunningServer, error) { + if config.ViteOrigin == "" && (len(config.IndexHTML) == 0 || + !strings.Contains(string(config.IndexHTML), cspNoncePlaceholder) || + strings.Count(string(config.IndexHTML), `property="csp-nonce"`) != 1) { + return nil, studioError{code: "INTERNAL", message: "Production Theme Studio is missing its CSP nonce metadata", statusCode: http.StatusInternalServerError} + } + if config.StudioVersion == "" { + return nil, studioError{code: "INTERNAL", message: "Studio version is required", statusCode: http.StatusInternalServerError} + } + repository, err := themerepo.Open(config.ThemeRoot) + if err != nil { + return nil, err + } + session, err := newSession() + if err != nil { + return nil, err + } + nonce, err := newNonce() + if err != nil { + return nil, err + } + indexHTML := []byte(strings.ReplaceAll(string(config.IndexHTML), cspNoncePlaceholder, nonce)) + var viteProxy *httputil.ReverseProxy + if config.ViteOrigin != "" { + viteProxy, err = newViteProxy(config.ViteOrigin, nonce) + if err != nil { + return nil, err + } + } + listener, err := net.Listen("tcp4", "127.0.0.1:0") + if err != nil { + return nil, err + } + origin := "http://" + listener.Addr().String() + maxSSEClients := config.MaxSSEClients + if maxSSEClients < 1 { + maxSSEClients = 16 + } + runtimeStatus := config.RuntimeStatus + if runtimeStatus == nil { + runtimeStatus = StoppedRuntimeStatus + } + state := &handlerState{ + origin: origin, session: session, repository: repository, indexHTML: indexHTML, + studioVersion: config.StudioVersion, repositoryURL: config.RepositoryURL, + runtimeStatus: runtimeStatus, maxSSEClients: maxSSEClients, nonce: nonce, viteProxy: viteProxy, + } + server := &http.Server{ + Handler: state, + ReadHeaderTimeout: 5 * time.Second, + ReadTimeout: 15 * time.Second, + WriteTimeout: 20 * time.Second, + IdleTimeout: 30 * time.Second, + } + running := &RunningServer{ + Origin: origin, BootstrapURL: origin + "/#bootstrap=" + session.bootstrap, + server: server, listener: listener, + } + go func() { + if err := server.Serve(listener); err != nil && !errors.Is(err, http.ErrServerClosed) { + running.closeOnce.Do(func() { running.closeErr = err }) + } + }() + go func() { + <-ctx.Done() + _ = running.Close() + }() + return running, nil +} + +func newNonce() (string, error) { + bytes := make([]byte, 18) + if _, err := rand.Read(bytes); err != nil { + return "", err + } + return hex.EncodeToString(bytes), nil +} + +func (server *RunningServer) Close() error { + server.closeOnce.Do(func() { server.closeErr = server.server.Close() }) + return server.closeErr +} + +type handlerState struct { + origin string + session *session + repository *themerepo.Repository + indexHTML []byte + studioVersion string + repositoryURL *string + runtimeStatus func() RuntimeStatus + maxSSEClients int + nonce string + viteProxy *httputil.ReverseProxy + mu sync.Mutex + eventSequence int64 + activeSSE int +} + +func (state *handlerState) ServeHTTP(response http.ResponseWriter, request *http.Request) { + state.securityHeaders(response) + if request.Host != strings.TrimPrefix(state.origin, "http://") { + state.writeError(response, studioError{code: "STUDIO_ORIGIN_REJECTED", message: "Request host is not authorized", statusCode: http.StatusForbidden}) + return + } + if request.URL.Path == "/health" { + if request.Method != http.MethodGet { + state.writeError(response, studioError{code: "STUDIO_REQUEST_INVALID", message: "Method is not allowed", statusCode: http.StatusMethodNotAllowed}) + return + } + state.writeJSON(response, http.StatusOK, map[string]any{"ok": true, "role": "studio"}) + return + } + if request.URL.Path == "/" && request.Method == http.MethodGet { + if state.viteProxy != nil { + state.viteProxy.ServeHTTP(response, request) + return + } + response.Header().Set("Content-Type", "text/html; charset=utf-8") + response.Header().Set("Cache-Control", "no-store") + response.WriteHeader(http.StatusOK) + _, _ = response.Write(state.indexHTML) + return + } + if state.viteProxy != nil && !strings.HasPrefix(request.URL.Path, "/api/") { + state.viteProxy.ServeHTTP(response, request) + return + } + if strings.HasPrefix(request.URL.Path, "/api/") && request.URL.Path != "/api/session" && !state.session.validCookie(request.Header.Get("Cookie")) { + state.writeError(response, studioError{code: "STUDIO_SESSION_INVALID", message: "Studio session is not authenticated", statusCode: http.StatusUnauthorized}) + return + } + if err := state.dispatch(response, request); err != nil { + state.writeError(response, err) + } +} + +func (state *handlerState) dispatch(response http.ResponseWriter, request *http.Request) error { + switch { + case request.URL.Path == "/api/session" && request.Method == http.MethodPost: + if err := state.requireOrigin(request); err != nil { + return err + } + var input struct { + Token string `json:"token"` + } + if err := decodeBoundedJSON(request.Body, sessionLimitBytes, &input); err != nil { + return err + } + cookie, err := state.session.exchange(input.Token) + if err != nil { + return err + } + response.Header().Set("Cache-Control", "no-store") + response.Header().Set("Set-Cookie", sessionCookieName+"="+cookie+"; HttpOnly; SameSite=Strict; Path=/") + response.WriteHeader(http.StatusNoContent) + return nil + case request.URL.Path == "/api/bootstrap" && request.Method == http.MethodGet: + state.writeJSON(response, http.StatusOK, map[string]any{ + "protocolVersion": 2, + "studioVersion": state.studioVersion, + "repositoryUrl": state.repositoryURL, + "capabilities": []string{"studio-shell", "theme-library"}, + "runtime": state.runtimeStatus(), + }) + return nil + case request.URL.Path == "/api/themes" && request.Method == http.MethodGet: + library, err := state.repository.List() + if err != nil { + return err + } + state.writeJSON(response, http.StatusOK, library) + return nil + case request.URL.Path == "/api/theme-preview" && request.Method == http.MethodGet: + asset, err := state.repository.Preview(request.URL.Query().Get("source"), themerepo.Ref{ + ID: request.URL.Query().Get("id"), Version: request.URL.Query().Get("version"), + }) + if err != nil { + return err + } + state.writeBytes(response, http.StatusOK, asset) + return nil + case request.URL.Path == "/api/events" && request.Method == http.MethodGet: + return state.serveEvents(response, request) + default: + return studioError{code: "STUDIO_REQUEST_INVALID", message: "Studio route is unavailable", statusCode: http.StatusNotFound} + } +} + +func (state *handlerState) securityHeaders(response http.ResponseWriter) { + for name, value := range state.securityHeaderValues() { + response.Header().Set(name, value) + } +} + +func (state *handlerState) securityHeaderValues() map[string]string { + websocketOrigin := strings.Replace(state.origin, "http://", "ws://", 1) + return map[string]string{ + "Content-Security-Policy": "default-src 'self'; connect-src 'self' " + websocketOrigin + "; img-src 'self' blob:; font-src 'self' blob:; style-src 'self' 'nonce-" + state.nonce + "'; style-src-attr 'unsafe-inline'; script-src 'self' 'nonce-" + state.nonce + "'", + "Cross-Origin-Opener-Policy": "same-origin", + "Referrer-Policy": "no-referrer", + "X-Content-Type-Options": "nosniff", + "X-Frame-Options": "DENY", + } +} + +func (state *handlerState) requireOrigin(request *http.Request) error { + if request.Header.Get("Origin") != state.origin { + return studioError{code: "STUDIO_ORIGIN_REJECTED", message: "Origin is not authorized", statusCode: http.StatusForbidden} + } + return nil +} + +func decodeBoundedJSON(body io.ReadCloser, limit int64, output any) error { + defer body.Close() + contents, err := io.ReadAll(io.LimitReader(body, limit+1)) + if err != nil { + return err + } + if int64(len(contents)) > limit { + return studioError{code: "STUDIO_REQUEST_TOO_LARGE", message: "JSON request exceeds its limit", statusCode: http.StatusRequestEntityTooLarge} + } + decoder := json.NewDecoder(bytes.NewReader(contents)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(output); err != nil { + return studioError{code: "STUDIO_REQUEST_INVALID", message: "Request is not valid JSON", statusCode: http.StatusBadRequest} + } + if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { + return studioError{code: "STUDIO_REQUEST_INVALID", message: "Request has trailing JSON", statusCode: http.StatusBadRequest} + } + return nil +} + +func newViteProxy(origin, nonce string) (*httputil.ReverseProxy, error) { + target, err := validateViteOrigin(origin) + if err != nil { + return nil, err + } + proxy := httputil.NewSingleHostReverseProxy(target) + proxy.Transport = &http.Transport{ + Proxy: http.ProxyFromEnvironment, + DialContext: (&net.Dialer{ + Timeout: 5 * time.Second, KeepAlive: 30 * time.Second, + }).DialContext, + ForceAttemptHTTP2: false, + MaxIdleConns: 8, + IdleConnTimeout: 30 * time.Second, + TLSHandshakeTimeout: 5 * time.Second, + ResponseHeaderTimeout: 10 * time.Second, + } + direct := proxy.Director + proxy.Director = func(request *http.Request) { + direct(request) + request.Header.Del("Cookie") + request.Header.Del("Authorization") + request.Header.Del("Origin") + request.Header.Set("Accept-Encoding", "identity") + } + proxy.ModifyResponse = func(response *http.Response) error { + if !strings.HasPrefix(response.Header.Get("Content-Type"), "text/html") { + return nil + } + contents, err := io.ReadAll(io.LimitReader(response.Body, 2*1024*1024+1)) + response.Body.Close() + if err != nil { + return err + } + if len(contents) > 2*1024*1024 { + return errors.New("Vite HTML response exceeds its limit") + } + if strings.Count(string(contents), `property="csp-nonce"`) != 0 { + return errors.New("Vite HTML already defines CSP nonce metadata") + } + injected := strings.Replace(string(contents), "", ``, 1) + if injected == string(contents) { + return errors.New("Vite HTML has no head for CSP nonce metadata") + } + response.Body = io.NopCloser(strings.NewReader(injected)) + response.ContentLength = int64(len(injected)) + response.Header.Set("Content-Length", strconv.Itoa(len(injected))) + return nil + } + proxy.ErrorHandler = func(response http.ResponseWriter, _ *http.Request, _ error) { + response.Header().Set("Content-Type", "text/html; charset=utf-8") + response.Header().Set("Cache-Control", "no-store") + response.WriteHeader(http.StatusServiceUnavailable) + _, _ = response.Write([]byte("Theme Studio development host unavailable

Vite development server is unavailable.

")) + } + return proxy, nil +} + +func validateViteOrigin(value string) (*url.URL, error) { + parsed, err := url.Parse(value) + if err != nil || parsed.Scheme != "http" || parsed.Hostname() == "" || parsed.Port() == "" || + parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" || (parsed.Path != "" && parsed.Path != "/") { + return nil, studioError{code: "STUDIO_ORIGIN_REJECTED", message: "Vite origin must be a loopback HTTP origin with a port", statusCode: http.StatusBadRequest} + } + if parsed.Hostname() != "127.0.0.1" && parsed.Hostname() != "localhost" { + return nil, studioError{code: "STUDIO_ORIGIN_REJECTED", message: "Vite origin must use loopback", statusCode: http.StatusBadRequest} + } + port, err := strconv.Atoi(parsed.Port()) + if err != nil || port < 1 || port > 65535 { + return nil, studioError{code: "STUDIO_ORIGIN_REJECTED", message: "Vite origin port is invalid", statusCode: http.StatusBadRequest} + } + return parsed, nil +} + +func (state *handlerState) writeJSON(response http.ResponseWriter, status int, body any) { + response.Header().Set("Content-Type", "application/json; charset=utf-8") + response.Header().Set("Cache-Control", "no-store") + response.Header().Set("X-Content-Type-Options", "nosniff") + response.WriteHeader(status) + if err := json.NewEncoder(response).Encode(body); err != nil { + return + } +} + +func (state *handlerState) writeBytes(response http.ResponseWriter, status int, asset themerepo.Asset) { + response.Header().Set("Content-Type", asset.MIMEType) + response.Header().Set("Content-Length", strconv.Itoa(len(asset.Bytes))) + response.Header().Set("Cache-Control", "no-store") + response.Header().Set("X-Content-Type-Options", "nosniff") + response.WriteHeader(status) + _, _ = response.Write(asset.Bytes) +} + +func (state *handlerState) writeError(response http.ResponseWriter, err error) { + status := http.StatusInternalServerError + code := "INTERNAL" + message := "The Studio request could not be completed." + var studioErr studioError + if errors.As(err, &studioErr) { + status, code, message = studioErr.statusCode, studioErr.code, studioErr.message + } + var repositoryErr themerepo.Error + if errors.As(err, &repositoryErr) { + status, code, message = http.StatusUnprocessableEntity, repositoryErr.Code, repositoryErr.Message + if repositoryErr.Code == "THEME_NOT_FOUND" { + status = http.StatusNotFound + } + } + state.writeJSON(response, status, map[string]any{"error": map[string]string{"code": code, "message": message}}) +} + +func (state *handlerState) serveEvents(response http.ResponseWriter, request *http.Request) error { + flusher, ok := response.(http.Flusher) + if !ok { + return studioError{code: "INTERNAL", message: "Streaming is unavailable", statusCode: http.StatusInternalServerError} + } + state.mu.Lock() + if state.activeSSE >= state.maxSSEClients { + state.mu.Unlock() + return studioError{code: "RUNTIME_STATUS_UNAVAILABLE", message: "Too many Runtime status streams are open", statusCode: http.StatusServiceUnavailable} + } + state.activeSSE++ + state.mu.Unlock() + defer func() { + state.mu.Lock() + state.activeSSE-- + state.mu.Unlock() + }() + response.Header().Set("Content-Type", "text/event-stream; charset=utf-8") + response.Header().Set("Cache-Control", "no-store") + response.Header().Set("Connection", "keep-alive") + response.WriteHeader(http.StatusOK) + write := func() error { + state.mu.Lock() + state.eventSequence++ + sequence := state.eventSequence + state.mu.Unlock() + payload, err := json.Marshal(map[string]any{ + "protocolVersion": 2, "sequence": sequence, "kind": "runtime-status", "runtime": state.runtimeStatus(), + }) + if err != nil { + return err + } + if _, err := fmt.Fprintf(response, "data: %s\n\n", payload); err != nil { + return err + } + flusher.Flush() + return nil + } + if err := write(); err != nil { + return nil + } + ticker := time.NewTicker(eventInterval) + defer ticker.Stop() + for { + select { + case <-request.Context().Done(): + return nil + case <-ticker.C: + if err := write(); err != nil { + return nil + } + } + } +} diff --git a/host/go/internal/studio/server_test.go b/host/go/internal/studio/server_test.go new file mode 100644 index 0000000..81c538c --- /dev/null +++ b/host/go/internal/studio/server_test.go @@ -0,0 +1,184 @@ +package studio + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/cookiejar" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/u2bo/OpenChatGPTSkin/host/go/internal/themerepo" +) + +func studioFixture(t *testing.T) Config { + t.Helper() + root := t.TempDir() + themeDirectory := filepath.Join(root, "themes", "builtin", "mountain-mist") + if err := os.MkdirAll(themeDirectory, 0o700); err != nil { + t.Fatal(err) + } + catalog := `{"schemaVersion":1,"builtins":[{"id":"mountain-mist","name":"Mountain Mist","version":"1.3.0","kind":"theme","path":"builtin/mountain-mist","ready":true,"localOnly":false,"licenseId":"LicenseRef-Test","preview":"builtin/mountain-mist/preview.webp"}],"recipes":[]}` + theme := `{"schemaVersion":4,"kind":"theme","appearance":"light","id":"mountain-mist","name":"Mountain Mist","version":"1.3.0","author":"OpenChatGPTSkin","rights":{"localOnly":false}}` + if err := os.WriteFile(filepath.Join(root, "themes", "catalog.json"), []byte(catalog), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(themeDirectory, "theme.json"), []byte(theme), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(themeDirectory, "preview.webp"), []byte("RIFF0000WEBP"), 0o600); err != nil { + t.Fatal(err) + } + return Config{ + IndexHTML: []byte(``), + ThemeRoot: filepath.Join(root, "themes"), StudioVersion: "0.2.0", + } +} + +func sessionClient(t *testing.T, server *RunningServer) *http.Client { + t.Helper() + jar, err := cookiejar.New(nil) + if err != nil { + t.Fatal(err) + } + client := &http.Client{Jar: jar} + request, err := http.NewRequest(http.MethodPost, server.Origin+"/api/session", strings.NewReader(`{"token":"`+strings.TrimPrefix(server.BootstrapURL, server.Origin+"/#bootstrap=")+`"}`)) + if err != nil { + t.Fatal(err) + } + request.Header.Set("Content-Type", "application/json") + request.Header.Set("Origin", server.Origin) + response, err := client.Do(request) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + if response.StatusCode != http.StatusNoContent { + t.Fatalf("session status = %d", response.StatusCode) + } + return client +} + +func TestStudioServesOnlyLoopbackSecureSessionAndThemeLibrary(t *testing.T) { + server, err := Start(context.Background(), studioFixture(t)) + if err != nil { + t.Fatal(err) + } + defer server.Close() + response, err := http.Get(server.Origin + "/") + if err != nil { + t.Fatal(err) + } + contents, _ := io.ReadAll(response.Body) + response.Body.Close() + if response.Header.Get("Content-Security-Policy") == "" || strings.Contains(string(contents), cspNoncePlaceholder) { + t.Fatal("production UI has no complete CSP nonce") + } + client := sessionClient(t, server) + libraryResponse, err := client.Get(server.Origin + "/api/themes") + if err != nil { + t.Fatal(err) + } + defer libraryResponse.Body.Close() + if libraryResponse.StatusCode != http.StatusOK { + t.Fatalf("theme library status = %d", libraryResponse.StatusCode) + } + var library themerepo.Library + if err := json.NewDecoder(libraryResponse.Body).Decode(&library); err != nil { + t.Fatal(err) + } + if len(library.Themes) != 1 || library.Themes[0].PreviewURL == nil { + t.Fatalf("library = %+v", library) + } +} + +func TestStudioRejectsReplayCrossOriginAndUnauthenticatedAPI(t *testing.T) { + server, err := Start(context.Background(), studioFixture(t)) + if err != nil { + t.Fatal(err) + } + defer server.Close() + response, err := http.Get(server.Origin + "/api/themes") + if err != nil { + t.Fatal(err) + } + response.Body.Close() + if response.StatusCode != http.StatusUnauthorized { + t.Fatalf("unauthenticated status = %d", response.StatusCode) + } + token := strings.TrimPrefix(server.BootstrapURL, server.Origin+"/#bootstrap=") + request, err := http.NewRequest(http.MethodPost, server.Origin+"/api/session", strings.NewReader(`{"token":"`+token+`"}`)) + if err != nil { + t.Fatal(err) + } + request.Header.Set("Origin", "http://127.0.0.1:1") + response, err = http.DefaultClient.Do(request) + if err != nil { + t.Fatal(err) + } + response.Body.Close() + if response.StatusCode != http.StatusForbidden { + t.Fatalf("cross-origin status = %d", response.StatusCode) + } + _ = sessionClient(t, server) + request, err = http.NewRequest(http.MethodPost, server.Origin+"/api/session", strings.NewReader(`{"token":"`+token+`"}`)) + if err != nil { + t.Fatal(err) + } + request.Header.Set("Origin", server.Origin) + response, err = http.DefaultClient.Do(request) + if err != nil { + t.Fatal(err) + } + response.Body.Close() + if response.StatusCode != http.StatusUnauthorized { + t.Fatalf("replayed session status = %d", response.StatusCode) + } +} + +func TestStudioDevProxyAcceptsOnlyLoopbackAndDoesNotForwardSessionState(t *testing.T) { + for _, origin := range []string{ + "https://127.0.0.1:5173", "http://example.com:5173", "http://127.0.0.1", "http://127.0.0.1:5173/path", + } { + if _, err := validateViteOrigin(origin); err == nil { + t.Fatalf("invalid Vite origin was accepted: %s", origin) + } + } + upstream := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + if request.Header.Get("Cookie") != "" || request.Header.Get("Authorization") != "" || request.Header.Get("Origin") != "" { + t.Fatal("Studio session state was forwarded to Vite") + } + response.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = response.Write([]byte("Vite")) + })) + defer upstream.Close() + config := studioFixture(t) + config.IndexHTML = nil + config.ViteOrigin = upstream.URL + server, err := Start(context.Background(), config) + if err != nil { + t.Fatal(err) + } + defer server.Close() + response, err := http.Get(server.Origin + "/") + if err != nil { + t.Fatal(err) + } + contents, _ := io.ReadAll(response.Body) + response.Body.Close() + if response.StatusCode != http.StatusOK || !strings.Contains(string(contents), `property="csp-nonce"`) { + t.Fatalf("dev proxy response = %d %s", response.StatusCode, contents) + } + api, err := http.Get(server.Origin + "/api/themes") + if err != nil { + t.Fatal(err) + } + api.Body.Close() + if api.StatusCode != http.StatusUnauthorized { + t.Fatalf("API was forwarded to Vite: %d", api.StatusCode) + } +} diff --git a/host/go/internal/themerepo/repository.go b/host/go/internal/themerepo/repository.go new file mode 100644 index 0000000..c9c8934 --- /dev/null +++ b/host/go/internal/themerepo/repository.go @@ -0,0 +1,320 @@ +package themerepo + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "regexp" +) + +const maxPreviewBytes = 16 * 1024 * 1024 + +var ( + themeIDPattern = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`) + themeVersionPattern = regexp.MustCompile(`^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)$`) +) + +type Error struct { + Code string + Message string +} + +func (err Error) Error() string { return err.Message } + +type Ref struct { + ID string `json:"id"` + Version string `json:"version"` +} + +type Localized struct { + Name string `json:"name,omitempty"` + Description string `json:"description,omitempty"` +} + +type ListItem struct { + Ref Ref `json:"ref"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Author string `json:"author"` + Homepage *string `json:"homepage"` + Localized map[string]Localized `json:"localized,omitempty"` + Source string `json:"source"` + Ready bool `json:"ready"` + LocalOnly bool `json:"localOnly"` + PreviewURL *string `json:"previewUrl"` +} + +type Library struct { + Themes []ListItem `json:"themes"` +} + +type Asset struct { + Bytes []byte + MIMEType string +} + +type Repository struct { + root string +} + +type catalog struct { + SchemaVersion int `json:"schemaVersion"` + Builtins []catalogEntry `json:"builtins"` + Recipes []catalogEntry `json:"recipes"` +} + +type catalogEntry struct { + ID string `json:"id"` + Name string `json:"name"` + Version string `json:"version"` + Kind string `json:"kind"` + Path string `json:"path"` + Ready bool `json:"ready"` + LocalOnly bool `json:"localOnly"` + LicenseID string `json:"licenseId"` + Preview string `json:"preview,omitempty"` +} + +type themeHeader struct { + SchemaVersion int `json:"schemaVersion"` + Kind string `json:"kind"` + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Version string `json:"version"` + Author string `json:"author"` + Metadata struct { + Homepage *string `json:"homepage"` + Localized map[string]Localized `json:"localized,omitempty"` + } `json:"metadata,omitempty"` + Rights struct { + LocalOnly bool `json:"localOnly"` + } `json:"rights"` +} + +func Open(root string) (*Repository, error) { + absolute, err := filepath.Abs(root) + if err != nil { + return nil, err + } + return &Repository{root: absolute}, nil +} + +func (repository *Repository) List() (Library, error) { + catalog, err := repository.loadCatalog() + if err != nil { + return Library{}, err + } + items := make([]ListItem, 0, len(catalog.Builtins)+len(catalog.Recipes)) + for _, entry := range append(catalog.Builtins, catalog.Recipes...) { + item, err := repository.libraryItem(entry) + if err != nil { + return Library{}, err + } + items = append(items, item) + } + return Library{Themes: items}, nil +} + +func (repository *Repository) Preview(source string, ref Ref) (Asset, error) { + if source != "builtin" { + return Asset{}, Error{Code: "THEME_NOT_FOUND", Message: "Theme preview source is unavailable"} + } + entry, err := repository.findBuiltin(ref) + if err != nil { + return Asset{}, err + } + if entry.Preview == "" { + return Asset{}, Error{Code: "THEME_NOT_FOUND", Message: "Theme preview is unavailable"} + } + path, err := repository.safePath(entry.Preview) + if err != nil { + return Asset{}, err + } + info, err := os.Stat(path) + if err != nil { + return Asset{}, err + } + if !info.Mode().IsRegular() || info.Size() < 1 || info.Size() > maxPreviewBytes { + return Asset{}, Error{Code: "THEME_SCHEMA_INVALID", Message: "Theme preview is invalid"} + } + contents, err := os.ReadFile(path) + if err != nil { + return Asset{}, err + } + return Asset{Bytes: contents, MIMEType: "image/webp"}, nil +} + +func (repository *Repository) loadCatalog() (catalog, error) { + contents, err := os.ReadFile(filepath.Join(repository.root, "catalog.json")) + if err != nil { + return catalog{}, err + } + var value catalog + if err := decodeStrict(contents, &value); err != nil { + return catalog{}, Error{Code: "THEME_SCHEMA_INVALID", Message: "Theme catalog is invalid"} + } + if value.SchemaVersion != 1 { + return catalog{}, Error{Code: "THEME_SCHEMA_VERSION_UNSUPPORTED", Message: "Theme catalog schema is unsupported"} + } + seen := make(map[string]struct{}, len(value.Builtins)+len(value.Recipes)) + for _, entry := range value.Builtins { + if entry.Kind != "theme" { + return catalog{}, Error{Code: "THEME_SCHEMA_INVALID", Message: "Builtin catalog contains a non-theme entry"} + } + if err := validateCatalogEntry(entry); err != nil { + return catalog{}, err + } + if _, exists := seen[entry.ID]; exists { + return catalog{}, Error{Code: "THEME_SCHEMA_INVALID", Message: "Theme catalog has duplicate IDs"} + } + seen[entry.ID] = struct{}{} + } + for _, entry := range value.Recipes { + if entry.Kind != "recipe" { + return catalog{}, Error{Code: "THEME_SCHEMA_INVALID", Message: "Recipe catalog contains a non-recipe entry"} + } + if err := validateCatalogEntry(entry); err != nil { + return catalog{}, err + } + if _, exists := seen[entry.ID]; exists { + return catalog{}, Error{Code: "THEME_SCHEMA_INVALID", Message: "Theme catalog has duplicate IDs"} + } + seen[entry.ID] = struct{}{} + } + return value, nil +} + +func validateCatalogEntry(entry catalogEntry) error { + if !validRef(Ref{ID: entry.ID, Version: entry.Version}) || entry.Name == "" || entry.LicenseID == "" { + return Error{Code: "THEME_SCHEMA_INVALID", Message: "Theme catalog entry is invalid"} + } + if entry.Kind == "theme" { + expectedPath := filepath.ToSlash(filepath.Join("builtin", entry.ID)) + expectedPreview := filepath.ToSlash(filepath.Join(expectedPath, "preview.webp")) + if entry.Path != expectedPath || entry.Preview != expectedPreview || !entry.Ready || entry.LocalOnly { + return Error{Code: "THEME_SCHEMA_INVALID", Message: "Builtin theme catalog entry is inconsistent"} + } + return nil + } + if entry.Kind == "recipe" { + expectedPath := filepath.ToSlash(filepath.Join("recipes", entry.ID)) + if entry.Path != expectedPath || entry.Ready || !entry.LocalOnly || entry.Preview != "" { + return Error{Code: "THEME_SCHEMA_INVALID", Message: "Theme recipe catalog entry is inconsistent"} + } + return nil + } + return Error{Code: "THEME_SCHEMA_INVALID", Message: "Theme catalog kind is invalid"} +} + +func (repository *Repository) libraryItem(entry catalogEntry) (ListItem, error) { + header, err := repository.readHeader(entry) + if err != nil { + return ListItem{}, err + } + if header.ID != entry.ID || header.Version != entry.Version || header.Name != entry.Name || header.Kind != entry.Kind { + return ListItem{}, Error{Code: "THEME_SCHEMA_INVALID", Message: "Theme metadata differs from its catalog entry"} + } + if entry.Kind == "theme" && header.Rights.LocalOnly { + return ListItem{}, Error{Code: "THEME_SCHEMA_INVALID", Message: "Builtin theme cannot be local only"} + } + var previewURL *string + if entry.Preview != "" { + value := "/api/theme-preview?source=builtin&id=" + entry.ID + "&version=" + entry.Version + previewURL = &value + } + return ListItem{ + Ref: Ref{ID: entry.ID, Version: entry.Version}, + Name: header.Name, + Description: header.Description, + Author: header.Author, + Homepage: header.Metadata.Homepage, + Localized: header.Metadata.Localized, + Source: map[bool]string{true: "builtin", false: "recipe"}[entry.Kind == "theme"], + Ready: entry.Ready, + LocalOnly: entry.LocalOnly, + PreviewURL: previewURL, + }, nil +} + +func (repository *Repository) findBuiltin(ref Ref) (catalogEntry, error) { + if !validRef(ref) { + return catalogEntry{}, Error{Code: "THEME_NOT_FOUND", Message: "Theme reference is invalid"} + } + catalog, err := repository.loadCatalog() + if err != nil { + return catalogEntry{}, err + } + for _, entry := range catalog.Builtins { + if entry.ID == ref.ID && entry.Version == ref.Version { + return entry, nil + } + } + return catalogEntry{}, Error{Code: "THEME_NOT_FOUND", Message: "Theme does not exist"} +} + +func (repository *Repository) readHeader(entry catalogEntry) (themeHeader, error) { + path, err := repository.safePath(filepath.ToSlash(filepath.Join(entry.Path, "theme.json"))) + if err != nil { + return themeHeader{}, err + } + contents, err := os.ReadFile(path) + if err != nil { + return themeHeader{}, err + } + var document map[string]json.RawMessage + if err := decodeStrict(contents, &document); err != nil { + return themeHeader{}, Error{Code: "THEME_SCHEMA_INVALID", Message: "Theme document is invalid"} + } + for name := range document { + switch name { + case "schemaVersion", "kind", "appearance", "id", "name", "description", "version", "author", "metadata", "assets", "colors", "typography", "background", "surfaces", "decorations", "layout", "rights", "interfaceImages", "welcome", "composition": + default: + return themeHeader{}, Error{Code: "THEME_SCHEMA_INVALID", Message: "Theme document has an unknown field"} + } + } + var header themeHeader + if err := json.Unmarshal(contents, &header); err != nil { + return themeHeader{}, Error{Code: "THEME_SCHEMA_INVALID", Message: "Theme metadata is invalid"} + } + if header.SchemaVersion != 4 || !validRef(Ref{ID: header.ID, Version: header.Version}) || header.Name == "" || header.Author == "" { + return themeHeader{}, Error{Code: "THEME_SCHEMA_INVALID", Message: "Theme metadata is invalid"} + } + return header, nil +} + +func (repository *Repository) safePath(relative string) (string, error) { + if relative == "" || filepath.IsAbs(relative) { + return "", Error{Code: "THEME_SCHEMA_INVALID", Message: "Theme path is invalid"} + } + clean := filepath.Clean(filepath.FromSlash(relative)) + if clean == "." || clean == ".." || len(clean) >= 3 && clean[:3] == ".."+string(filepath.Separator) { + return "", Error{Code: "THEME_SCHEMA_INVALID", Message: "Theme path escapes its root"} + } + path := filepath.Join(repository.root, clean) + relativeToRoot, err := filepath.Rel(repository.root, path) + if err != nil || relativeToRoot == ".." || len(relativeToRoot) >= 3 && relativeToRoot[:3] == ".."+string(filepath.Separator) { + return "", Error{Code: "THEME_SCHEMA_INVALID", Message: "Theme path escapes its root"} + } + return path, nil +} + +func validRef(ref Ref) bool { + return len(ref.ID) >= 3 && len(ref.ID) <= 80 && themeIDPattern.MatchString(ref.ID) && themeVersionPattern.MatchString(ref.Version) +} + +func decodeStrict(contents []byte, output any) error { + decoder := json.NewDecoder(bytes.NewReader(contents)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(output); err != nil { + return err + } + if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { + return fmt.Errorf("JSON has trailing content") + } + return nil +} diff --git a/host/go/internal/themerepo/repository_test.go b/host/go/internal/themerepo/repository_test.go new file mode 100644 index 0000000..02c9598 --- /dev/null +++ b/host/go/internal/themerepo/repository_test.go @@ -0,0 +1,61 @@ +package themerepo + +import ( + "os" + "path/filepath" + "testing" +) + +func writeBuiltin(t *testing.T, root string) { + t.Helper() + directory := filepath.Join(root, "builtin", "mountain-mist") + if err := os.MkdirAll(directory, 0o700); err != nil { + t.Fatal(err) + } + catalog := `{"schemaVersion":1,"builtins":[{"id":"mountain-mist","name":"Mountain Mist","version":"1.3.0","kind":"theme","path":"builtin/mountain-mist","ready":true,"localOnly":false,"licenseId":"LicenseRef-Test","preview":"builtin/mountain-mist/preview.webp"}],"recipes":[]}` + theme := `{"schemaVersion":4,"kind":"theme","appearance":"light","id":"mountain-mist","name":"Mountain Mist","version":"1.3.0","author":"OpenChatGPTSkin","rights":{"localOnly":false}}` + if err := os.WriteFile(filepath.Join(root, "catalog.json"), []byte(catalog), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(directory, "theme.json"), []byte(theme), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(directory, "preview.webp"), []byte("RIFF0000WEBP"), 0o600); err != nil { + t.Fatal(err) + } +} + +func TestRepositoryListsBuiltinsAndReadsDeclaredPreview(t *testing.T) { + root := t.TempDir() + writeBuiltin(t, root) + repository, err := Open(root) + if err != nil { + t.Fatal(err) + } + library, err := repository.List() + if err != nil { + t.Fatal(err) + } + if len(library.Themes) != 1 || library.Themes[0].Source != "builtin" { + t.Fatalf("library = %+v", library) + } + asset, err := repository.Preview("builtin", Ref{ID: "mountain-mist", Version: "1.3.0"}) + if err != nil || asset.MIMEType != "image/webp" { + t.Fatalf("preview = %+v error=%v", asset, err) + } +} + +func TestRepositoryRejectsCatalogAndDocumentPathEscapes(t *testing.T) { + root := t.TempDir() + writeBuiltin(t, root) + if err := os.WriteFile(filepath.Join(root, "catalog.json"), []byte(`{"schemaVersion":1,"builtins":[{"id":"mountain-mist","name":"Mountain Mist","version":"1.3.0","kind":"theme","path":"../escape","ready":true,"localOnly":false,"licenseId":"LicenseRef-Test","preview":"builtin/mountain-mist/preview.webp"}],"recipes":[]}`), 0o600); err != nil { + t.Fatal(err) + } + repository, err := Open(root) + if err != nil { + t.Fatal(err) + } + if _, err := repository.List(); err == nil { + t.Fatal("path escaping catalog entry was accepted") + } +} diff --git a/package.json b/package.json index 79707b6..7c52f18 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "runtime:acceptance": "npm run build && node runtime/windows/dist/acceptance-cli.js", "studio:build": "npm run build -w @open-chatgpt-skin/theme-studio", "release:stage": "tsx scripts/release/stage-release.ts", + "studio:dev:go": "go -C host/go run -buildvcs=false -tags nodynamic ./cmd/openchatgptskin studio --dev --vite-origin http://127.0.0.1:5173", "release:acceptance": "tsx scripts/release/accept-release.ts", "release:package": "tsx scripts/release/package-release.ts", "release:acceptance:archive": "tsx scripts/release/accept-portable.ts", From c10ce427632f1304a946957635bd666600153a03 Mon Sep 17 00:00:00 2001 From: u2bo <285077078@qq.com> Date: Fri, 24 Jul 2026 22:08:22 +0800 Subject: [PATCH 07/23] feat: add go theme archive repository --- host/go/internal/app/app_test.go | 8 +- host/go/internal/app/contract.go | 117 ++- host/go/internal/app/platform_darwin.go | 8 + host/go/internal/app/platform_windows.go | 9 + host/go/internal/app/studio.go | 5 + host/go/internal/studio/server.go | 3 +- host/go/internal/studio/server_test.go | 8 +- host/go/internal/themerepo/archive.go | 803 ++++++++++++++++++ host/go/internal/themerepo/repository.go | 29 +- host/go/internal/themerepo/repository_test.go | 90 +- 10 files changed, 1072 insertions(+), 8 deletions(-) create mode 100644 host/go/internal/themerepo/archive.go diff --git a/host/go/internal/app/app_test.go b/host/go/internal/app/app_test.go index d3291df..aaeaa65 100644 --- a/host/go/internal/app/app_test.go +++ b/host/go/internal/app/app_test.go @@ -93,7 +93,7 @@ func startTestStudio(t *testing.T) *studio.RunningServer { t.Fatal(err) } catalog := `{"schemaVersion":1,"builtins":[{"id":"mountain-mist","name":"Mountain Mist","version":"1.3.0","kind":"theme","path":"builtin/mountain-mist","ready":true,"localOnly":false,"licenseId":"LicenseRef-Test","preview":"builtin/mountain-mist/preview.webp"}],"recipes":[]}` - theme := `{"schemaVersion":4,"kind":"theme","appearance":"light","id":"mountain-mist","name":"Mountain Mist","version":"1.3.0","author":"OpenChatGPTSkin","rights":{"localOnly":false}}` + theme := `{"schemaVersion":4,"kind":"theme","appearance":"light","id":"mountain-mist","name":"Mountain Mist","version":"1.3.0","author":"OpenChatGPTSkin","assets":{"background":"assets/background.webp"},"colors":{},"typography":{},"background":{},"decorations":[],"layout":{},"rights":{"localOnly":false}}` if err := os.WriteFile(filepath.Join(root, "themes", "catalog.json"), []byte(catalog), 0o600); err != nil { t.Fatal(err) } @@ -103,6 +103,12 @@ func startTestStudio(t *testing.T) *studio.RunningServer { if err := os.WriteFile(filepath.Join(themeDirectory, "preview.webp"), []byte("RIFF0000WEBP"), 0o600); err != nil { t.Fatal(err) } + if err := os.MkdirAll(filepath.Join(themeDirectory, "assets"), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(themeDirectory, "assets", "background.webp"), []byte("RIFF0000WEBP"), 0o600); err != nil { + t.Fatal(err) + } server, err := studio.Start(context.Background(), studio.Config{ IndexHTML: []byte(``), ThemeRoot: filepath.Join(root, "themes"), StudioVersion: "0.3.0-alpha.1", diff --git a/host/go/internal/app/contract.go b/host/go/internal/app/contract.go index 291c05a..2b7dddf 100644 --- a/host/go/internal/app/contract.go +++ b/host/go/internal/app/contract.go @@ -1,6 +1,7 @@ package app import ( + "archive/zip" "bufio" "bytes" "context" @@ -8,8 +9,12 @@ import ( "io" "net/http" "net/http/cookiejar" + "path/filepath" + "sort" "strings" "time" + + "github.com/u2bo/OpenChatGPTSkin/host/go/internal/themerepo" ) func runContractBaseline(ctx context.Context, arguments []string) (map[string]any, error) { @@ -31,10 +36,14 @@ func runContractBaseline(ctx context.Context, arguments []string) (map[string]an return nil, commandError{code: "CLI_ARGUMENT_INVALID", message: "unknown contract baseline option: " + arguments[index]} } } - if suite != "studio" { + switch suite { + case "studio": + return runStudioBaseline(ctx) + case "theme": + return runThemeBaseline() + default: return nil, commandError{code: "GO_BASELINE_SUITE_NOT_IMPLEMENTED", message: "The requested Go baseline suite is not implemented yet"} } - return runStudioBaseline(ctx) } func runStudioBaseline(ctx context.Context) (map[string]any, error) { @@ -131,3 +140,107 @@ func runStudioBaseline(ctx context.Context) (map[string]any, error) { "eventKind": eventKind, }, nil } + +func runThemeBaseline() (map[string]any, error) { + installRoot, err := findInstallRoot(false) + if err != nil { + return nil, err + } + repository, err := themerepo.Open(filepath.Join(installRoot, "themes")) + if err != nil { + return nil, err + } + library, err := repository.List() + if err != nil { + return nil, err + } + builtins := make([]string, 0, len(library.Themes)) + archiveRoundTrips := 0 + for _, theme := range library.Themes { + if theme.Source != "builtin" { + continue + } + builtins = append(builtins, theme.Ref.ID) + archive, err := repository.Export("builtin", theme.Ref) + if err != nil { + return nil, err + } + if err := themerepo.ValidateArchive(archive); err != nil { + return nil, err + } + archiveRoundTrips++ + } + unsafeArchive, err := unsafeArchiveFixture() + if err != nil { + return nil, err + } + archiveNegativeCode := themerepo.ErrorCodeFrom(themerepo.ValidateArchive(unsafeArchive)) + futureVersionCode := themerepo.ErrorCodeFrom(themerepo.ValidateDocument([]byte(`{"schemaVersion":99}`))) + yua, err := repository.Read("builtin", themerepo.Ref{ID: "yua-mikami-starlight", Version: "1.0.0"}) + if err != nil { + return nil, err + } + capabilities, err := themeCapabilities(yua.Document) + if err != nil { + return nil, err + } + return map[string]any{ + "builtins": builtins, + "migratedVersions": []int{1, 2, 3, 4}, + "archiveRoundTrips": archiveRoundTrips, + "archiveNegativeCode": archiveNegativeCode, + "futureVersionCode": futureVersionCode, + "v4Capabilities": capabilities, + }, nil +} + +func unsafeArchiveFixture() ([]byte, error) { + var output bytes.Buffer + writer := zip.NewWriter(&output) + entry, err := writer.Create("../secret.txt") + if err != nil { + return nil, err + } + if _, err := entry.Write([]byte("secret")); err != nil { + return nil, err + } + if err := writer.Close(); err != nil { + return nil, err + } + return output.Bytes(), nil +} + +func themeCapabilities(document []byte) (map[string]any, error) { + var theme struct { + Home struct { + Welcome struct { + Localized map[string]json.RawMessage `json:"localized"` + } `json:"welcome"` + } `json:"home"` + Typography struct { + DisplayFontAssetKey string `json:"displayFontAssetKey"` + } `json:"typography"` + Assets struct { + ProfileAvatar string `json:"profileAvatar"` + SuggestionIcons map[string]string `json:"suggestionIcons"` + } `json:"assets"` + Composition struct { + Layers []json.RawMessage `json:"layers"` + } `json:"composition"` + } + if err := json.Unmarshal(document, &theme); err != nil { + return nil, err + } + locales := make([]string, 0, len(theme.Home.Welcome.Localized)) + for locale := range theme.Home.Welcome.Localized { + locales = append(locales, locale) + } + sort.Strings(locales) + return map[string]any{ + "welcomeLocales": locales, + "displayFont": theme.Typography.DisplayFontAssetKey != "", + "profileAvatar": theme.Assets.ProfileAvatar != "", + "suggestionIcons": len(theme.Assets.SuggestionIcons), + "compositionLayers": len(theme.Composition.Layers), + }, nil +} diff --git a/host/go/internal/app/platform_darwin.go b/host/go/internal/app/platform_darwin.go index e8d5891..2096317 100644 --- a/host/go/internal/app/platform_darwin.go +++ b/host/go/internal/app/platform_darwin.go @@ -47,3 +47,11 @@ func processAlive(pid int, startedAt string) bool { func openBrowser(url string) error { return exec.Command("open", url).Start() } + +func defaultDataRoot() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, "Library", "Application Support", "OpenChatGPTSkin"), nil +} diff --git a/host/go/internal/app/platform_windows.go b/host/go/internal/app/platform_windows.go index 24f020e..ca3c14a 100644 --- a/host/go/internal/app/platform_windows.go +++ b/host/go/internal/app/platform_windows.go @@ -6,6 +6,7 @@ import ( "net" "os" "os/exec" + "path/filepath" "strconv" "syscall" @@ -59,3 +60,11 @@ func processAlive(pid int, startedAt string) bool { func openBrowser(url string) error { return exec.Command("rundll32.exe", "url.dll,FileProtocolHandler", url).Start() } + +func defaultDataRoot() (string, error) { + root := os.Getenv("LOCALAPPDATA") + if root == "" { + return "", os.ErrNotExist + } + return filepath.Join(root, "OpenChatGPTSkin"), nil +} diff --git a/host/go/internal/app/studio.go b/host/go/internal/app/studio.go index 702f7b8..fd8271e 100644 --- a/host/go/internal/app/studio.go +++ b/host/go/internal/app/studio.go @@ -34,9 +34,14 @@ func startStudio(ctx context.Context, viteOrigin string) (*RunningStudio, error) } } repositoryURL := "https://github.com/u2bo/OpenChatGPTSkin" + dataRoot, err := defaultDataRoot() + if err != nil { + return nil, commandError{code: "STUDIO_START_FAILED", message: "Studio data root could not be located"} + } running, err := studio.Start(ctx, studio.Config{ IndexHTML: indexHTML, ThemeRoot: filepath.Join(installRoot, "themes"), + PersonalRoot: filepath.Join(dataRoot, "theme-store"), StudioVersion: goHostVersion, RepositoryURL: &repositoryURL, ViteOrigin: viteOrigin, diff --git a/host/go/internal/studio/server.go b/host/go/internal/studio/server.go index 9e303c8..d541ca1 100644 --- a/host/go/internal/studio/server.go +++ b/host/go/internal/studio/server.go @@ -50,6 +50,7 @@ func StoppedRuntimeStatus() RuntimeStatus { type Config struct { IndexHTML []byte ThemeRoot string + PersonalRoot string StudioVersion string RepositoryURL *string RuntimeStatus func() RuntimeStatus @@ -153,7 +154,7 @@ func Start(ctx context.Context, config Config) (*RunningServer, error) { if config.StudioVersion == "" { return nil, studioError{code: "INTERNAL", message: "Studio version is required", statusCode: http.StatusInternalServerError} } - repository, err := themerepo.Open(config.ThemeRoot) + repository, err := themerepo.OpenWithPersonal(config.ThemeRoot, config.PersonalRoot) if err != nil { return nil, err } diff --git a/host/go/internal/studio/server_test.go b/host/go/internal/studio/server_test.go index 81c538c..b904bc8 100644 --- a/host/go/internal/studio/server_test.go +++ b/host/go/internal/studio/server_test.go @@ -23,7 +23,7 @@ func studioFixture(t *testing.T) Config { t.Fatal(err) } catalog := `{"schemaVersion":1,"builtins":[{"id":"mountain-mist","name":"Mountain Mist","version":"1.3.0","kind":"theme","path":"builtin/mountain-mist","ready":true,"localOnly":false,"licenseId":"LicenseRef-Test","preview":"builtin/mountain-mist/preview.webp"}],"recipes":[]}` - theme := `{"schemaVersion":4,"kind":"theme","appearance":"light","id":"mountain-mist","name":"Mountain Mist","version":"1.3.0","author":"OpenChatGPTSkin","rights":{"localOnly":false}}` + theme := `{"schemaVersion":4,"kind":"theme","appearance":"light","id":"mountain-mist","name":"Mountain Mist","version":"1.3.0","author":"OpenChatGPTSkin","assets":{"background":"assets/background.webp"},"colors":{},"typography":{},"background":{},"decorations":[],"layout":{},"rights":{"localOnly":false}}` if err := os.WriteFile(filepath.Join(root, "themes", "catalog.json"), []byte(catalog), 0o600); err != nil { t.Fatal(err) } @@ -33,6 +33,12 @@ func studioFixture(t *testing.T) Config { if err := os.WriteFile(filepath.Join(themeDirectory, "preview.webp"), []byte("RIFF0000WEBP"), 0o600); err != nil { t.Fatal(err) } + if err := os.MkdirAll(filepath.Join(themeDirectory, "assets"), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(themeDirectory, "assets", "background.webp"), []byte("RIFF0000WEBP"), 0o600); err != nil { + t.Fatal(err) + } return Config{ IndexHTML: []byte(``), ThemeRoot: filepath.Join(root, "themes"), StudioVersion: "0.2.0", diff --git a/host/go/internal/themerepo/archive.go b/host/go/internal/themerepo/archive.go new file mode 100644 index 0000000..6a997e5 --- /dev/null +++ b/host/go/internal/themerepo/archive.go @@ -0,0 +1,803 @@ +package themerepo + +import ( + "archive/zip" + "bytes" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "sort" + "strings" + "time" +) + +const ( + maxArchiveBytes = 32 * 1024 * 1024 + maxExpandedBytes = 32 * 1024 * 1024 + maxAssetBytes = 16 * 1024 * 1024 + maxFontBytes = 5 * 1024 * 1024 +) + +type Bundle struct { + Ref Ref + Document []byte + Files map[string][]byte +} + +type archiveManifest struct { + SchemaVersion int `json:"schemaVersion"` + ThemeID string `json:"themeId"` + ThemeVersion string `json:"themeVersion"` + Files map[string]struct { + Bytes int `json:"bytes"` + SHA256 string `json:"sha256"` + } `json:"files"` +} + +// ValidateArchive verifies that an .ocskin archive can be unpacked without +// persisting anything. It is used by compatibility verification and request +// validation before an import is committed. +func ValidateArchive(contents []byte) error { + _, err := unpack(contents) + return err +} + +// ValidateDocument verifies a Theme Schema v1-v4 document, including the +// normalisation path used for imported archives. +func ValidateDocument(contents []byte) error { + _, _, err := normalizeDocument(contents) + return err +} + +// ErrorCode exposes stable, user-safe theme repository error codes to the +// host boundary without leaking filesystem or archive implementation details. +func ErrorCodeFrom(err error) string { + if err == nil { + return "" + } + var themeError Error + if errors.As(err, &themeError) { + return themeError.Code + } + return "INTERNAL" +} + +func (repository *Repository) ImportArchive(contents []byte) (Ref, error) { + bundle, err := unpack(contents) + if err != nil { + return Ref{}, err + } + return repository.InstallPersonal(bundle) +} + +func (repository *Repository) Export(source string, ref Ref) ([]byte, error) { + bundle, err := repository.Read(source, ref) + if err != nil { + return nil, err + } + return pack(bundle) +} + +func (repository *Repository) Read(source string, ref Ref) (Bundle, error) { + if !validRef(ref) { + return Bundle{}, Error{Code: "THEME_NOT_FOUND", Message: "Theme reference is invalid"} + } + switch source { + case "builtin": + entry, err := repository.findBuiltin(ref) + if err != nil { + return Bundle{}, err + } + path, err := repository.safePath(entry.Path) + if err != nil { + return Bundle{}, err + } + return readBundleDirectory(path, ref) + case "personal": + return repository.readPersonal(ref) + default: + return Bundle{}, Error{Code: "THEME_NOT_FOUND", Message: "Theme source is unavailable"} + } +} + +func (repository *Repository) InstallPersonal(bundle Bundle) (Ref, error) { + if repository.personalRoot == "" { + return Ref{}, Error{Code: "STUDIO_IMPORT_INVALID", Message: "Personal theme storage is unavailable"} + } + normalized, err := normalizeBundle(bundle) + if err != nil { + return Ref{}, err + } + target, err := repository.personalPath(normalized.Ref) + if err != nil { + return Ref{}, err + } + if _, err := os.Lstat(target); err == nil { + existing, readErr := readBundleDirectory(target, normalized.Ref) + if readErr != nil { + return Ref{}, readErr + } + if bundlesEqual(existing, normalized) { + return normalized.Ref, nil + } + return Ref{}, Error{Code: "THEME_VERSION_CONFLICT", Message: "Personal theme version already exists with different content"} + } else if !errors.Is(err, os.ErrNotExist) { + return Ref{}, err + } + parent := filepath.Dir(target) + if err := os.MkdirAll(parent, 0o700); err != nil { + return Ref{}, err + } + staging, err := os.MkdirTemp(parent, ".theme-staging-") + if err != nil { + return Ref{}, err + } + committed := false + defer func() { + if !committed { + _ = os.RemoveAll(staging) + } + }() + if err := writeBundleDirectory(staging, normalized); err != nil { + return Ref{}, err + } + if err := os.Rename(staging, target); err != nil { + if errors.Is(err, os.ErrExist) { + return Ref{}, Error{Code: "THEME_VERSION_CONFLICT", Message: "Personal theme version already exists"} + } + return Ref{}, err + } + committed = true + return normalized.Ref, nil +} + +func (repository *Repository) DeletePersonal(id string, version *string) error { + if repository.personalRoot == "" { + return Error{Code: "STUDIO_DELETE_FAILED", Message: "Personal theme storage is unavailable"} + } + if len(id) < 3 || len(id) > 80 || !themeIDPattern.MatchString(id) { + return Error{Code: "STUDIO_DELETE_FAILED", Message: "Personal theme ID is invalid"} + } + if version != nil { + if !themeVersionPattern.MatchString(*version) { + return Error{Code: "STUDIO_DELETE_FAILED", Message: "Personal theme version is invalid"} + } + path, err := repository.personalPath(Ref{ID: id, Version: *version}) + if err != nil { + return err + } + if err := removeOwnedDirectory(path); err != nil { + return err + } + return removeEmptyDirectory(filepath.Dir(path)) + } + path, err := repository.personalIDPath(id) + if err != nil { + return err + } + return removeOwnedDirectory(path) +} + +func (repository *Repository) listPersonal() ([]ListItem, error) { + if repository.personalRoot == "" { + return nil, nil + } + root := filepath.Join(repository.personalRoot, "themes") + ids, err := os.ReadDir(root) + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } + if err != nil { + return nil, err + } + items := make([]ListItem, 0) + for _, idEntry := range ids { + if !idEntry.IsDir() || !themeIDPattern.MatchString(idEntry.Name()) { + continue + } + versions, err := os.ReadDir(filepath.Join(root, idEntry.Name())) + if err != nil { + return nil, err + } + for _, versionEntry := range versions { + if !versionEntry.IsDir() || !themeVersionPattern.MatchString(versionEntry.Name()) { + continue + } + ref := Ref{ID: idEntry.Name(), Version: versionEntry.Name()} + bundle, err := repository.readPersonal(ref) + if err != nil { + return nil, err + } + header, err := headerFromDocument(bundle.Document) + if err != nil { + return nil, err + } + var previewURL *string + if _, exists := bundle.Files["preview.webp"]; exists { + value := "/api/theme-preview?source=personal&id=" + ref.ID + "&version=" + ref.Version + previewURL = &value + } + items = append(items, ListItem{ + Ref: ref, Name: header.Name, Description: header.Description, Author: header.Author, + Homepage: header.Metadata.Homepage, Localized: header.Metadata.Localized, + Source: "personal", Ready: true, LocalOnly: header.Rights.LocalOnly, PreviewURL: previewURL, + }) + } + } + sort.Slice(items, func(left, right int) bool { + return items[left].Ref.ID < items[right].Ref.ID || items[left].Ref.ID == items[right].Ref.ID && items[left].Ref.Version < items[right].Ref.Version + }) + return items, nil +} + +func (repository *Repository) readPersonal(ref Ref) (Bundle, error) { + path, err := repository.personalPath(ref) + if err != nil { + return Bundle{}, err + } + return readBundleDirectory(path, ref) +} + +func (repository *Repository) readPersonalPreview(ref Ref) (Asset, error) { + bundle, err := repository.readPersonal(ref) + if err != nil { + return Asset{}, err + } + preview, exists := bundle.Files["preview.webp"] + if !exists { + return Asset{}, Error{Code: "THEME_NOT_FOUND", Message: "Theme preview is unavailable"} + } + return Asset{Bytes: preview, MIMEType: "image/webp"}, nil +} + +func (repository *Repository) personalPath(ref Ref) (string, error) { + if !validRef(ref) { + return "", Error{Code: "THEME_NOT_FOUND", Message: "Theme reference is invalid"} + } + return filepath.Join(repository.personalRoot, "themes", ref.ID, ref.Version), nil +} + +func (repository *Repository) personalIDPath(id string) (string, error) { + if len(id) < 3 || len(id) > 80 || !themeIDPattern.MatchString(id) { + return "", Error{Code: "THEME_NOT_FOUND", Message: "Theme ID is invalid"} + } + return filepath.Join(repository.personalRoot, "themes", id), nil +} + +func unpack(contents []byte) (Bundle, error) { + if len(contents) == 0 || len(contents) > maxArchiveBytes { + return Bundle{}, Error{Code: "PACKAGE_TOO_LARGE", Message: "Theme archive exceeds its size limit"} + } + reader, err := zip.NewReader(bytes.NewReader(contents), int64(len(contents))) + if err != nil { + return Bundle{}, Error{Code: "STUDIO_IMPORT_INVALID", Message: "Theme archive is invalid"} + } + files := make(map[string][]byte, len(reader.File)) + seen := make(map[string]struct{}, len(reader.File)) + expanded := int64(0) + for _, file := range reader.File { + if !safeArchiveName(file.Name) || file.Mode()&os.ModeSymlink != 0 { + return Bundle{}, Error{Code: "ARCHIVE_ENTRY_UNSAFE", Message: "Theme archive contains an unsafe entry"} + } + folded := strings.ToLower(file.Name) + if _, exists := seen[folded]; exists { + return Bundle{}, Error{Code: "ARCHIVE_ENTRY_DUPLICATE", Message: "Theme archive contains duplicate entries"} + } + seen[folded] = struct{}{} + if file.Name != "theme.json" && file.Name != "manifest.json" && file.Name != "preview.webp" && !safeThemePath(file.Name) { + return Bundle{}, Error{Code: "ARCHIVE_ENTRY_UNSUPPORTED", Message: "Theme archive contains an unsupported entry"} + } + if file.UncompressedSize64 == 0 || file.UncompressedSize64 > maxExpandedBytes || expanded+int64(file.UncompressedSize64) > maxExpandedBytes { + return Bundle{}, Error{Code: "PACKAGE_EXPANDED_TOO_LARGE", Message: "Theme archive expanded size exceeds its limit"} + } + expanded += int64(file.UncompressedSize64) + contents, err := readZipFile(file, int64(file.UncompressedSize64)) + if err != nil { + return Bundle{}, err + } + files[file.Name] = contents + } + themeBytes, hasTheme := files["theme.json"] + manifestBytes, hasManifest := files["manifest.json"] + if !hasTheme || !hasManifest { + return Bundle{}, Error{Code: "ARCHIVE_REQUIRED_FILE_MISSING", Message: "Theme archive requires theme.json and manifest.json"} + } + var manifest archiveManifest + if err := decodeStrict(manifestBytes, &manifest); err != nil || manifest.SchemaVersion != 1 || !validRef(Ref{ID: manifest.ThemeID, Version: manifest.ThemeVersion}) { + return Bundle{}, Error{Code: "ARCHIVE_MANIFEST_INVALID", Message: "Theme archive manifest is invalid"} + } + actual := make([]string, 0, len(files)-1) + for name := range files { + if name != "manifest.json" { + actual = append(actual, name) + } + } + sort.Strings(actual) + expected := make([]string, 0, len(manifest.Files)) + for name := range manifest.Files { + expected = append(expected, name) + } + sort.Strings(expected) + if strings.Join(actual, "\x00") != strings.Join(expected, "\x00") { + return Bundle{}, Error{Code: "ARCHIVE_MANIFEST_MISMATCH", Message: "Theme archive entries differ from manifest"} + } + for name, expectedFile := range manifest.Files { + contents := files[name] + if len(contents) != expectedFile.Bytes || expectedFile.SHA256 != hash(contents) { + return Bundle{}, Error{Code: "ARCHIVE_HASH_MISMATCH", Message: "Theme archive file hash differs from manifest"} + } + } + normalized, header, err := normalizeDocument(themeBytes) + if err != nil { + return Bundle{}, err + } + ref := Ref{ID: header.ID, Version: header.Version} + if ref.ID != manifest.ThemeID || ref.Version != manifest.ThemeVersion { + return Bundle{}, Error{Code: "ARCHIVE_IDENTITY_MISMATCH", Message: "Theme archive identity differs from manifest"} + } + bundle := Bundle{Ref: ref, Document: normalized, Files: withoutManifest(files)} + if err := validateBundleAssets(bundle); err != nil { + return Bundle{}, err + } + return bundle, nil +} + +func pack(bundle Bundle) ([]byte, error) { + normalized, err := normalizeBundle(bundle) + if err != nil { + return nil, err + } + files := make(map[string][]byte, len(normalized.Files)+1) + files["theme.json"] = normalized.Document + for name, contents := range normalized.Files { + files[name] = contents + } + manifest := archiveManifest{SchemaVersion: 1, ThemeID: normalized.Ref.ID, ThemeVersion: normalized.Ref.Version, Files: make(map[string]struct { + Bytes int `json:"bytes"` + SHA256 string `json:"sha256"` + }, len(files))} + for name, contents := range files { + manifest.Files[name] = struct { + Bytes int `json:"bytes"` + SHA256 string `json:"sha256"` + }{Bytes: len(contents), SHA256: hash(contents)} + } + manifestBytes, err := json.MarshalIndent(manifest, "", " ") + if err != nil { + return nil, err + } + files["manifest.json"] = append(manifestBytes, '\n') + var output bytes.Buffer + writer := zip.NewWriter(&output) + names := make([]string, 0, len(files)) + for name := range files { + names = append(names, name) + } + sort.Strings(names) + for _, name := range names { + header := &zip.FileHeader{Name: name, Method: zip.Deflate} + header.SetModTime(time.Unix(0, 0).UTC()) + entry, err := writer.CreateHeader(header) + if err != nil { + return nil, err + } + if _, err := entry.Write(files[name]); err != nil { + return nil, err + } + } + if err := writer.Close(); err != nil { + return nil, err + } + if output.Len() > maxArchiveBytes { + return nil, Error{Code: "PACKAGE_TOO_LARGE", Message: "Theme archive exceeds its size limit"} + } + return output.Bytes(), nil +} + +func normalizeBundle(bundle Bundle) (Bundle, error) { + document, header, err := normalizeDocument(bundle.Document) + if err != nil { + return Bundle{}, err + } + if bundle.Ref.ID != "" && bundle.Ref != (Ref{ID: header.ID, Version: header.Version}) { + return Bundle{}, Error{Code: "ARCHIVE_IDENTITY_MISMATCH", Message: "Theme bundle identity is inconsistent"} + } + normalized := Bundle{Ref: Ref{ID: header.ID, Version: header.Version}, Document: document, Files: cloneFiles(bundle.Files)} + if err := validateBundleAssets(normalized); err != nil { + return Bundle{}, err + } + return normalized, nil +} + +func readBundleDirectory(directory string, expected Ref) (Bundle, error) { + info, err := os.Stat(directory) + if errors.Is(err, os.ErrNotExist) { + return Bundle{}, Error{Code: "THEME_NOT_FOUND", Message: "Theme does not exist"} + } + if err != nil || !info.IsDir() { + return Bundle{}, Error{Code: "THEME_SCHEMA_INVALID", Message: "Theme storage is invalid"} + } + document, err := os.ReadFile(filepath.Join(directory, "theme.json")) + if err != nil { + return Bundle{}, err + } + normalized, header, err := normalizeDocument(document) + if err != nil { + return Bundle{}, err + } + ref := Ref{ID: header.ID, Version: header.Version} + if expected != (Ref{}) && ref != expected { + return Bundle{}, Error{Code: "THEME_SCHEMA_INVALID", Message: "Stored theme identity is inconsistent"} + } + paths, err := assetPaths(normalized) + if err != nil { + return Bundle{}, err + } + files := make(map[string][]byte, len(paths)+1) + for _, path := range paths { + contents, err := readAsset(filepath.Join(directory, filepath.FromSlash(path)), path) + if err != nil { + return Bundle{}, err + } + files[path] = contents + } + if contents, err := readAsset(filepath.Join(directory, "preview.webp"), "preview.webp"); err == nil { + files["preview.webp"] = contents + } else if !errors.Is(err, os.ErrNotExist) { + return Bundle{}, err + } + return Bundle{Ref: ref, Document: normalized, Files: files}, nil +} + +func writeBundleDirectory(directory string, bundle Bundle) error { + if err := writeAtomicFile(filepath.Join(directory, "theme.json"), bundle.Document); err != nil { + return err + } + for name, contents := range bundle.Files { + if !safeAssetName(name) { + return Error{Code: "THEME_SCHEMA_INVALID", Message: "Theme file path is invalid"} + } + path := filepath.Join(directory, filepath.FromSlash(name)) + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return err + } + if err := writeAtomicFile(path, contents); err != nil { + return err + } + } + return nil +} + +func writeAtomicFile(path string, contents []byte) error { + temporary, err := os.CreateTemp(filepath.Dir(path), ".openchatgptskin-*") + if err != nil { + return err + } + temporaryPath := temporary.Name() + committed := false + defer func() { + if !committed { + _ = os.Remove(temporaryPath) + } + }() + if _, err := temporary.Write(contents); err != nil { + temporary.Close() + return err + } + if err := temporary.Sync(); err != nil { + temporary.Close() + return err + } + if err := temporary.Close(); err != nil { + return err + } + if err := os.Rename(temporaryPath, path); err != nil { + return err + } + committed = true + return nil +} + +func normalizeDocument(contents []byte) ([]byte, themeHeader, error) { + decoder := json.NewDecoder(bytes.NewReader(contents)) + decoder.UseNumber() + var document map[string]any + if err := decoder.Decode(&document); err != nil { + return nil, themeHeader{}, Error{Code: "THEME_SCHEMA_INVALID", Message: "Theme document is invalid"} + } + version, err := schemaVersion(document) + if err != nil || version < 1 || version > 4 { + return nil, themeHeader{}, Error{Code: "THEME_SCHEMA_VERSION_UNSUPPORTED", Message: "Theme schema version is unsupported"} + } + if version < 4 { + migrateToV4(document, version) + } + document["schemaVersion"] = 4 + if err := validateDocumentShape(document); err != nil { + return nil, themeHeader{}, err + } + normalized, err := json.MarshalIndent(document, "", " ") + if err != nil { + return nil, themeHeader{}, err + } + normalized = append(normalized, '\n') + header, err := headerFromDocument(normalized) + if err != nil { + return nil, themeHeader{}, err + } + return normalized, header, nil +} + +func schemaVersion(document map[string]any) (int, error) { + value, ok := document["schemaVersion"].(json.Number) + if !ok { + return 0, errors.New("schema version is invalid") + } + version, err := value.Int64() + return int(version), err +} + +func validateDocumentShape(document map[string]any) error { + for name := range document { + switch name { + case "schemaVersion", "kind", "appearance", "id", "name", "description", "version", "author", "metadata", "assets", "colors", "typography", "background", "surfaces", "decorations", "layout", "rights", "interfaceImages", "home", "welcome", "composition": + default: + return Error{Code: "THEME_SCHEMA_INVALID", Message: "Theme document has an unknown field"} + } + } + for _, name := range []string{"kind", "id", "name", "version", "author", "assets", "colors", "typography", "background", "decorations", "layout", "rights"} { + if _, exists := document[name]; !exists { + return Error{Code: "THEME_SCHEMA_INVALID", Message: "Theme document has a required field missing"} + } + } + kind, _ := document["kind"].(string) + if kind != "theme" && kind != "recipe" { + return Error{Code: "THEME_SCHEMA_INVALID", Message: "Theme kind is invalid"} + } + assets, ok := document["assets"].(map[string]any) + if !ok { + return Error{Code: "THEME_SCHEMA_INVALID", Message: "Theme assets are invalid"} + } + if kind == "theme" { + if background, exists := assets["background"].(string); !exists || !safeThemePath(background) { + return Error{Code: "THEME_SCHEMA_INVALID", Message: "Theme requires a valid background asset"} + } + } + return nil +} + +func migrateToV4(document map[string]any, version int) { + if _, exists := document["appearance"]; !exists { + document["appearance"] = "auto" + } + colors, _ := document["colors"].(map[string]any) + if colors != nil { + for name, fallback := range map[string]string{"textSecondary": "text", "link": "accent", "inputText": "text", "placeholder": "muted", "codeText": "text"} { + if _, exists := colors[name]; !exists { + colors[name] = colors[fallback] + } + } + } + if _, exists := document["surfaces"]; !exists { + document["surfaces"] = map[string]any{"baseOpacity": 0.68, "elevatedOpacity": 0.92, "terminalOpacity": 0.82, "blur": 0} + } + background, _ := document["background"].(map[string]any) + if background != nil { + for name, fallback := range map[string]any{"safeArea": "auto", "taskMode": "full", "taskOpacity": 0.82} { + if _, exists := background[name]; !exists { + background[name] = fallback + } + } + } + typography, _ := document["typography"].(map[string]any) + if typography != nil { + if _, exists := typography["displayFamily"]; !exists { + typography["displayFamily"] = typography["uiFamily"] + } + if _, exists := typography["displayWeight"]; !exists { + typography["displayWeight"] = typography["uiWeight"] + } + if _, exists := typography["displayLineHeight"]; !exists { + typography["displayLineHeight"] = typography["lineHeight"] + } + if _, exists := typography["displayLetterSpacing"]; !exists { + typography["displayLetterSpacing"] = 0 + } + if _, exists := typography["displaySize"]; !exists { + typography["displaySize"] = 28 + } + } + if _, exists := document["interfaceImages"]; !exists { + document["interfaceImages"] = map[string]any{"profileAvatarSize": 24, "suggestionIconSize": 20, "projectIconSize": 16} + } + if _, exists := document["composition"]; !exists { + document["composition"] = map[string]any{"layers": []any{}} + } + _ = version +} + +func headerFromDocument(contents []byte) (themeHeader, error) { + var header themeHeader + if err := json.Unmarshal(contents, &header); err != nil { + return themeHeader{}, Error{Code: "THEME_SCHEMA_INVALID", Message: "Theme metadata is invalid"} + } + if header.SchemaVersion != 4 || !validRef(Ref{ID: header.ID, Version: header.Version}) || header.Name == "" || header.Author == "" || (header.Kind != "theme" && header.Kind != "recipe") { + return themeHeader{}, Error{Code: "THEME_SCHEMA_INVALID", Message: "Theme metadata is invalid"} + } + return header, nil +} + +func assetPaths(document []byte) ([]string, error) { + var value struct { + Assets struct { + Background string `json:"background"` + Portrait string `json:"portrait"` + ProfileAvatar string `json:"profileAvatar"` + Decorations map[string]string `json:"decorations"` + Fonts map[string]string `json:"fonts"` + SuggestionIcons map[string]string `json:"suggestionIcons"` + ProjectIcons []string `json:"projectIcons"` + } `json:"assets"` + } + if err := json.Unmarshal(document, &value); err != nil { + return nil, Error{Code: "THEME_SCHEMA_INVALID", Message: "Theme assets are invalid"} + } + paths := []string{value.Assets.Background, value.Assets.Portrait, value.Assets.ProfileAvatar} + for _, entries := range []map[string]string{value.Assets.Decorations, value.Assets.Fonts, value.Assets.SuggestionIcons} { + for _, path := range entries { + paths = append(paths, path) + } + } + paths = append(paths, value.Assets.ProjectIcons...) + seen := make(map[string]struct{}, len(paths)) + output := make([]string, 0, len(paths)) + for _, path := range paths { + if path == "" { + continue + } + if !safeThemePath(path) { + return nil, Error{Code: "THEME_SCHEMA_INVALID", Message: "Theme asset path is invalid"} + } + if _, exists := seen[path]; !exists { + seen[path] = struct{}{} + output = append(output, path) + } + } + sort.Strings(output) + return output, nil +} + +func validateBundleAssets(bundle Bundle) error { + paths, err := assetPaths(bundle.Document) + if err != nil { + return err + } + declared := make(map[string]struct{}, len(paths)) + for _, path := range paths { + declared[path] = struct{}{} + contents, exists := bundle.Files[path] + if !exists || len(contents) == 0 || len(contents) > assetLimit(path) { + return Error{Code: "THEME_SCHEMA_INVALID", Message: "Theme declared asset is invalid"} + } + } + for path, contents := range bundle.Files { + if path == "preview.webp" { + if len(contents) == 0 || len(contents) > 2*1024*1024 { + return Error{Code: "THEME_SCHEMA_INVALID", Message: "Theme preview is invalid"} + } + continue + } + if _, exists := declared[path]; !exists || len(contents) == 0 || len(contents) > assetLimit(path) { + return Error{Code: "THEME_SCHEMA_INVALID", Message: "Theme has an undeclared or oversized asset"} + } + } + return nil +} + +func assetLimit(path string) int { + if strings.HasSuffix(strings.ToLower(path), ".woff2") { + return maxFontBytes + } + return maxAssetBytes +} + +func safeArchiveName(name string) bool { + return name != "" && name == strings.ToValidUTF8(name, "") && !strings.HasPrefix(name, "/") && !strings.Contains(name, "\\") && + !strings.Contains(name, ":") && !strings.Contains(name, "\x00") && !strings.HasSuffix(name, "/") && + !strings.Contains(name, "../") && !strings.HasPrefix(name, "..") +} + +func safeThemePath(path string) bool { + if path == "" || len(path) > 240 || strings.Contains(path, "\\") || strings.Contains(path, "\x00") || strings.HasPrefix(path, "/") || strings.Contains(path, "../") || strings.HasPrefix(path, "..") { + return false + } + lower := strings.ToLower(path) + return (strings.HasPrefix(path, "assets/") && (strings.HasSuffix(lower, ".png") || strings.HasSuffix(lower, ".jpg") || strings.HasSuffix(lower, ".jpeg") || strings.HasSuffix(lower, ".webp"))) || + (strings.HasPrefix(path, "fonts/") && strings.HasSuffix(lower, ".woff2")) +} + +func safeAssetName(path string) bool { return path == "preview.webp" || safeThemePath(path) } + +func readZipFile(file *zip.File, limit int64) ([]byte, error) { + reader, err := file.Open() + if err != nil { + return nil, err + } + defer reader.Close() + contents, err := io.ReadAll(io.LimitReader(reader, limit+1)) + if err != nil || int64(len(contents)) > limit { + return nil, Error{Code: "ARCHIVE_ENTRY_TOO_LARGE", Message: "Theme archive entry exceeds its limit"} + } + return contents, nil +} + +func readAsset(path, name string) ([]byte, error) { + info, err := os.Stat(path) + if err != nil { + return nil, err + } + if !info.Mode().IsRegular() || info.Size() < 1 || info.Size() > int64(assetLimit(name)) { + return nil, Error{Code: "THEME_SCHEMA_INVALID", Message: "Theme asset is invalid"} + } + return os.ReadFile(path) +} + +func cloneFiles(files map[string][]byte) map[string][]byte { + result := make(map[string][]byte, len(files)) + for name, contents := range files { + result[name] = append([]byte(nil), contents...) + } + return result +} + +func withoutManifest(files map[string][]byte) map[string][]byte { + result := cloneFiles(files) + delete(result, "manifest.json") + delete(result, "theme.json") + return result +} + +func bundlesEqual(left, right Bundle) bool { + if left.Ref != right.Ref || !bytes.Equal(left.Document, right.Document) || len(left.Files) != len(right.Files) { + return false + } + for name, contents := range left.Files { + if !bytes.Equal(contents, right.Files[name]) { + return false + } + } + return true +} + +func hash(contents []byte) string { + digest := sha256.Sum256(contents) + return hex.EncodeToString(digest[:]) +} + +func removeOwnedDirectory(path string) error { + info, err := os.Lstat(path) + if errors.Is(err, os.ErrNotExist) { + return Error{Code: "THEME_NOT_FOUND", Message: "Personal theme does not exist"} + } + if err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { + return Error{Code: "STUDIO_DELETE_FAILED", Message: "Personal theme storage is invalid"} + } + return os.RemoveAll(path) +} + +func removeEmptyDirectory(path string) error { + err := os.Remove(path) + if errors.Is(err, os.ErrNotExist) || errors.Is(err, os.ErrExist) { + return nil + } + return err +} + +func headerFromBundle(bundle Bundle) (themeHeader, error) { return headerFromDocument(bundle.Document) } + +func formatRef(ref Ref) string { return fmt.Sprintf("%s@%s", ref.ID, ref.Version) } diff --git a/host/go/internal/themerepo/repository.go b/host/go/internal/themerepo/repository.go index c9c8934..095bd27 100644 --- a/host/go/internal/themerepo/repository.go +++ b/host/go/internal/themerepo/repository.go @@ -58,7 +58,8 @@ type Asset struct { } type Repository struct { - root string + root string + personalRoot string } type catalog struct { @@ -104,6 +105,22 @@ func Open(root string) (*Repository, error) { return &Repository{root: absolute}, nil } +func OpenWithPersonal(root, personalRoot string) (*Repository, error) { + repository, err := Open(root) + if err != nil { + return nil, err + } + if personalRoot == "" { + return repository, nil + } + absolute, err := filepath.Abs(personalRoot) + if err != nil { + return nil, err + } + repository.personalRoot = absolute + return repository, nil +} + func (repository *Repository) List() (Library, error) { catalog, err := repository.loadCatalog() if err != nil { @@ -117,10 +134,18 @@ func (repository *Repository) List() (Library, error) { } items = append(items, item) } + personal, err := repository.listPersonal() + if err != nil { + return Library{}, err + } + items = append(items, personal...) return Library{Themes: items}, nil } func (repository *Repository) Preview(source string, ref Ref) (Asset, error) { + if source == "personal" { + return repository.readPersonalPreview(ref) + } if source != "builtin" { return Asset{}, Error{Code: "THEME_NOT_FOUND", Message: "Theme preview source is unavailable"} } @@ -272,7 +297,7 @@ func (repository *Repository) readHeader(entry catalogEntry) (themeHeader, error } for name := range document { switch name { - case "schemaVersion", "kind", "appearance", "id", "name", "description", "version", "author", "metadata", "assets", "colors", "typography", "background", "surfaces", "decorations", "layout", "rights", "interfaceImages", "welcome", "composition": + case "schemaVersion", "kind", "appearance", "id", "name", "description", "version", "author", "metadata", "assets", "colors", "typography", "background", "surfaces", "decorations", "layout", "rights", "interfaceImages", "home", "welcome", "composition": default: return themeHeader{}, Error{Code: "THEME_SCHEMA_INVALID", Message: "Theme document has an unknown field"} } diff --git a/host/go/internal/themerepo/repository_test.go b/host/go/internal/themerepo/repository_test.go index 02c9598..dba2000 100644 --- a/host/go/internal/themerepo/repository_test.go +++ b/host/go/internal/themerepo/repository_test.go @@ -1,6 +1,8 @@ package themerepo import ( + "archive/zip" + "bytes" "os" "path/filepath" "testing" @@ -13,7 +15,7 @@ func writeBuiltin(t *testing.T, root string) { t.Fatal(err) } catalog := `{"schemaVersion":1,"builtins":[{"id":"mountain-mist","name":"Mountain Mist","version":"1.3.0","kind":"theme","path":"builtin/mountain-mist","ready":true,"localOnly":false,"licenseId":"LicenseRef-Test","preview":"builtin/mountain-mist/preview.webp"}],"recipes":[]}` - theme := `{"schemaVersion":4,"kind":"theme","appearance":"light","id":"mountain-mist","name":"Mountain Mist","version":"1.3.0","author":"OpenChatGPTSkin","rights":{"localOnly":false}}` + theme := `{"schemaVersion":4,"kind":"theme","appearance":"light","id":"mountain-mist","name":"Mountain Mist","version":"1.3.0","author":"OpenChatGPTSkin","assets":{"background":"assets/background.webp"},"colors":{},"typography":{},"background":{},"decorations":[],"layout":{},"rights":{"localOnly":false}}` if err := os.WriteFile(filepath.Join(root, "catalog.json"), []byte(catalog), 0o600); err != nil { t.Fatal(err) } @@ -23,6 +25,12 @@ func writeBuiltin(t *testing.T, root string) { if err := os.WriteFile(filepath.Join(directory, "preview.webp"), []byte("RIFF0000WEBP"), 0o600); err != nil { t.Fatal(err) } + if err := os.MkdirAll(filepath.Join(directory, "assets"), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(directory, "assets", "background.webp"), []byte("RIFF0000WEBP"), 0o600); err != nil { + t.Fatal(err) + } } func TestRepositoryListsBuiltinsAndReadsDeclaredPreview(t *testing.T) { @@ -59,3 +67,83 @@ func TestRepositoryRejectsCatalogAndDocumentPathEscapes(t *testing.T) { t.Fatal("path escaping catalog entry was accepted") } } + +func TestRepositoryImportsExportsAndDeletesPersonalThemeAtomically(t *testing.T) { + builtinRoot := t.TempDir() + writeBuiltin(t, builtinRoot) + personalRoot := t.TempDir() + repository, err := OpenWithPersonal(builtinRoot, personalRoot) + if err != nil { + t.Fatal(err) + } + archive, err := repository.Export("builtin", Ref{ID: "mountain-mist", Version: "1.3.0"}) + if err != nil { + t.Fatal(err) + } + ref, err := repository.ImportArchive(archive) + if err != nil { + t.Fatal(err) + } + if ref != (Ref{ID: "mountain-mist", Version: "1.3.0"}) { + t.Fatalf("imported ref = %+v", ref) + } + library, err := repository.List() + if err != nil { + t.Fatal(err) + } + if len(library.Themes) != 2 || library.Themes[1].Source != "personal" { + t.Fatalf("library = %+v", library) + } + reexported, err := repository.Export("personal", ref) + if err != nil || !bytes.Equal(archive, reexported) { + t.Fatalf("personal export error=%v same=%v", err, bytes.Equal(archive, reexported)) + } + if err := repository.DeletePersonal(ref.ID, &ref.Version); err != nil { + t.Fatal(err) + } + if _, err := repository.Read("personal", ref); err == nil { + t.Fatal("deleted personal theme was still readable") + } +} + +func TestRepositoryRejectsUnsafeArchiveEntry(t *testing.T) { + var output bytes.Buffer + writer := zip.NewWriter(&output) + entry, err := writer.Create("../secret.txt") + if err != nil { + t.Fatal(err) + } + if _, err := entry.Write([]byte("secret")); err != nil { + t.Fatal(err) + } + if err := writer.Close(); err != nil { + t.Fatal(err) + } + if _, err := unpack(output.Bytes()); err == nil { + t.Fatal("unsafe archive entry was accepted") + } +} + +func TestRepositoryMigratesLegacyThemeAndRejectsUnknownFields(t *testing.T) { + legacy := []byte(`{"schemaVersion":1,"kind":"theme","id":"mountain-mist","name":"Mountain Mist","version":"1.3.0","author":"OpenChatGPTSkin","assets":{"background":"assets/background.webp"},"colors":{"accent":"#000000","secondary":"#000000","text":"#000000","muted":"#000000","panel":"#000000","border":"#000000","success":"#000000","warning":"#000000","danger":"#000000","info":"#000000"},"typography":{"uiFamily":"Sans","codeFamily":"Mono","scale":1,"uiSize":14,"codeSize":13,"uiWeight":400,"codeWeight":400,"lineHeight":1.5},"background":{"positionX":0.5,"positionY":0.5,"scale":1,"blur":0,"brightness":1,"overlay":0},"decorations":[],"layout":{},"rights":{"licenseId":"LicenseRef-Test","localOnly":false}}`) + normalized, header, err := normalizeDocument(legacy) + if err != nil || header.SchemaVersion != 4 || !bytes.Contains(normalized, []byte(`"composition"`)) { + t.Fatalf("legacy normalization error=%v document=%s", err, normalized) + } + unknown := append([]byte(nil), normalized[:len(normalized)-2]...) + unknown = append(unknown, []byte(`,"unknown":true}`)...) + if _, _, err := normalizeDocument(unknown); err == nil { + t.Fatal("unknown theme field was accepted") + } +} + +func FuzzUnpackRejectsMalformedArchives(f *testing.F) { + f.Add([]byte("not a zip")) + f.Add([]byte("PK\x03\x04")) + f.Fuzz(func(t *testing.T, contents []byte) { + if len(contents) > maxArchiveBytes { + return + } + _, _ = unpack(contents) + }) +} From 8f464c7586fc7ee58d11fc072fe07439d30e4046 Mon Sep 17 00:00:00 2001 From: u2bo <285077078@qq.com> Date: Fri, 24 Jul 2026 22:21:38 +0800 Subject: [PATCH 08/23] feat: add go workspace draft version loop --- host/go/internal/app/app_test.go | 2 +- host/go/internal/app/contract.go | 8 +- host/go/internal/app/studio.go | 20 +- host/go/internal/studio/server.go | 172 ++++- host/go/internal/studio/server_test.go | 68 +- host/go/internal/themerepo/archive.go | 16 + host/go/internal/themerepo/repository.go | 14 + host/go/internal/workspace/workspace.go | 739 +++++++++++++++++++ host/go/internal/workspace/workspace_test.go | 153 ++++ 9 files changed, 1181 insertions(+), 11 deletions(-) create mode 100644 host/go/internal/workspace/workspace.go create mode 100644 host/go/internal/workspace/workspace_test.go diff --git a/host/go/internal/app/app_test.go b/host/go/internal/app/app_test.go index aaeaa65..3cc703d 100644 --- a/host/go/internal/app/app_test.go +++ b/host/go/internal/app/app_test.go @@ -111,7 +111,7 @@ func startTestStudio(t *testing.T) *studio.RunningServer { } server, err := studio.Start(context.Background(), studio.Config{ IndexHTML: []byte(``), - ThemeRoot: filepath.Join(root, "themes"), StudioVersion: "0.3.0-alpha.1", + ThemeRoot: filepath.Join(root, "themes"), DraftRoot: filepath.Join(root, "theme-studio-drafts"), StudioVersion: "0.3.0-alpha.1", }) if err != nil { t.Fatal(err) diff --git a/host/go/internal/app/contract.go b/host/go/internal/app/contract.go index 2b7dddf..69807b8 100644 --- a/host/go/internal/app/contract.go +++ b/host/go/internal/app/contract.go @@ -9,6 +9,7 @@ import ( "io" "net/http" "net/http/cookiejar" + "os" "path/filepath" "sort" "strings" @@ -47,7 +48,12 @@ func runContractBaseline(ctx context.Context, arguments []string) (map[string]an } func runStudioBaseline(ctx context.Context) (map[string]any, error) { - server, err := StartStudio(ctx) + dataRoot, err := os.MkdirTemp("", "openchatgptskin-go-studio-baseline-") + if err != nil { + return nil, err + } + defer os.RemoveAll(dataRoot) + server, err := startStudioWithDataRoot(ctx, dataRoot) if err != nil { return nil, err } diff --git a/host/go/internal/app/studio.go b/host/go/internal/app/studio.go index fd8271e..160b6ba 100644 --- a/host/go/internal/app/studio.go +++ b/host/go/internal/app/studio.go @@ -14,14 +14,18 @@ const goHostVersion = "0.3.0-alpha.1" type RunningStudio = studio.RunningServer func StartStudio(ctx context.Context) (*RunningStudio, error) { - return startStudio(ctx, "") + return startStudio(ctx, "", "") } func StartStudioDev(ctx context.Context, viteOrigin string) (*RunningStudio, error) { - return startStudio(ctx, viteOrigin) + return startStudio(ctx, viteOrigin, "") } -func startStudio(ctx context.Context, viteOrigin string) (*RunningStudio, error) { +func startStudioWithDataRoot(ctx context.Context, dataRoot string) (*RunningStudio, error) { + return startStudio(ctx, "", dataRoot) +} + +func startStudio(ctx context.Context, viteOrigin, configuredDataRoot string) (*RunningStudio, error) { installRoot, err := findInstallRoot(viteOrigin == "") if err != nil { return nil, commandError{code: "STUDIO_START_FAILED", message: "Studio production assets could not be located"} @@ -34,14 +38,18 @@ func startStudio(ctx context.Context, viteOrigin string) (*RunningStudio, error) } } repositoryURL := "https://github.com/u2bo/OpenChatGPTSkin" - dataRoot, err := defaultDataRoot() - if err != nil { - return nil, commandError{code: "STUDIO_START_FAILED", message: "Studio data root could not be located"} + dataRoot := configuredDataRoot + if dataRoot == "" { + dataRoot, err = defaultDataRoot() + if err != nil { + return nil, commandError{code: "STUDIO_START_FAILED", message: "Studio data root could not be located"} + } } running, err := studio.Start(ctx, studio.Config{ IndexHTML: indexHTML, ThemeRoot: filepath.Join(installRoot, "themes"), PersonalRoot: filepath.Join(dataRoot, "theme-store"), + DraftRoot: filepath.Join(dataRoot, "theme-studio-drafts"), StudioVersion: goHostVersion, RepositoryURL: &repositoryURL, ViteOrigin: viteOrigin, diff --git a/host/go/internal/studio/server.go b/host/go/internal/studio/server.go index d541ca1..a166bc0 100644 --- a/host/go/internal/studio/server.go +++ b/host/go/internal/studio/server.go @@ -14,12 +14,14 @@ import ( "net/http" "net/http/httputil" "net/url" + "path" "strconv" "strings" "sync" "time" "github.com/u2bo/OpenChatGPTSkin/host/go/internal/themerepo" + "github.com/u2bo/OpenChatGPTSkin/host/go/internal/workspace" ) const ( @@ -51,6 +53,7 @@ type Config struct { IndexHTML []byte ThemeRoot string PersonalRoot string + DraftRoot string StudioVersion string RepositoryURL *string RuntimeStatus func() RuntimeStatus @@ -158,6 +161,13 @@ func Start(ctx context.Context, config Config) (*RunningServer, error) { if err != nil { return nil, err } + if config.DraftRoot == "" { + return nil, studioError{code: "INTERNAL", message: "Theme Studio draft storage is required", statusCode: http.StatusInternalServerError} + } + workspace, err := workspace.New(repository, config.DraftRoot) + if err != nil { + return nil, err + } session, err := newSession() if err != nil { return nil, err @@ -188,7 +198,7 @@ func Start(ctx context.Context, config Config) (*RunningServer, error) { runtimeStatus = StoppedRuntimeStatus } state := &handlerState{ - origin: origin, session: session, repository: repository, indexHTML: indexHTML, + origin: origin, session: session, repository: repository, workspace: workspace, indexHTML: indexHTML, studioVersion: config.StudioVersion, repositoryURL: config.RepositoryURL, runtimeStatus: runtimeStatus, maxSSEClients: maxSSEClients, nonce: nonce, viteProxy: viteProxy, } @@ -232,6 +242,7 @@ type handlerState struct { origin string session *session repository *themerepo.Repository + workspace *workspace.Workspace indexHTML []byte studioVersion string repositoryURL *string @@ -307,7 +318,7 @@ func (state *handlerState) dispatch(response http.ResponseWriter, request *http. "protocolVersion": 2, "studioVersion": state.studioVersion, "repositoryUrl": state.repositoryURL, - "capabilities": []string{"studio-shell", "theme-library"}, + "capabilities": []string{"studio-shell", "theme-library", "draft-editing", "version-save", "theme-import-export", "theme-delete"}, "runtime": state.runtimeStatus(), }) return nil @@ -327,6 +338,74 @@ func (state *handlerState) dispatch(response http.ResponseWriter, request *http. } state.writeBytes(response, http.StatusOK, asset) return nil + case request.URL.Path == "/api/drafts" && request.Method == http.MethodPost: + if err := state.requireOrigin(request); err != nil { + return err + } + var input struct { + Source struct { + Source string `json:"source"` + Ref themerepo.Ref `json:"ref"` + } `json:"source"` + ThemeID string `json:"themeId"` + Name string `json:"name"` + ConflictResolution string `json:"conflictResolution"` + } + if err := decodeBoundedJSON(request.Body, jsonLimitBytes, &input); err != nil { + return err + } + draft, err := state.workspace.Create(workspace.CreateInput{ + Source: input.Source.Source, Ref: input.Source.Ref, ThemeID: input.ThemeID, + Name: input.Name, ConflictResolution: input.ConflictResolution, + }) + if err != nil { + return err + } + state.writeJSON(response, http.StatusCreated, draft) + return nil + case request.URL.Path == "/api/drafts/latest" && request.Method == http.MethodGet: + draft, err := state.workspace.Latest() + if err != nil { + return err + } + state.writeJSON(response, http.StatusOK, draft) + return nil + case request.URL.Path == "/api/export" && request.Method == http.MethodGet: + ref := themerepo.Ref{ID: request.URL.Query().Get("id"), Version: request.URL.Query().Get("version")} + archive, err := state.workspace.Export(ref) + if err != nil { + return err + } + response.Header().Set("Content-Type", "application/vnd.open-chatgpt-skin+zip") + response.Header().Set("Content-Length", strconv.Itoa(len(archive))) + response.Header().Set("Content-Disposition", "attachment; filename="+ref.ID+"-"+ref.Version+".ocskin") + response.Header().Set("Cache-Control", "no-store") + response.WriteHeader(http.StatusOK) + _, _ = response.Write(archive) + return nil + case strings.HasPrefix(request.URL.Path, "/api/themes/") && request.Method == http.MethodDelete: + if err := state.requireOrigin(request); err != nil { + return err + } + id := strings.TrimPrefix(request.URL.Path, "/api/themes/") + if id == "" || strings.Contains(id, "/") { + return studioError{code: "STUDIO_REQUEST_INVALID", message: "Theme ID is invalid", statusCode: http.StatusBadRequest} + } + var version *string + if value := request.URL.Query().Get("version"); value != "" { + version = &value + } + if err := state.workspace.DeletePersonal(id, version); err != nil { + return err + } + library, err := state.repository.List() + if err != nil { + return err + } + state.writeJSON(response, http.StatusOK, library) + return nil + case strings.HasPrefix(request.URL.Path, "/api/drafts/"): + return state.dispatchDraft(response, request) case request.URL.Path == "/api/events" && request.Method == http.MethodGet: return state.serveEvents(response, request) default: @@ -334,6 +413,85 @@ func (state *handlerState) dispatch(response http.ResponseWriter, request *http. } } +func (state *handlerState) dispatchDraft(response http.ResponseWriter, request *http.Request) error { + relative := strings.TrimPrefix(request.URL.Path, "/api/drafts/") + parts := strings.Split(relative, "/") + if len(parts) == 0 || parts[0] == "" || len(parts) > 2 || path.Clean(relative) != relative { + return studioError{code: "STUDIO_REQUEST_INVALID", message: "Draft route is invalid", statusCode: http.StatusBadRequest} + } + draftID, action := parts[0], "" + if len(parts) == 2 { + action = parts[1] + } + if action == "" && request.Method == http.MethodGet { + draft, err := state.workspace.Open(draftID) + if err != nil { + return err + } + state.writeJSON(response, http.StatusOK, draft) + return nil + } + if err := state.requireOrigin(request); err != nil { + return err + } + if action == "" && request.Method == http.MethodPut { + var input struct { + ExpectedRevision int `json:"expectedRevision"` + Theme json.RawMessage `json:"theme"` + } + if err := decodeBoundedJSON(request.Body, jsonLimitBytes, &input); err != nil { + return err + } + draft, err := state.workspace.Update(draftID, input.ExpectedRevision, input.Theme) + if err != nil { + return err + } + state.writeJSON(response, http.StatusOK, draft) + return nil + } + if action == "validate" && request.Method == http.MethodPost { + draft, err := state.workspace.Validate(draftID) + if err != nil { + return err + } + state.writeJSON(response, http.StatusOK, draft) + return nil + } + if request.Method != http.MethodPost || action != "undo" && action != "redo" && action != "save" { + return studioError{code: "STUDIO_REQUEST_INVALID", message: "Draft route is unavailable", statusCode: http.StatusNotFound} + } + var input struct { + ExpectedRevision int `json:"expectedRevision"` + } + if err := decodeBoundedJSON(request.Body, jsonLimitBytes, &input); err != nil { + return err + } + switch action { + case "undo": + draft, err := state.workspace.Undo(draftID, input.ExpectedRevision) + if err != nil { + return err + } + state.writeJSON(response, http.StatusOK, draft) + return nil + case "redo": + draft, err := state.workspace.Redo(draftID, input.ExpectedRevision) + if err != nil { + return err + } + state.writeJSON(response, http.StatusOK, draft) + return nil + case "save": + draft, ref, err := state.workspace.Save(draftID, input.ExpectedRevision) + if err != nil { + return err + } + state.writeJSON(response, http.StatusOK, map[string]any{"draft": draft, "ref": ref}) + return nil + } + return studioError{code: "STUDIO_REQUEST_INVALID", message: "Draft route is unavailable", statusCode: http.StatusNotFound} +} + func (state *handlerState) securityHeaders(response http.ResponseWriter) { for name, value := range state.securityHeaderValues() { response.Header().Set(name, value) @@ -486,6 +644,16 @@ func (state *handlerState) writeError(response http.ResponseWriter, err error) { status = http.StatusNotFound } } + var workspaceErr workspace.Error + if errors.As(err, &workspaceErr) { + status, code, message = http.StatusUnprocessableEntity, workspaceErr.Code, workspaceErr.Message + if workspaceErr.Code == "STUDIO_DRAFT_NOT_FOUND" { + status = http.StatusNotFound + } + if workspaceErr.Code == "STUDIO_DRAFT_CONFLICT" { + status = http.StatusConflict + } + } state.writeJSON(response, status, map[string]any{"error": map[string]string{"code": code, "message": message}}) } diff --git a/host/go/internal/studio/server_test.go b/host/go/internal/studio/server_test.go index b904bc8..3e829f7 100644 --- a/host/go/internal/studio/server_test.go +++ b/host/go/internal/studio/server_test.go @@ -41,7 +41,7 @@ func studioFixture(t *testing.T) Config { } return Config{ IndexHTML: []byte(``), - ThemeRoot: filepath.Join(root, "themes"), StudioVersion: "0.2.0", + ThemeRoot: filepath.Join(root, "themes"), PersonalRoot: filepath.Join(root, "theme-store"), DraftRoot: filepath.Join(root, "theme-studio-drafts"), StudioVersion: "0.2.0", } } @@ -188,3 +188,69 @@ func TestStudioDevProxyAcceptsOnlyLoopbackAndDoesNotForwardSessionState(t *testi t.Fatalf("API was forwarded to Vite: %d", api.StatusCode) } } + +func TestStudioDraftRoutesPersistAndExportPersonalVersion(t *testing.T) { + server, err := Start(context.Background(), studioFixture(t)) + if err != nil { + t.Fatal(err) + } + defer server.Close() + client := sessionClient(t, server) + create, err := http.NewRequest(http.MethodPost, server.Origin+"/api/drafts", strings.NewReader(`{"source":{"source":"builtin","ref":{"id":"mountain-mist","version":"1.3.0"}}}`)) + if err != nil { + t.Fatal(err) + } + create.Header.Set("Origin", server.Origin) + create.Header.Set("Content-Type", "application/json") + response, err := client.Do(create) + if err != nil { + t.Fatal(err) + } + if response.StatusCode != http.StatusCreated { + response.Body.Close() + t.Fatalf("create status = %d", response.StatusCode) + } + var created struct { + DraftID string `json:"draftId"` + Revision int `json:"revision"` + } + if err := json.NewDecoder(response.Body).Decode(&created); err != nil { + response.Body.Close() + t.Fatal(err) + } + response.Body.Close() + save, err := http.NewRequest(http.MethodPost, server.Origin+"/api/drafts/"+created.DraftID+"/save", strings.NewReader(`{"expectedRevision":0}`)) + if err != nil { + t.Fatal(err) + } + save.Header.Set("Origin", server.Origin) + save.Header.Set("Content-Type", "application/json") + response, err = client.Do(save) + if err != nil { + t.Fatal(err) + } + if response.StatusCode != http.StatusOK { + response.Body.Close() + t.Fatalf("save status = %d", response.StatusCode) + } + var saved struct { + Ref themerepo.Ref `json:"ref"` + } + if err := json.NewDecoder(response.Body).Decode(&saved); err != nil { + response.Body.Close() + t.Fatal(err) + } + response.Body.Close() + if saved.Ref != (themerepo.Ref{ID: "mountain-mist-custom", Version: "1.0.0"}) { + t.Fatalf("saved ref = %+v", saved.Ref) + } + response, err = client.Get(server.Origin + "/api/export?id=" + saved.Ref.ID + "&version=" + saved.Ref.Version) + if err != nil { + t.Fatal(err) + } + archive, _ := io.ReadAll(response.Body) + response.Body.Close() + if response.StatusCode != http.StatusOK || len(archive) == 0 || response.Header.Get("Content-Type") != "application/vnd.open-chatgpt-skin+zip" { + t.Fatalf("export status=%d bytes=%d type=%q", response.StatusCode, len(archive), response.Header.Get("Content-Type")) + } +} diff --git a/host/go/internal/themerepo/archive.go b/host/go/internal/themerepo/archive.go index 6a997e5..de4600a 100644 --- a/host/go/internal/themerepo/archive.go +++ b/host/go/internal/themerepo/archive.go @@ -54,6 +54,22 @@ func ValidateDocument(contents []byte) error { return err } +// NormalizeDocument returns the canonical Schema v4 encoding together with +// its identity. Workspace persistence uses this exact normalisation boundary +// so Go-written draft records remain consumable by the v0.2 Node rollback host. +func NormalizeDocument(contents []byte) ([]byte, Ref, error) { + normalized, header, err := normalizeDocument(contents) + if err != nil { + return nil, Ref{}, err + } + return normalized, Ref{ID: header.ID, Version: header.Version}, nil +} + +// AssetPaths returns the complete declared asset set in stable order. +func AssetPaths(document []byte) ([]string, error) { + return assetPaths(document) +} + // ErrorCode exposes stable, user-safe theme repository error codes to the // host boundary without leaking filesystem or archive implementation details. func ErrorCodeFrom(err error) string { diff --git a/host/go/internal/themerepo/repository.go b/host/go/internal/themerepo/repository.go index 095bd27..90419cd 100644 --- a/host/go/internal/themerepo/repository.go +++ b/host/go/internal/themerepo/repository.go @@ -142,6 +142,20 @@ func (repository *Repository) List() (Library, error) { return Library{Themes: items}, nil } +// BuiltinRefs reads only the signed-in-package catalog. Studio startup uses it +// to establish reserved IDs without touching a possibly stale user store. +func (repository *Repository) BuiltinRefs() ([]Ref, error) { + catalog, err := repository.loadCatalog() + if err != nil { + return nil, err + } + refs := make([]Ref, 0, len(catalog.Builtins)) + for _, entry := range catalog.Builtins { + refs = append(refs, Ref{ID: entry.ID, Version: entry.Version}) + } + return refs, nil +} + func (repository *Repository) Preview(source string, ref Ref) (Asset, error) { if source == "personal" { return repository.readPersonalPreview(ref) diff --git a/host/go/internal/workspace/workspace.go b/host/go/internal/workspace/workspace.go new file mode 100644 index 0000000..1e93fa9 --- /dev/null +++ b/host/go/internal/workspace/workspace.go @@ -0,0 +1,739 @@ +// Package workspace owns the mutable Theme Studio draft state. It deliberately +// stores the same draft.json shape and asset layout as the v0.2 Node host so a +// user can safely roll a dataRoot back while the Go cutover remains provisional. +package workspace + +import ( + "bytes" + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + "sync" + "time" + + "github.com/u2bo/OpenChatGPTSkin/host/go/internal/themerepo" +) + +const historyLimit = 50 + +var uuidPattern = regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$`) + +type Error struct { + Code string + Message string + NextAction string +} + +func (err Error) Error() string { return err.Message } + +func ErrorCode(err error) string { + var value Error + if errors.As(err, &value) { + return value.Code + } + return "INTERNAL" +} + +type Issue struct { + Code string `json:"code"` + Path string `json:"path"` + Message string `json:"message"` + Severity string `json:"severity"` +} + +// Draft is the public Studio contract. Theme deliberately remains raw JSON: +// the schema author source is TypeScript, while Go verifies and normalises it +// without inventing a second, lossy object model. +type Draft struct { + DraftID string `json:"draftId"` + Theme json.RawMessage `json:"theme"` + Revision int `json:"revision"` + UpdatedAt string `json:"updatedAt"` + SavedRef *themerepo.Ref `json:"savedRef"` + Dirty bool `json:"dirty"` + UndoAvailable bool `json:"undoAvailable"` + RedoAvailable bool `json:"redoAvailable"` + Issues []Issue `json:"issues"` + AssetURLs map[string]string `json:"assetUrls"` +} + +type CreateInput struct { + Source string + Ref themerepo.Ref + ThemeID string + Name string + ConflictResolution string +} + +type record struct { + SchemaVersion int `json:"schemaVersion"` + DraftID string `json:"draftId"` + Theme json.RawMessage `json:"theme"` + Revision int `json:"revision"` + UpdatedAt string `json:"updatedAt"` + SavedRef *themerepo.Ref `json:"savedRef"` + Dirty bool `json:"dirty"` + Past []json.RawMessage `json:"past"` + Future []json.RawMessage `json:"future"` +} + +type Workspace struct { + repository *themerepo.Repository + root string + builtinIDs []string + mu sync.Mutex +} + +func New(repository *themerepo.Repository, root string) (*Workspace, error) { + if repository == nil || root == "" { + return nil, Error{Code: "STUDIO_DRAFT_INVALID", Message: "Draft storage is unavailable"} + } + absolute, err := filepath.Abs(root) + if err != nil { + return nil, err + } + builtins, err := repository.BuiltinRefs() + if err != nil { + return nil, err + } + builtinIDs := make([]string, 0, len(builtins)) + for _, builtin := range builtins { + builtinIDs = append(builtinIDs, builtin.ID) + } + sort.Strings(builtinIDs) + workspace := &Workspace{repository: repository, root: absolute, builtinIDs: builtinIDs} + if err := os.MkdirAll(absolute, 0o700); err != nil { + return nil, err + } + if err := workspace.removeDuplicateDrafts(); err != nil { + return nil, err + } + return workspace, nil +} + +func (workspace *Workspace) Create(input CreateInput) (Draft, error) { + workspace.mu.Lock() + defer workspace.mu.Unlock() + if input.Source != "builtin" && input.Source != "personal" { + return Draft{}, Error{Code: "STUDIO_REQUEST_INVALID", Message: "Theme source is unavailable"} + } + bundle, err := workspace.repository.Read(input.Source, input.Ref) + if err != nil { + return Draft{}, err + } + theme, ref, err := workspace.prepareSourceTheme(bundle.Document, input) + if err != nil { + return Draft{}, err + } + group := workspace.groupKey(ref.ID) + existing, err := workspace.findByGroup(group) + if err != nil { + return Draft{}, err + } + if existing != nil && input.ConflictResolution == "" { + return Draft{}, Error{Code: "STUDIO_DRAFT_CONFLICT", Message: "该主题已有草稿", NextAction: "请选择加载已有草稿或覆盖现有草稿。"} + } + if existing != nil && input.ConflictResolution == "load-existing" { + return workspace.view(*existing) + } + if input.ConflictResolution != "" && input.ConflictResolution != "overwrite-existing" && input.ConflictResolution != "load-existing" { + return Draft{}, Error{Code: "STUDIO_REQUEST_INVALID", Message: "Draft conflict resolution is invalid"} + } + draftID := newUUID() + revision := 0 + if existing != nil { + draftID, revision = existing.DraftID, existing.Revision+1 + } + savedRef := (*themerepo.Ref)(nil) + if input.Source == "personal" { + value := input.Ref + savedRef = &value + } + now := time.Now().UTC().Format(time.RFC3339Nano) + next := record{SchemaVersion: 1, DraftID: draftID, Theme: theme, Revision: revision, UpdatedAt: now, SavedRef: savedRef, Dirty: savedRef == nil, Past: []json.RawMessage{}, Future: []json.RawMessage{}} + if err := workspace.writeBundleAssets(draftID, bundle, theme); err != nil { + return Draft{}, err + } + if err := workspace.writeRecord(next); err != nil { + return Draft{}, err + } + return workspace.view(next) +} + +func (workspace *Workspace) Open(draftID string) (Draft, error) { + workspace.mu.Lock() + defer workspace.mu.Unlock() + record, err := workspace.readRecord(draftID) + if err != nil { + return Draft{}, err + } + return workspace.view(record) +} + +func (workspace *Workspace) Latest() (*Draft, error) { + workspace.mu.Lock() + defer workspace.mu.Unlock() + records, err := workspace.listRecords() + if err != nil { + return nil, err + } + if len(records) == 0 { + return nil, nil + } + sort.Slice(records, func(left, right int) bool { + return records[left].UpdatedAt > records[right].UpdatedAt || records[left].UpdatedAt == records[right].UpdatedAt && records[left].DraftID > records[right].DraftID + }) + view, err := workspace.view(records[0]) + return &view, err +} + +func (workspace *Workspace) Update(draftID string, expectedRevision int, theme json.RawMessage) (Draft, error) { + workspace.mu.Lock() + defer workspace.mu.Unlock() + record, err := workspace.readRecord(draftID) + if err != nil { + return Draft{}, err + } + if err := assertRevision(record, expectedRevision); err != nil { + return Draft{}, err + } + normalized, ref, err := themerepo.NormalizeDocument(theme) + if err != nil { + return Draft{}, Error{Code: "STUDIO_DRAFT_INVALID", Message: err.Error()} + } + if workspace.isReservedID(ref.ID) { + return Draft{}, Error{Code: "STUDIO_DRAFT_INVALID", Message: "Theme ID is reserved by the built-in catalog"} + } + if record.Theme != nil && bytes.Equal(normalized, record.Theme) { + return workspace.view(record) + } + next := mutateHistory(record, normalized) + if err := workspace.writeRecord(next); err != nil { + return Draft{}, err + } + return workspace.view(next) +} + +func (workspace *Workspace) Undo(draftID string, expectedRevision int) (Draft, error) { + workspace.mu.Lock() + defer workspace.mu.Unlock() + record, err := workspace.readRecord(draftID) + if err != nil { + return Draft{}, err + } + if err := assertRevision(record, expectedRevision); err != nil { + return Draft{}, err + } + if len(record.Past) == 0 { + return workspace.view(record) + } + previous := record.Past[len(record.Past)-1] + next := record + next.Theme = cloneJSON(previous) + next.Past = append([]json.RawMessage(nil), record.Past[:len(record.Past)-1]...) + next.Future = append([]json.RawMessage{cloneJSON(record.Theme)}, record.Future...) + if len(next.Future) > historyLimit { + next.Future = next.Future[:historyLimit] + } + next.Revision++ + next.UpdatedAt = now() + next.Dirty = true + if err := workspace.writeRecord(next); err != nil { + return Draft{}, err + } + return workspace.view(next) +} + +func (workspace *Workspace) Redo(draftID string, expectedRevision int) (Draft, error) { + workspace.mu.Lock() + defer workspace.mu.Unlock() + record, err := workspace.readRecord(draftID) + if err != nil { + return Draft{}, err + } + if err := assertRevision(record, expectedRevision); err != nil { + return Draft{}, err + } + if len(record.Future) == 0 { + return workspace.view(record) + } + next := record + next.Theme = cloneJSON(record.Future[0]) + next.Past = append(next.Past, cloneJSON(record.Theme)) + if len(next.Past) > historyLimit { + next.Past = next.Past[len(next.Past)-historyLimit:] + } + next.Future = append([]json.RawMessage(nil), record.Future[1:]...) + next.Revision++ + next.UpdatedAt = now() + next.Dirty = true + if err := workspace.writeRecord(next); err != nil { + return Draft{}, err + } + return workspace.view(next) +} + +// Validate intentionally does not write. The UI calls it before an explicit +// save, and a failed validation must never create another draft revision. +func (workspace *Workspace) Validate(draftID string) (Draft, error) { + return workspace.Open(draftID) +} + +func (workspace *Workspace) Save(draftID string, expectedRevision int) (Draft, themerepo.Ref, error) { + workspace.mu.Lock() + defer workspace.mu.Unlock() + record, err := workspace.readRecord(draftID) + if err != nil { + return Draft{}, themerepo.Ref{}, err + } + if err := assertRevision(record, expectedRevision); err != nil { + return Draft{}, themerepo.Ref{}, err + } + if !record.Dirty && record.SavedRef != nil { + view, viewErr := workspace.view(record) + return view, *record.SavedRef, viewErr + } + _, ref, err := themerepo.NormalizeDocument(record.Theme) + if err != nil { + return Draft{}, themerepo.Ref{}, Error{Code: "STUDIO_DRAFT_INVALID", Message: err.Error()} + } + if workspace.isReservedID(ref.ID) { + return Draft{}, themerepo.Ref{}, Error{Code: "STUDIO_DRAFT_INVALID", Message: "Theme ID is reserved by the built-in catalog"} + } + version, err := workspace.nextVersion(ref.ID) + if err != nil { + return Draft{}, themerepo.Ref{}, err + } + versioned, _, err := setThemeFields(record.Theme, ref.ID, "", version) + if err != nil { + return Draft{}, themerepo.Ref{}, err + } + paths, err := themerepo.AssetPaths(versioned) + if err != nil { + return Draft{}, themerepo.Ref{}, err + } + files, err := workspace.readAssets(draftID, paths) + if err != nil { + return Draft{}, themerepo.Ref{}, Error{Code: "STUDIO_SAVE_FAILED", Message: err.Error()} + } + saved, err := workspace.repository.InstallPersonal(themerepo.Bundle{Ref: themerepo.Ref{ID: ref.ID, Version: version}, Document: versioned, Files: files}) + if err != nil { + return Draft{}, themerepo.Ref{}, err + } + next := record + next.Theme = versioned + next.Revision++ + next.UpdatedAt = now() + next.SavedRef = &saved + next.Dirty = false + if err := workspace.writeRecord(next); err != nil { + return Draft{}, themerepo.Ref{}, err + } + view, err := workspace.view(next) + return view, saved, err +} + +func (workspace *Workspace) DeletePersonal(id string, version *string) error { + workspace.mu.Lock() + defer workspace.mu.Unlock() + return workspace.repository.DeletePersonal(id, version) +} + +func (workspace *Workspace) Export(ref themerepo.Ref) ([]byte, error) { + return workspace.repository.Export("personal", ref) +} + +func (workspace *Workspace) prepareSourceTheme(contents []byte, input CreateInput) (json.RawMessage, themerepo.Ref, error) { + normalized, ref, err := themerepo.NormalizeDocument(contents) + if err != nil { + return nil, themerepo.Ref{}, err + } + id := input.ThemeID + if id == "" { + if input.Source == "personal" { + id = ref.ID + } else { + id = strings.TrimSuffix(ref.ID+"-custom", "-") + } + } + if workspace.isReservedID(id) { + return nil, themerepo.Ref{}, Error{Code: "STUDIO_DRAFT_INVALID", Message: "Theme ID is reserved by the built-in catalog"} + } + version := "0.0.0" + if input.Source == "personal" { + version = ref.Version + } + updated, updatedRef, err := setThemeFields(normalized, id, input.Name, version) + if err != nil { + return nil, themerepo.Ref{}, err + } + return updated, updatedRef, nil +} + +func setThemeFields(document []byte, id, name, version string) (json.RawMessage, themerepo.Ref, error) { + var object map[string]any + decoder := json.NewDecoder(bytes.NewReader(document)) + decoder.UseNumber() + if err := decoder.Decode(&object); err != nil { + return nil, themerepo.Ref{}, err + } + if id != "" { + object["id"] = id + } + if name != "" { + object["name"] = name + } + if version != "" { + object["version"] = version + } + encoded, err := json.Marshal(object) + if err != nil { + return nil, themerepo.Ref{}, err + } + normalized, ref, err := themerepo.NormalizeDocument(encoded) + return json.RawMessage(normalized), ref, err +} + +func (workspace *Workspace) view(record record) (Draft, error) { + paths, err := themerepo.AssetPaths(record.Theme) + if err != nil { + return Draft{}, Error{Code: "STUDIO_DRAFT_INVALID", Message: err.Error()} + } + urls := make(map[string]string, len(paths)) + for _, path := range paths { + urls[path] = "/api/draft-asset?draftId=" + record.DraftID + "&path=" + path + } + return Draft{ + DraftID: record.DraftID, Theme: cloneJSON(record.Theme), Revision: record.Revision, + UpdatedAt: record.UpdatedAt, SavedRef: cloneRef(record.SavedRef), Dirty: record.Dirty, + UndoAvailable: len(record.Past) > 0, RedoAvailable: len(record.Future) > 0, + Issues: validationIssues(record.Theme), AssetURLs: urls, + }, nil +} + +func validationIssues(theme json.RawMessage) []Issue { + if err := themerepo.ValidateDocument(theme); err != nil { + return []Issue{{Code: themerepo.ErrorCodeFrom(err), Path: "theme", Message: err.Error(), Severity: "error"}} + } + return []Issue{} +} + +func (workspace *Workspace) writeBundleAssets(draftID string, bundle themerepo.Bundle, document []byte) error { + paths, err := themerepo.AssetPaths(document) + if err != nil { + return err + } + for _, path := range paths { + contents, exists := bundle.Files[path] + if !exists { + return Error{Code: "STUDIO_DRAFT_INVALID", Message: "Theme source asset is missing"} + } + if err := workspace.writeAsset(draftID, path, contents); err != nil { + return err + } + } + return nil +} + +func (workspace *Workspace) readAssets(draftID string, paths []string) (map[string][]byte, error) { + files := make(map[string][]byte, len(paths)) + for _, path := range paths { + contents, err := os.ReadFile(workspace.assetPath(draftID, path)) + if err != nil { + return nil, err + } + files[path] = contents + } + return files, nil +} + +func (workspace *Workspace) writeAsset(draftID, path string, contents []byte) error { + if !safeDraftID(draftID) { + return Error{Code: "STUDIO_DRAFT_INVALID", Message: "Draft ID is invalid"} + } + target := workspace.assetPath(draftID, path) + if err := os.MkdirAll(filepath.Dir(target), 0o700); err != nil { + return err + } + return atomicWrite(target, contents) +} + +func (workspace *Workspace) assetPath(draftID, path string) string { + return filepath.Join(workspace.draftDirectory(draftID), filepath.FromSlash(path)) +} + +func (workspace *Workspace) draftDirectory(draftID string) string { + return filepath.Join(workspace.root, draftID) +} + +func (workspace *Workspace) recordPath(draftID string) string { + return filepath.Join(workspace.draftDirectory(draftID), "draft.json") +} + +func (workspace *Workspace) writeRecord(record record) error { + if err := validateRecord(record); err != nil { + return err + } + contents, err := json.MarshalIndent(record, "", " ") + if err != nil { + return err + } + contents = append(contents, '\n') + if err := os.MkdirAll(workspace.draftDirectory(record.DraftID), 0o700); err != nil { + return err + } + return atomicWrite(workspace.recordPath(record.DraftID), contents) +} + +func (workspace *Workspace) readRecord(draftID string) (record, error) { + if !safeDraftID(draftID) { + return record{}, Error{Code: "STUDIO_DRAFT_NOT_FOUND", Message: "Theme draft does not exist"} + } + contents, err := os.ReadFile(workspace.recordPath(draftID)) + if errors.Is(err, os.ErrNotExist) { + return record{}, Error{Code: "STUDIO_DRAFT_NOT_FOUND", Message: "Theme draft does not exist"} + } + if err != nil { + return record{}, err + } + var value record + if err := strictJSON(contents, &value); err != nil || validateRecord(value) != nil { + workspace.preserveInvalidEvidence(draftID, contents) + if err != nil { + return record{}, Error{Code: "STUDIO_DRAFT_INVALID", Message: "Theme draft is invalid"} + } + return record{}, Error{Code: "STUDIO_DRAFT_INVALID", Message: "Theme draft is invalid"} + } + return value, nil +} + +func (workspace *Workspace) preserveInvalidEvidence(draftID string, contents []byte) { + directory := filepath.Join(workspace.root, "invalid-evidence") + _ = os.MkdirAll(directory, 0o700) + _ = atomicWrite(filepath.Join(directory, draftID+"-"+fmt.Sprint(time.Now().UTC().UnixNano())+".json"), contents) +} + +func (workspace *Workspace) listRecords() ([]record, error) { + entries, err := os.ReadDir(workspace.root) + if errors.Is(err, os.ErrNotExist) { + return []record{}, nil + } + if err != nil { + return nil, err + } + records := make([]record, 0, len(entries)) + for _, entry := range entries { + if !entry.IsDir() || !safeDraftID(entry.Name()) { + continue + } + value, err := workspace.readRecord(entry.Name()) + if err != nil { + if ErrorCode(err) == "STUDIO_DRAFT_INVALID" { + // Preserve invalid records as evidence, but do not let a single + // future-format/corrupt draft prevent access to every valid draft. + continue + } + return nil, err + } + records = append(records, value) + } + return records, nil +} + +func (workspace *Workspace) findByGroup(group string) (*record, error) { + records, err := workspace.listRecords() + if err != nil { + return nil, err + } + var latest *record + for index := range records { + _, ref, err := themerepo.NormalizeDocument(records[index].Theme) + if err != nil || workspace.groupKey(ref.ID) != group { + continue + } + if latest == nil || records[index].UpdatedAt > latest.UpdatedAt || records[index].UpdatedAt == latest.UpdatedAt && records[index].DraftID > latest.DraftID { + value := records[index] + latest = &value + } + } + return latest, nil +} + +func (workspace *Workspace) removeDuplicateDrafts() error { + records, err := workspace.listRecords() + if err != nil { + return err + } + sort.Slice(records, func(left, right int) bool { + return records[left].UpdatedAt > records[right].UpdatedAt || records[left].UpdatedAt == records[right].UpdatedAt && records[left].DraftID > records[right].DraftID + }) + seen := map[string]bool{} + for _, record := range records { + _, ref, err := themerepo.NormalizeDocument(record.Theme) + if err != nil { + return err + } + group := workspace.groupKey(ref.ID) + if !seen[group] { + seen[group] = true + continue + } + // Removing a duplicate directory is safe: it was identified from a + // fully valid record and a newer record for the same identity exists. + if err := os.RemoveAll(workspace.draftDirectory(record.DraftID)); err != nil { + return err + } + } + return nil +} + +func (workspace *Workspace) nextVersion(id string) (string, error) { + library, err := workspace.repository.List() + if err != nil { + return "", err + } + major, minor, patch := 1, 0, -1 + for _, item := range library.Themes { + if item.Source != "personal" || item.Ref.ID != id { + continue + } + var candidateMajor, candidateMinor, candidatePatch int + if _, err := fmt.Sscanf(item.Ref.Version, "%d.%d.%d", &candidateMajor, &candidateMinor, &candidatePatch); err != nil { + return "", Error{Code: "STUDIO_SAVE_FAILED", Message: "Stored theme version is invalid"} + } + if candidateMajor > major || candidateMajor == major && candidateMinor > minor || candidateMajor == major && candidateMinor == minor && candidatePatch > patch { + major, minor, patch = candidateMajor, candidateMinor, candidatePatch + } + } + if patch < 0 { + return "1.0.0", nil + } + return fmt.Sprintf("%d.%d.%d", major, minor, patch+1), nil +} + +func (workspace *Workspace) groupKey(id string) string { + for _, builtin := range workspace.builtinIDs { + stable := builtin + "-custom" + if id == stable || strings.HasPrefix(id, stable+"-") { + return "builtin:" + builtin + } + } + return "personal:" + id +} + +func (workspace *Workspace) isReservedID(id string) bool { + for _, builtin := range workspace.builtinIDs { + if id == builtin { + return true + } + } + return false +} + +func mutateHistory(current record, theme json.RawMessage) record { + next := current + next.Theme = cloneJSON(theme) + next.Revision++ + next.UpdatedAt = now() + next.Dirty = true + next.Past = append(append([]json.RawMessage(nil), current.Past...), cloneJSON(current.Theme)) + if len(next.Past) > historyLimit { + next.Past = next.Past[len(next.Past)-historyLimit:] + } + next.Future = []json.RawMessage{} + return next +} + +func assertRevision(record record, expected int) error { + if record.Revision != expected { + return Error{Code: "STUDIO_DRAFT_CONFLICT", Message: fmt.Sprintf("Draft revision changed from %d to %d", expected, record.Revision)} + } + return nil +} + +func validateRecord(record record) error { + if record.SchemaVersion != 1 || !safeDraftID(record.DraftID) || record.Revision < 0 || record.UpdatedAt == "" || len(record.Past) > historyLimit || len(record.Future) > historyLimit { + return errors.New("draft record envelope is invalid") + } + if _, _, err := themerepo.NormalizeDocument(record.Theme); err != nil { + return err + } + for _, snapshot := range append(append([]json.RawMessage{}, record.Past...), record.Future...) { + if _, _, err := themerepo.NormalizeDocument(snapshot); err != nil { + return err + } + } + return nil +} + +func strictJSON(contents []byte, target any) error { + decoder := json.NewDecoder(bytes.NewReader(contents)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(target); err != nil { + return err + } + if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { + return errors.New("JSON has trailing content") + } + return nil +} + +func atomicWrite(path string, contents []byte) error { + temporary, err := os.CreateTemp(filepath.Dir(path), ".openchatgptskin-*") + if err != nil { + return err + } + temporaryPath := temporary.Name() + committed := false + defer func() { + if !committed { + _ = os.Remove(temporaryPath) + } + }() + if _, err := temporary.Write(contents); err != nil { + _ = temporary.Close() + return err + } + if err := temporary.Sync(); err != nil { + _ = temporary.Close() + return err + } + if err := temporary.Close(); err != nil { + return err + } + if err := os.Rename(temporaryPath, path); err != nil { + return err + } + committed = true + return nil +} + +func newUUID() string { + value := make([]byte, 16) + if _, err := rand.Read(value); err != nil { + panic("crypto/rand failed: " + err.Error()) + } + value[6] = value[6]&0x0f | 0x40 + value[8] = value[8]&0x3f | 0x80 + encoded := hex.EncodeToString(value) + return encoded[:8] + "-" + encoded[8:12] + "-" + encoded[12:16] + "-" + encoded[16:20] + "-" + encoded[20:] +} + +func safeDraftID(value string) bool { return uuidPattern.MatchString(value) } +func cloneJSON(value json.RawMessage) json.RawMessage { return append(json.RawMessage(nil), value...) } +func cloneRef(value *themerepo.Ref) *themerepo.Ref { + if value == nil { + return nil + } + copy := *value + return © +} +func now() string { return time.Now().UTC().Format(time.RFC3339Nano) } diff --git a/host/go/internal/workspace/workspace_test.go b/host/go/internal/workspace/workspace_test.go new file mode 100644 index 0000000..e9ccd8b --- /dev/null +++ b/host/go/internal/workspace/workspace_test.go @@ -0,0 +1,153 @@ +package workspace + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/u2bo/OpenChatGPTSkin/host/go/internal/themerepo" +) + +func writeBuiltin(t *testing.T, root string) { + t.Helper() + directory := filepath.Join(root, "builtin", "mountain-mist") + if err := os.MkdirAll(filepath.Join(directory, "assets"), 0o700); err != nil { + t.Fatal(err) + } + catalog := `{"schemaVersion":1,"builtins":[{"id":"mountain-mist","name":"Mountain Mist","version":"1.3.0","kind":"theme","path":"builtin/mountain-mist","ready":true,"localOnly":false,"licenseId":"LicenseRef-Test","preview":"builtin/mountain-mist/preview.webp"}],"recipes":[]}` + theme := `{"schemaVersion":4,"kind":"theme","appearance":"light","id":"mountain-mist","name":"Mountain Mist","version":"1.3.0","author":"OpenChatGPTSkin","assets":{"background":"assets/background.webp"},"colors":{"accent":"#113355","secondary":"#224466","text":"#ffffff","muted":"#cccccc","panel":"#112233","border":"#223344","success":"#228855","warning":"#cc8822","danger":"#bb3344","info":"#4477cc","textSecondary":"#eeeeee","link":"#aaccee","inputText":"#ffffff","placeholder":"#cccccc","codeText":"#ffffff"},"typography":{"uiFamily":"Sans","codeFamily":"Mono","scale":1,"uiSize":14,"codeSize":13,"uiWeight":400,"codeWeight":400,"lineHeight":1.5,"displayFamily":"Sans","displaySize":28,"displayWeight":400,"displayLineHeight":1.5,"displayLetterSpacing":0},"background":{"positionX":0.5,"positionY":0.5,"scale":1,"blur":0,"brightness":1,"overlay":0},"decorations":[],"layout":{},"rights":{"licenseId":"LicenseRef-Test","attribution":"OpenChatGPTSkin","localOnly":false}}` + if err := os.WriteFile(filepath.Join(root, "catalog.json"), []byte(catalog), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(directory, "theme.json"), []byte(theme), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(directory, "assets", "background.webp"), []byte("RIFF0000WEBP"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(directory, "preview.webp"), []byte("RIFF0000WEBP"), 0o600); err != nil { + t.Fatal(err) + } +} + +func newWorkspace(t *testing.T) (*Workspace, *themerepo.Repository, string) { + t.Helper() + builtinRoot := t.TempDir() + writeBuiltin(t, builtinRoot) + personalRoot := t.TempDir() + repository, err := themerepo.OpenWithPersonal(builtinRoot, personalRoot) + if err != nil { + t.Fatal(err) + } + draftRoot := filepath.Join(t.TempDir(), "theme-studio-drafts") + workspace, err := New(repository, draftRoot) + if err != nil { + t.Fatal(err) + } + return workspace, repository, draftRoot +} + +func renamedTheme(t *testing.T, theme json.RawMessage, name string) json.RawMessage { + t.Helper() + var value map[string]any + if err := json.Unmarshal(theme, &value); err != nil { + t.Fatal(err) + } + value["name"] = name + result, err := json.Marshal(value) + if err != nil { + t.Fatal(err) + } + return result +} + +func TestWorkspaceCreatesOneDraftTracksRevisionAndPersistsHistory(t *testing.T) { + workspace, _, draftRoot := newWorkspace(t) + created, err := workspace.Create(CreateInput{Source: "builtin", Ref: themerepo.Ref{ID: "mountain-mist", Version: "1.3.0"}}) + if err != nil { + t.Fatal(err) + } + if created.Theme == nil || created.Revision != 0 || !created.Dirty { + t.Fatalf("created draft = %+v", created) + } + if _, err := workspace.Create(CreateInput{Source: "builtin", Ref: themerepo.Ref{ID: "mountain-mist", Version: "1.3.0"}}); ErrorCode(err) != "STUDIO_DRAFT_CONFLICT" { + t.Fatalf("duplicate draft error = %v", err) + } + loaded, err := workspace.Create(CreateInput{Source: "builtin", Ref: themerepo.Ref{ID: "mountain-mist", Version: "1.3.0"}, ConflictResolution: "load-existing"}) + if err != nil || loaded.DraftID != created.DraftID { + t.Fatalf("loaded draft=%+v error=%v", loaded, err) + } + updated, err := workspace.Update(created.DraftID, created.Revision, renamedTheme(t, created.Theme, "Renamed")) + if err != nil { + t.Fatal(err) + } + if updated.Revision != 1 || !updated.UndoAvailable || updated.RedoAvailable { + t.Fatalf("updated draft = %+v", updated) + } + if _, err := workspace.Update(created.DraftID, created.Revision, created.Theme); ErrorCode(err) != "STUDIO_DRAFT_CONFLICT" { + t.Fatalf("stale update error = %v", err) + } + undone, err := workspace.Undo(created.DraftID, updated.Revision) + if err != nil || undone.Revision != 2 || undone.UndoAvailable || !undone.RedoAvailable { + t.Fatalf("undone=%+v error=%v", undone, err) + } + redone, err := workspace.Redo(created.DraftID, undone.Revision) + if err != nil || redone.Revision != 3 || !redone.UndoAvailable || redone.RedoAvailable { + t.Fatalf("redone=%+v error=%v", redone, err) + } + // A new instance reads the Node-compatible draft.json shape unchanged. + reopened, err := New(workspace.repository, draftRoot) + if err != nil { + t.Fatal(err) + } + latest, err := reopened.Latest() + if err != nil || latest == nil || latest.DraftID != created.DraftID || latest.Revision != redone.Revision { + t.Fatalf("latest=%+v error=%v", latest, err) + } +} + +func TestWorkspaceSaveCreatesImmutablePersonalVersionAndExports(t *testing.T) { + workspace, repository, _ := newWorkspace(t) + draft, err := workspace.Create(CreateInput{Source: "builtin", Ref: themerepo.Ref{ID: "mountain-mist", Version: "1.3.0"}}) + if err != nil { + t.Fatal(err) + } + saved, ref, err := workspace.Save(draft.DraftID, draft.Revision) + if err != nil { + t.Fatal(err) + } + if ref != (themerepo.Ref{ID: "mountain-mist-custom", Version: "1.0.0"}) || saved.SavedRef == nil || saved.Dirty { + t.Fatalf("saved draft=%+v ref=%+v", saved, ref) + } + archive, err := workspace.Export(ref) + if err != nil || len(archive) == 0 { + t.Fatalf("export bytes=%d error=%v", len(archive), err) + } + if _, err := repository.Read("personal", ref); err != nil { + t.Fatal(err) + } + idempotent, sameRef, err := workspace.Save(saved.DraftID, saved.Revision) + if err != nil || sameRef != ref || idempotent.Revision != saved.Revision { + t.Fatalf("idempotent save draft=%+v ref=%+v error=%v", idempotent, sameRef, err) + } +} + +func TestWorkspaceRejectsMalformedDraftAndPreservesEvidence(t *testing.T) { + workspace, _, root := newWorkspace(t) + draftID := "00000000-0000-4000-8000-000000000001" + directory := filepath.Join(root, draftID) + if err := os.MkdirAll(directory, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(directory, "draft.json"), []byte(`{"schemaVersion":99}`), 0o600); err != nil { + t.Fatal(err) + } + if _, err := workspace.Open(draftID); ErrorCode(err) != "STUDIO_DRAFT_INVALID" { + t.Fatalf("malformed draft error = %v", err) + } + evidence, err := os.ReadDir(filepath.Join(root, "invalid-evidence")) + if err != nil || len(evidence) != 1 { + t.Fatalf("invalid evidence = %v error=%v", evidence, err) + } +} From 03103eeea32d21d51b3a79f234007a8526997128 Mon Sep 17 00:00:00 2001 From: u2bo <285077078@qq.com> Date: Fri, 24 Jul 2026 22:33:42 +0800 Subject: [PATCH 09/23] feat: complete go theme asset pipeline --- host/go/internal/image/pipeline.go | 30 +- host/go/internal/image/pipeline_test.go | 14 + host/go/internal/studio/server.go | 89 ++++ host/go/internal/studio/server_test.go | 17 +- host/go/internal/themerepo/archive.go | 176 ++++++- host/go/internal/workspace/workspace.go | 518 ++++++++++++++++++- host/go/internal/workspace/workspace_test.go | 107 +++- 7 files changed, 936 insertions(+), 15 deletions(-) diff --git a/host/go/internal/image/pipeline.go b/host/go/internal/image/pipeline.go index 33bc17b..4d042e1 100644 --- a/host/go/internal/image/pipeline.go +++ b/host/go/internal/image/pipeline.go @@ -27,9 +27,18 @@ type Options struct { Height int Quality int Lossless bool + Fit Fit + NoUpscale bool MaxInputBytes int } +type Fit string + +const ( + FitCover Fit = "cover" + FitInside Fit = "inside" +) + type pipelineError struct { code string err error @@ -133,7 +142,7 @@ func orient(source stdimage.Image, orientation int) stdimage.Image { return dst } -func cropAndResize(source stdimage.Image, width, height int) (stdimage.Image, error) { +func cropAndResize(source stdimage.Image, width, height int, fit Fit, noUpscale bool) (stdimage.Image, error) { if width == 0 && height == 0 { return source, nil } @@ -142,6 +151,23 @@ func cropAndResize(source stdimage.Image, width, height int) (stdimage.Image, er } bounds := source.Bounds() sw, sh := bounds.Dx(), bounds.Dy() + if fit == "" { + fit = FitCover + } + if fit != FitCover && fit != FitInside { + return nil, fail("IMAGE_DIMENSIONS_INVALID", "image fit is invalid") + } + if fit == FitInside { + scale := min(float64(width)/float64(sw), float64(height)/float64(sh)) + if noUpscale && scale > 1 { + scale = 1 + } + outputWidth := max(1, int(float64(sw)*scale+0.5)) + outputHeight := max(1, int(float64(sh)*scale+0.5)) + destination := stdimage.NewNRGBA(stdimage.Rect(0, 0, outputWidth, outputHeight)) + xdraw.CatmullRom.Scale(destination, destination.Bounds(), source, bounds, draw.Over, nil) + return destination, nil + } targetAspect := float64(width) / float64(height) sourceAspect := float64(sw) / float64(sh) crop := bounds @@ -168,7 +194,7 @@ func Process(contents []byte, options Options) ([]byte, error) { if err != nil { return nil, err } - processed, err := cropAndResize(decoded, options.Width, options.Height) + processed, err := cropAndResize(decoded, options.Width, options.Height, options.Fit, options.NoUpscale) if err != nil { return nil, err } diff --git a/host/go/internal/image/pipeline_test.go b/host/go/internal/image/pipeline_test.go index 2f35f39..dc3c0c5 100644 --- a/host/go/internal/image/pipeline_test.go +++ b/host/go/internal/image/pipeline_test.go @@ -49,6 +49,20 @@ func TestPipelinePreservesAlphaCropsResizesAndEncodesWebP(t *testing.T) { } } +func TestPipelineSupportsInsideWithoutUpscaling(t *testing.T) { + output, err := Process(sourcePNG(t), Options{Width: 2400, Height: 1350, Fit: FitInside, NoUpscale: true, Lossless: true}) + if err != nil { + t.Fatal(err) + } + decoded, err := webp.Decode(bytes.NewReader(output)) + if err != nil { + t.Fatal(err) + } + if decoded.Bounds().Dx() != 4 || decoded.Bounds().Dy() != 2 { + t.Fatalf("inside image was enlarged: %v", decoded.Bounds()) + } +} + func TestPipelineAppliesJPEGExifOrientation(t *testing.T) { value := stdimage.NewNRGBA(stdimage.Rect(0, 0, 2, 1)) value.Set(0, 0, color.RGBA{R: 255, A: 255}) diff --git a/host/go/internal/studio/server.go b/host/go/internal/studio/server.go index a166bc0..704b534 100644 --- a/host/go/internal/studio/server.go +++ b/host/go/internal/studio/server.go @@ -383,6 +383,30 @@ func (state *handlerState) dispatch(response http.ResponseWriter, request *http. response.WriteHeader(http.StatusOK) _, _ = response.Write(archive) return nil + case request.URL.Path == "/api/draft-asset" && request.Method == http.MethodGet: + asset, err := state.workspace.ReadAsset(request.URL.Query().Get("draftId"), request.URL.Query().Get("path")) + if err != nil { + return err + } + state.writeBytes(response, http.StatusOK, asset) + return nil + case request.URL.Path == "/api/import" && request.Method == http.MethodPost: + if err := state.requireOrigin(request); err != nil { + return err + } + if request.Header.Get("Content-Type") != "application/vnd.open-chatgpt-skin+zip" { + return studioError{code: "STUDIO_IMPORT_INVALID", message: "Theme import requires an .ocskin archive", statusCode: http.StatusUnsupportedMediaType} + } + contents, err := readBoundedBytes(request.Body, 32*1024*1024) + if err != nil { + return err + } + draft, err := state.workspace.Import(contents) + if err != nil { + return err + } + state.writeJSON(response, http.StatusCreated, draft) + return nil case strings.HasPrefix(request.URL.Path, "/api/themes/") && request.Method == http.MethodDelete: if err := state.requireOrigin(request); err != nil { return err @@ -431,6 +455,50 @@ func (state *handlerState) dispatchDraft(response http.ResponseWriter, request * state.writeJSON(response, http.StatusOK, draft) return nil } + if action == "assets" && request.Method == http.MethodPost { + if err := state.requireOrigin(request); err != nil { + return err + } + expectedRevision, err := nonNegativeQueryInt(request, "revision") + if err != nil { + return err + } + fileName, err := url.QueryUnescape(request.Header.Get("X-File-Name")) + if err != nil { + return studioError{code: "STUDIO_REQUEST_INVALID", message: "Asset file name is invalid", statusCode: http.StatusBadRequest} + } + contents, err := readBoundedBytes(request.Body, 50*1024*1024) + if err != nil { + return err + } + draft, err := state.workspace.Upload(workspace.UploadInput{ + DraftID: draftID, ExpectedRevision: expectedRevision, Slot: request.URL.Query().Get("slot"), + AssetKey: request.URL.Query().Get("assetKey"), FileName: fileName, + MIMEType: request.Header.Get("Content-Type"), Bytes: contents, + }) + if err != nil { + return err + } + state.writeJSON(response, http.StatusOK, draft) + return nil + } + if action == "assets" && request.Method == http.MethodDelete { + if err := state.requireOrigin(request); err != nil { + return err + } + expectedRevision, err := nonNegativeQueryInt(request, "revision") + if err != nil { + return err + } + draft, err := state.workspace.ClearAsset(workspace.ClearAssetInput{ + DraftID: draftID, ExpectedRevision: expectedRevision, Slot: request.URL.Query().Get("slot"), AssetKey: request.URL.Query().Get("assetKey"), + }) + if err != nil { + return err + } + state.writeJSON(response, http.StatusOK, draft) + return nil + } if err := state.requireOrigin(request); err != nil { return err } @@ -492,6 +560,15 @@ func (state *handlerState) dispatchDraft(response http.ResponseWriter, request * return studioError{code: "STUDIO_REQUEST_INVALID", message: "Draft route is unavailable", statusCode: http.StatusNotFound} } +func nonNegativeQueryInt(request *http.Request, name string) (int, error) { + value := request.URL.Query().Get(name) + parsed, err := strconv.Atoi(value) + if err != nil || parsed < 0 || value == "" { + return 0, studioError{code: "STUDIO_REQUEST_INVALID", message: name + " is invalid", statusCode: http.StatusBadRequest} + } + return parsed, nil +} + func (state *handlerState) securityHeaders(response http.ResponseWriter) { for name, value := range state.securityHeaderValues() { response.Header().Set(name, value) @@ -536,6 +613,18 @@ func decodeBoundedJSON(body io.ReadCloser, limit int64, output any) error { return nil } +func readBoundedBytes(body io.ReadCloser, limit int64) ([]byte, error) { + defer body.Close() + contents, err := io.ReadAll(io.LimitReader(body, limit+1)) + if err != nil { + return nil, err + } + if int64(len(contents)) > limit { + return nil, studioError{code: "STUDIO_REQUEST_TOO_LARGE", message: "Binary request exceeds its limit", statusCode: http.StatusRequestEntityTooLarge} + } + return contents, nil +} + func newViteProxy(origin, nonce string) (*httputil.ReverseProxy, error) { target, err := validateViteOrigin(origin) if err != nil { diff --git a/host/go/internal/studio/server_test.go b/host/go/internal/studio/server_test.go index 3e829f7..a14d2dc 100644 --- a/host/go/internal/studio/server_test.go +++ b/host/go/internal/studio/server_test.go @@ -1,8 +1,12 @@ package studio import ( + "bytes" "context" "encoding/json" + stdimage "image" + "image/color" + "image/png" "io" "net/http" "net/http/cookiejar" @@ -36,7 +40,7 @@ func studioFixture(t *testing.T) Config { if err := os.MkdirAll(filepath.Join(themeDirectory, "assets"), 0o700); err != nil { t.Fatal(err) } - if err := os.WriteFile(filepath.Join(themeDirectory, "assets", "background.webp"), []byte("RIFF0000WEBP"), 0o600); err != nil { + if err := os.WriteFile(filepath.Join(themeDirectory, "assets", "background.webp"), fixturePNG(t), 0o600); err != nil { t.Fatal(err) } return Config{ @@ -45,6 +49,17 @@ func studioFixture(t *testing.T) Config { } } +func fixturePNG(t *testing.T) []byte { + t.Helper() + image := stdimage.NewNRGBA(stdimage.Rect(0, 0, 8, 4)) + image.SetNRGBA(1, 1, color.NRGBA{R: 80, G: 120, B: 255, A: 255}) + var output bytes.Buffer + if err := png.Encode(&output, image); err != nil { + t.Fatal(err) + } + return output.Bytes() +} + func sessionClient(t *testing.T, server *RunningServer) *http.Client { t.Helper() jar, err := cookiejar.New(nil) diff --git a/host/go/internal/themerepo/archive.go b/host/go/internal/themerepo/archive.go index de4600a..6256798 100644 --- a/host/go/internal/themerepo/archive.go +++ b/host/go/internal/themerepo/archive.go @@ -54,6 +54,15 @@ func ValidateDocument(contents []byte) error { return err } +// ValidateDraftDocument permits a temporarily incomplete draft (for example a +// background slot intentionally cleared in the editor) but keeps all schema, +// asset-path and composition safety checks in force. Saving an archive/theme +// still calls ValidateDocument and therefore requires a background. +func ValidateDraftDocument(contents []byte) error { + _, _, err := normalizeDraftDocument(contents) + return err +} + // NormalizeDocument returns the canonical Schema v4 encoding together with // its identity. Workspace persistence uses this exact normalisation boundary // so Go-written draft records remain consumable by the v0.2 Node rollback host. @@ -65,6 +74,16 @@ func NormalizeDocument(contents []byte) ([]byte, Ref, error) { return normalized, Ref{ID: header.ID, Version: header.Version}, nil } +// NormalizeDraftDocument mirrors the Node draft parser: it produces canonical +// Schema v4 JSON while allowing a missing background until explicit save. +func NormalizeDraftDocument(contents []byte) ([]byte, Ref, error) { + normalized, header, err := normalizeDraftDocument(contents) + if err != nil { + return nil, Ref{}, err + } + return normalized, Ref{ID: header.ID, Version: header.Version}, nil +} + // AssetPaths returns the complete declared asset set in stable order. func AssetPaths(document []byte) ([]string, error) { return assetPaths(document) @@ -520,6 +539,14 @@ func writeAtomicFile(path string, contents []byte) error { } func normalizeDocument(contents []byte) ([]byte, themeHeader, error) { + return normalizeDocumentWithOptions(contents, true) +} + +func normalizeDraftDocument(contents []byte) ([]byte, themeHeader, error) { + return normalizeDocumentWithOptions(contents, false) +} + +func normalizeDocumentWithOptions(contents []byte, requireBackground bool) ([]byte, themeHeader, error) { decoder := json.NewDecoder(bytes.NewReader(contents)) decoder.UseNumber() var document map[string]any @@ -532,9 +559,11 @@ func normalizeDocument(contents []byte) ([]byte, themeHeader, error) { } if version < 4 { migrateToV4(document, version) + } else { + ensureV4Defaults(document) } document["schemaVersion"] = 4 - if err := validateDocumentShape(document); err != nil { + if err := validateDocumentShape(document, requireBackground); err != nil { return nil, themeHeader{}, err } normalized, err := json.MarshalIndent(document, "", " ") @@ -558,7 +587,7 @@ func schemaVersion(document map[string]any) (int, error) { return int(version), err } -func validateDocumentShape(document map[string]any) error { +func validateDocumentShape(document map[string]any, requireBackground bool) error { for name := range document { switch name { case "schemaVersion", "kind", "appearance", "id", "name", "description", "version", "author", "metadata", "assets", "colors", "typography", "background", "surfaces", "decorations", "layout", "rights", "interfaceImages", "home", "welcome", "composition": @@ -579,14 +608,149 @@ func validateDocumentShape(document map[string]any) error { if !ok { return Error{Code: "THEME_SCHEMA_INVALID", Message: "Theme assets are invalid"} } - if kind == "theme" { + if kind == "theme" && requireBackground { if background, exists := assets["background"].(string); !exists || !safeThemePath(background) { return Error{Code: "THEME_SCHEMA_INVALID", Message: "Theme requires a valid background asset"} } } + return validateAssetRelationships(document, assets) +} + +// validateAssetRelationships is intentionally kept beside archive validation: +// these relationships must be identical for imported, saved and exported +// themes rather than reimplemented by each Studio route. +func validateAssetRelationships(document, assets map[string]any) error { + decorations, err := stringMap(assets["decorations"], "theme decoration assets") + if err != nil { + return err + } + fonts, err := stringMap(assets["fonts"], "theme font assets") + if err != nil { + return err + } + if icons, err := stringMap(assets["suggestionIcons"], "suggestion icons"); err != nil { + return err + } else { + for key := range icons { + if key != "card1" && key != "card2" && key != "card3" && key != "card4" { + return Error{Code: "THEME_SCHEMA_INVALID", Message: "Suggestion icon slot is invalid"} + } + } + } + if projects, exists := assets["projectIcons"]; exists { + entries, ok := projects.([]any) + if !ok || len(entries) < 1 || len(entries) > 12 { + return Error{Code: "THEME_SCHEMA_INVALID", Message: "Project icons are invalid"} + } + } + if typography, exists := document["typography"]; exists { + value, ok := typography.(map[string]any) + if !ok { + return Error{Code: "THEME_SCHEMA_INVALID", Message: "Theme typography is invalid"} + } + for _, field := range []string{"uiFontAssetKey", "codeFontAssetKey", "displayFontAssetKey"} { + if key, exists := value[field]; exists { + name, ok := key.(string) + if !ok || fonts[name] == "" { + return Error{Code: "THEME_DISPLAY_FONT_MISSING", Message: "Theme font key is not declared"} + } + } + } + } + entries, ok := document["decorations"].([]any) + if !ok || len(entries) > 16 { + return Error{Code: "THEME_SCHEMA_INVALID", Message: "Theme decorations are invalid"} + } + for _, entry := range entries { + item, ok := entry.(map[string]any) + if !ok { + return Error{Code: "THEME_SCHEMA_INVALID", Message: "Theme decoration is invalid"} + } + if item["type"] == "image" { + key, ok := item["assetKey"].(string) + if !ok || decorations[key] == "" { + return Error{Code: "THEME_SCHEMA_INVALID", Message: "Image decoration asset is not declared"} + } + } + } + composition, ok := document["composition"].(map[string]any) + if !ok { + return Error{Code: "THEME_COMPOSITION_INVALID", Message: "Theme composition is invalid"} + } + layers, ok := composition["layers"].([]any) + if !ok || len(layers) > 24 { + return Error{Code: "THEME_COMPOSITION_INVALID", Message: "Theme composition layer count is invalid"} + } + seen := map[string]struct{}{} + for _, entry := range layers { + layer, ok := entry.(map[string]any) + if !ok { + return Error{Code: "THEME_COMPOSITION_INVALID", Message: "Theme composition layer is invalid"} + } + id, ok := layer["id"].(string) + if !ok || id == "" || len(id) > 40 || !themeIDPattern.MatchString(id) { + return Error{Code: "THEME_COMPOSITION_INVALID", Message: "Theme composition layer ID is invalid"} + } + if _, exists := seen[id]; exists { + return Error{Code: "THEME_COMPOSITION_INVALID", Message: "Theme composition layer ID is duplicated"} + } + seen[id] = struct{}{} + asset, ok := layer["asset"].(map[string]any) + if !ok { + return Error{Code: "THEME_COMPOSITION_INVALID", Message: "Theme composition asset is invalid"} + } + kind, ok := asset["kind"].(string) + if !ok || kind != "portrait" && kind != "decoration" { + return Error{Code: "THEME_COMPOSITION_INVALID", Message: "Theme composition asset kind is invalid"} + } + if kind == "portrait" { + if portrait, ok := assets["portrait"].(string); !ok || portrait == "" { + return Error{Code: "THEME_COMPOSITION_INVALID", Message: "Theme composition portrait is not declared"} + } + } else { + key, ok := asset["assetKey"].(string) + if !ok || decorations[key] == "" { + return Error{Code: "THEME_COMPOSITION_INVALID", Message: "Theme composition decoration is not declared"} + } + } + if surface, ok := layer["surface"].(string); !ok || !validCompositionSurface(surface) { + return Error{Code: "THEME_COMPOSITION_INVALID", Message: "Theme composition surface is invalid"} + } + if _, ok := layer["required"].(bool); !ok { + return Error{Code: "THEME_COMPOSITION_INVALID", Message: "Theme composition required flag is invalid"} + } + } return nil } +func stringMap(value any, label string) (map[string]string, error) { + if value == nil { + return map[string]string{}, nil + } + raw, ok := value.(map[string]any) + if !ok { + return nil, Error{Code: "THEME_SCHEMA_INVALID", Message: label + " are invalid"} + } + result := make(map[string]string, len(raw)) + for key, value := range raw { + path, ok := value.(string) + if !ok || !safeThemePath(path) { + return nil, Error{Code: "THEME_SCHEMA_INVALID", Message: label + " contain an invalid path"} + } + result[key] = path + } + return result, nil +} + +func validCompositionSurface(value string) bool { + for _, allowed := range []string{"viewport", "home-hero", "task-background", "content-layer", "sidebar", "composer"} { + if value == allowed { + return true + } + } + return false +} + func migrateToV4(document map[string]any, version int) { if _, exists := document["appearance"]; !exists { document["appearance"] = "auto" @@ -599,6 +763,11 @@ func migrateToV4(document map[string]any, version int) { } } } + ensureV4Defaults(document) + _ = version +} + +func ensureV4Defaults(document map[string]any) { if _, exists := document["surfaces"]; !exists { document["surfaces"] = map[string]any{"baseOpacity": 0.68, "elevatedOpacity": 0.92, "terminalOpacity": 0.82, "blur": 0} } @@ -634,7 +803,6 @@ func migrateToV4(document map[string]any, version int) { if _, exists := document["composition"]; !exists { document["composition"] = map[string]any{"layers": []any{}} } - _ = version } func headerFromDocument(contents []byte) (themeHeader, error) { diff --git a/host/go/internal/workspace/workspace.go b/host/go/internal/workspace/workspace.go index 1e93fa9..30ca941 100644 --- a/host/go/internal/workspace/workspace.go +++ b/host/go/internal/workspace/workspace.go @@ -6,6 +6,8 @@ package workspace import ( "bytes" "crypto/rand" + "crypto/sha256" + "encoding/binary" "encoding/hex" "encoding/json" "errors" @@ -19,12 +21,14 @@ import ( "sync" "time" + imagepipeline "github.com/u2bo/OpenChatGPTSkin/host/go/internal/image" "github.com/u2bo/OpenChatGPTSkin/host/go/internal/themerepo" ) const historyLimit = 50 var uuidPattern = regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$`) +var assetKeyPattern = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`) type Error struct { Code string @@ -73,6 +77,23 @@ type CreateInput struct { ConflictResolution string } +type UploadInput struct { + DraftID string + ExpectedRevision int + Slot string + AssetKey string + FileName string + MIMEType string + Bytes []byte +} + +type ClearAssetInput struct { + DraftID string + ExpectedRevision int + Slot string + AssetKey string +} + type record struct { SchemaVersion int `json:"schemaVersion"` DraftID string `json:"draftId"` @@ -129,6 +150,26 @@ func (workspace *Workspace) Create(input CreateInput) (Draft, error) { if err != nil { return Draft{}, err } + return workspace.createFromBundleLocked(input, bundle) +} + +func (workspace *Workspace) Import(contents []byte) (Draft, error) { + workspace.mu.Lock() + defer workspace.mu.Unlock() + ref, err := workspace.repository.ImportArchive(contents) + if err != nil { + return Draft{}, Error{Code: "STUDIO_IMPORT_INVALID", Message: err.Error()} + } + bundle, err := workspace.repository.Read("personal", ref) + if err != nil { + return Draft{}, Error{Code: "STUDIO_IMPORT_INVALID", Message: err.Error()} + } + return workspace.createFromBundleLocked(CreateInput{ + Source: "personal", Ref: ref, ThemeID: ref.ID, ConflictResolution: "overwrite-existing", + }, bundle) +} + +func (workspace *Workspace) createFromBundleLocked(input CreateInput, bundle themerepo.Bundle) (Draft, error) { theme, ref, err := workspace.prepareSourceTheme(bundle.Document, input) if err != nil { return Draft{}, err @@ -205,7 +246,7 @@ func (workspace *Workspace) Update(draftID string, expectedRevision int, theme j if err := assertRevision(record, expectedRevision); err != nil { return Draft{}, err } - normalized, ref, err := themerepo.NormalizeDocument(theme) + normalized, ref, err := themerepo.NormalizeDraftDocument(theme) if err != nil { return Draft{}, Error{Code: "STUDIO_DRAFT_INVALID", Message: err.Error()} } @@ -222,6 +263,71 @@ func (workspace *Workspace) Update(draftID string, expectedRevision int, theme j return workspace.view(next) } +// Upload processes an asset completely before the draft is mutated. This +// preserves the one-upload/one-revision invariant and leaves the old draft +// untouched if decoding, limits, or a filesystem write fails. +func (workspace *Workspace) Upload(input UploadInput) (Draft, error) { + workspace.mu.Lock() + defer workspace.mu.Unlock() + record, err := workspace.readRecord(input.DraftID) + if err != nil { + return Draft{}, err + } + if err := assertRevision(record, input.ExpectedRevision); err != nil { + return Draft{}, err + } + path, contents, err := normalizeAsset(input) + if err != nil { + return Draft{}, err + } + nextTheme, err := attachAsset(record.Theme, input, path) + if err != nil { + return Draft{}, err + } + normalized, _, err := themerepo.NormalizeDraftDocument(nextTheme) + if err != nil { + return Draft{}, Error{Code: "STUDIO_ASSET_INVALID", Message: err.Error()} + } + target := workspace.assetPath(input.DraftID, path) + _, existed := os.Stat(target) + if err := workspace.writeAsset(input.DraftID, path, contents); err != nil { + return Draft{}, err + } + next := mutateHistory(record, normalized) + if err := workspace.writeRecord(next); err != nil { + if errors.Is(existed, os.ErrNotExist) { + _ = os.Remove(target) + } + return Draft{}, err + } + return workspace.view(next) +} + +func (workspace *Workspace) ClearAsset(input ClearAssetInput) (Draft, error) { + workspace.mu.Lock() + defer workspace.mu.Unlock() + record, err := workspace.readRecord(input.DraftID) + if err != nil { + return Draft{}, err + } + if err := assertRevision(record, input.ExpectedRevision); err != nil { + return Draft{}, err + } + nextTheme, err := detachAsset(record.Theme, input) + if err != nil { + return Draft{}, err + } + normalized, _, err := themerepo.NormalizeDraftDocument(nextTheme) + if err != nil { + return Draft{}, Error{Code: "STUDIO_ASSET_INVALID", Message: err.Error()} + } + next := mutateHistory(record, normalized) + if err := workspace.writeRecord(next); err != nil { + return Draft{}, err + } + return workspace.view(next) +} + func (workspace *Workspace) Undo(draftID string, expectedRevision int) (Draft, error) { workspace.mu.Lock() defer workspace.mu.Unlock() @@ -324,6 +430,17 @@ func (workspace *Workspace) Save(draftID string, expectedRevision int) (Draft, t if err != nil { return Draft{}, themerepo.Ref{}, Error{Code: "STUDIO_SAVE_FAILED", Message: err.Error()} } + background, err := backgroundAsset(versioned) + if err != nil { + return Draft{}, themerepo.Ref{}, err + } + preview, err := imagepipeline.Process(files[background], imagepipeline.Options{ + Width: 640, Height: 400, Quality: 84, Fit: imagepipeline.FitCover, MaxInputBytes: 16 * 1024 * 1024, + }) + if err != nil { + return Draft{}, themerepo.Ref{}, Error{Code: "STUDIO_SAVE_FAILED", Message: err.Error()} + } + files["preview.webp"] = preview saved, err := workspace.repository.InstallPersonal(themerepo.Bundle{Ref: themerepo.Ref{ID: ref.ID, Version: version}, Document: versioned, Files: files}) if err != nil { return Draft{}, themerepo.Ref{}, err @@ -351,6 +468,38 @@ func (workspace *Workspace) Export(ref themerepo.Ref) ([]byte, error) { return workspace.repository.Export("personal", ref) } +func (workspace *Workspace) ReadAsset(draftID, path string) (themerepo.Asset, error) { + workspace.mu.Lock() + defer workspace.mu.Unlock() + record, err := workspace.readRecord(draftID) + if err != nil { + return themerepo.Asset{}, err + } + paths, err := themerepo.AssetPaths(record.Theme) + if err != nil { + return themerepo.Asset{}, Error{Code: "STUDIO_ASSET_INVALID", Message: err.Error()} + } + found := false + for _, candidate := range paths { + if candidate == path { + found = true + break + } + } + if !found { + return themerepo.Asset{}, Error{Code: "STUDIO_ASSET_INVALID", Message: "Draft asset is not declared"} + } + contents, err := os.ReadFile(workspace.assetPath(draftID, path)) + if err != nil { + return themerepo.Asset{}, err + } + mimeType := "image/webp" + if strings.HasSuffix(strings.ToLower(path), ".woff2") { + mimeType = "font/woff2" + } + return themerepo.Asset{Bytes: contents, MIMEType: mimeType}, nil +} + func (workspace *Workspace) prepareSourceTheme(contents []byte, input CreateInput) (json.RawMessage, themerepo.Ref, error) { normalized, ref, err := themerepo.NormalizeDocument(contents) if err != nil { @@ -398,10 +547,365 @@ func setThemeFields(document []byte, id, name, version string) (json.RawMessage, if err != nil { return nil, themerepo.Ref{}, err } - normalized, ref, err := themerepo.NormalizeDocument(encoded) + normalized, ref, err := themerepo.NormalizeDraftDocument(encoded) return json.RawMessage(normalized), ref, err } +func backgroundAsset(document []byte) (string, error) { + value, _, err := themeObject(document) + if err != nil { + return "", err + } + assets, _ := value["assets"].(map[string]any) + background, ok := assets["background"].(string) + if !ok || background == "" { + return "", Error{Code: "STUDIO_DRAFT_INVALID", Message: "Background image is required"} + } + return background, nil +} + +func normalizeAsset(input UploadInput) (string, []byte, error) { + if !validSlot(input.Slot) || strings.TrimSpace(input.FileName) == "" || len(input.FileName) > 160 { + return "", nil, Error{Code: "STUDIO_ASSET_INVALID", Message: "Asset upload metadata is invalid"} + } + if isFontSlot(input.Slot) { + if len(input.Bytes) == 0 || len(input.Bytes) > 5*1024*1024 || strings.ToLower(filepath.Ext(input.FileName)) != ".woff2" || !validWOFF2(input.Bytes) { + return "", nil, Error{Code: "STUDIO_ASSET_INVALID", Message: "Font must be a valid WOFF2 file up to 5 MB"} + } + key, err := requiredAssetKey(input.Slot, input.AssetKey) + if err != nil { + return "", nil, err + } + return "fonts/" + key + "-" + digest(input.Bytes) + ".woff2", append([]byte(nil), input.Bytes...), nil + } + if len(input.Bytes) == 0 || len(input.Bytes) > 50*1024*1024 || !validImageType(input.FileName, input.MIMEType) { + return "", nil, Error{Code: "STUDIO_ASSET_INVALID", Message: "Image must be a PNG, JPEG, or WebP file up to 50 MB"} + } + options := imagepipeline.Options{Quality: 80, Fit: imagepipeline.FitInside, NoUpscale: true, MaxInputBytes: 50 * 1024 * 1024} + switch input.Slot { + case "background": + options.Width, options.Height = 2400, 1350 + case "profile-avatar": + options.Width, options.Height, options.Fit, options.NoUpscale = 256, 256, imagepipeline.FitCover, false + case "suggestion-card1", "suggestion-card2", "suggestion-card3", "suggestion-card4", "project-icon1", "project-icon2", "project-icon3", "project-icon4": + options.Width, options.Height, options.Fit, options.NoUpscale = 192, 192, imagepipeline.FitCover, false + default: + options.Width, options.Height = 1400, 1400 + } + processed, err := imagepipeline.Process(input.Bytes, options) + if err != nil { + return "", nil, Error{Code: "STUDIO_ASSET_INVALID", Message: err.Error()} + } + name := "decoration" + switch input.Slot { + case "background": + name = "background" + case "portrait": + name = "portrait" + case "profile-avatar": + name = "profile-avatar" + case "suggestion-card1", "suggestion-card2", "suggestion-card3", "suggestion-card4", "project-icon1", "project-icon2", "project-icon3", "project-icon4": + name = input.Slot + case "decoration", "composition-layer": + key, keyErr := requiredAssetKey(input.Slot, input.AssetKey) + if keyErr != nil { + return "", nil, keyErr + } + name += "-" + key + } + return "assets/" + name + "-" + digest(processed) + ".webp", processed, nil +} + +func attachAsset(document []byte, input UploadInput, assetPath string) ([]byte, error) { + value, assets, err := themeObject(document) + if err != nil { + return nil, err + } + switch input.Slot { + case "background", "portrait", "profile-avatar": + field := map[string]string{"background": "background", "portrait": "portrait", "profile-avatar": "profileAvatar"}[input.Slot] + assets[field] = assetPath + case "suggestion-card1", "suggestion-card2", "suggestion-card3", "suggestion-card4": + icons := objectField(assets, "suggestionIcons") + icons[strings.TrimPrefix(input.Slot, "suggestion-")] = assetPath + case "project-icon1", "project-icon2", "project-icon3", "project-icon4": + index := int(input.Slot[len(input.Slot)-1] - '1') + icons, err := stringSliceField(assets, "projectIcons") + if err != nil { + return nil, err + } + for len(icons) <= index { + icons = append(icons, assetPath) + } + icons[index] = assetPath + assets["projectIcons"] = stringsToAny(icons) + case "decoration", "composition-layer": + key, err := requiredAssetKey(input.Slot, input.AssetKey) + if err != nil { + return nil, err + } + decorations := objectField(assets, "decorations") + decorations[key] = assetPath + if input.Slot == "decoration" { + entries, err := anySliceField(value, "decorations") + if err != nil { + return nil, err + } + entry := map[string]any{"type": "image", "enabled": true, "intensity": 0.6, "assetKey": key, "placement": "corners", "opacity": 0.75, "scale": 1} + replaced := false + for index, candidate := range entries { + if item, ok := candidate.(map[string]any); ok && item["assetKey"] == key { + entries[index], replaced = entry, true + break + } + } + if !replaced { + if len(entries) >= 16 { + return nil, Error{Code: "STUDIO_ASSET_INVALID", Message: "A theme can contain at most 16 decorations"} + } + entries = append(entries, entry) + } + value["decorations"] = entries + } else if err := upsertCompositionLayer(value, key); err != nil { + return nil, err + } + case "ui-font", "code-font", "display-font": + key, err := requiredAssetKey(input.Slot, input.AssetKey) + if err != nil { + return nil, err + } + fonts := objectField(assets, "fonts") + fonts[key] = assetPath + typography := objectField(value, "typography") + field := map[string]string{"ui-font": "uiFontAssetKey", "code-font": "codeFontAssetKey", "display-font": "displayFontAssetKey"}[input.Slot] + typography[field] = key + family := map[string]string{"ui-font": "uiFamily", "code-font": "codeFamily", "display-font": "displayFamily"}[input.Slot] + typography[family] = "ocs-" + key + default: + return nil, Error{Code: "STUDIO_ASSET_INVALID", Message: "Asset slot is unavailable"} + } + return json.Marshal(value) +} + +func detachAsset(document []byte, input ClearAssetInput) ([]byte, error) { + if !validSlot(input.Slot) { + return nil, Error{Code: "STUDIO_ASSET_INVALID", Message: "Asset slot is unavailable"} + } + value, assets, err := themeObject(document) + if err != nil { + return nil, err + } + switch input.Slot { + case "background", "portrait", "profile-avatar": + delete(assets, map[string]string{"background": "background", "portrait": "portrait", "profile-avatar": "profileAvatar"}[input.Slot]) + case "suggestion-card1", "suggestion-card2", "suggestion-card3", "suggestion-card4": + delete(objectField(assets, "suggestionIcons"), strings.TrimPrefix(input.Slot, "suggestion-")) + case "project-icon1", "project-icon2", "project-icon3", "project-icon4": + icons, err := stringSliceField(assets, "projectIcons") + if err != nil { + return nil, err + } + index := int(input.Slot[len(input.Slot)-1] - '1') + if index < len(icons) { + icons = append(icons[:index], icons[index+1:]...) + } + if len(icons) == 0 { + delete(assets, "projectIcons") + } else { + assets["projectIcons"] = stringsToAny(icons) + } + case "decoration", "composition-layer": + key, err := requiredAssetKey(input.Slot, input.AssetKey) + if err != nil { + return nil, err + } + delete(objectField(assets, "decorations"), key) + entries, sliceErr := anySliceField(value, "decorations") + if sliceErr != nil { + return nil, sliceErr + } + value["decorations"] = filterAssetKey(entries, key) + if composition, ok := value["composition"].(map[string]any); ok { + if layers, ok := composition["layers"].([]any); ok { + composition["layers"] = filterCompositionKey(layers, key) + } + } + case "ui-font", "code-font", "display-font": + key, err := requiredAssetKey(input.Slot, input.AssetKey) + if err != nil { + return nil, err + } + delete(objectField(assets, "fonts"), key) + typography := objectField(value, "typography") + delete(typography, map[string]string{"ui-font": "uiFontAssetKey", "code-font": "codeFontAssetKey", "display-font": "displayFontAssetKey"}[input.Slot]) + } + return json.Marshal(value) +} + +func themeObject(document []byte) (map[string]any, map[string]any, error) { + decoder := json.NewDecoder(bytes.NewReader(document)) + decoder.UseNumber() + value := map[string]any{} + if err := decoder.Decode(&value); err != nil { + return nil, nil, Error{Code: "STUDIO_ASSET_INVALID", Message: "Theme draft is invalid"} + } + assets, ok := value["assets"].(map[string]any) + if !ok { + return nil, nil, Error{Code: "STUDIO_ASSET_INVALID", Message: "Theme assets are invalid"} + } + return value, assets, nil +} + +func objectField(parent map[string]any, key string) map[string]any { + if value, ok := parent[key].(map[string]any); ok { + return value + } + value := map[string]any{} + parent[key] = value + return value +} + +func anySliceField(parent map[string]any, key string) ([]any, error) { + if value, exists := parent[key]; exists { + entries, ok := value.([]any) + if !ok { + return nil, Error{Code: "STUDIO_ASSET_INVALID", Message: "Theme asset collection is invalid"} + } + return append([]any(nil), entries...), nil + } + return []any{}, nil +} + +func stringSliceField(parent map[string]any, key string) ([]string, error) { + entries, err := anySliceField(parent, key) + if err != nil { + return nil, err + } + result := make([]string, 0, len(entries)) + for _, entry := range entries { + value, ok := entry.(string) + if !ok { + return nil, Error{Code: "STUDIO_ASSET_INVALID", Message: "Theme icon collection is invalid"} + } + result = append(result, value) + } + return result, nil +} + +func upsertCompositionLayer(theme map[string]any, key string) error { + composition := objectField(theme, "composition") + layers, err := anySliceField(composition, "layers") + if err != nil { + return err + } + layer := map[string]any{ + "id": key, "asset": map[string]any{"kind": "decoration", "assetKey": key}, "surface": "home-hero", + "anchor": "top-left", "positionX": 0.1, "positionY": 0.1, "width": 0.2, "opacity": 1, "rotation": 0, "required": false, + } + for index, candidate := range layers { + if entry, ok := candidate.(map[string]any); ok && entry["id"] == key { + layers[index] = layer + composition["layers"] = layers + return nil + } + } + if len(layers) >= 24 { + return Error{Code: "STUDIO_ASSET_INVALID", Message: "A theme can contain at most 24 composition layers"} + } + composition["layers"] = append(layers, layer) + return nil +} + +func filterAssetKey(entries []any, key string) []any { + result := make([]any, 0, len(entries)) + for _, entry := range entries { + if value, ok := entry.(map[string]any); ok && value["assetKey"] == key { + continue + } + result = append(result, entry) + } + return result +} + +func filterCompositionKey(entries []any, key string) []any { + result := make([]any, 0, len(entries)) + for _, entry := range entries { + value, ok := entry.(map[string]any) + if !ok { + result = append(result, entry) + continue + } + asset, _ := value["asset"].(map[string]any) + if asset["assetKey"] == key || value["id"] == key { + continue + } + result = append(result, entry) + } + return result +} + +func validSlot(slot string) bool { + switch slot { + case "background", "portrait", "decoration", "ui-font", "code-font", "display-font", "composition-layer", "profile-avatar", "suggestion-card1", "suggestion-card2", "suggestion-card3", "suggestion-card4", "project-icon1", "project-icon2", "project-icon3", "project-icon4": + return true + default: + return false + } +} + +func isFontSlot(slot string) bool { + return slot == "ui-font" || slot == "code-font" || slot == "display-font" +} + +func requiredAssetKey(slot, value string) (string, error) { + if value == "" { + switch slot { + case "ui-font", "code-font", "display-font": + value = slot + default: + return "", Error{Code: "STUDIO_ASSET_INVALID", Message: "Asset key is required"} + } + } + if len(value) > 40 || !assetKeyPattern.MatchString(value) { + return "", Error{Code: "STUDIO_ASSET_INVALID", Message: "Asset key is invalid"} + } + return value, nil +} + +func validImageType(fileName, mimeType string) bool { + extension := strings.ToLower(filepath.Ext(fileName)) + switch extension { + case ".png": + return mimeType == "image/png" + case ".jpg", ".jpeg": + return mimeType == "image/jpeg" + case ".webp": + return mimeType == "image/webp" + default: + return false + } +} + +func validWOFF2(contents []byte) bool { + if len(contents) < 48 || string(contents[:4]) != "wOF2" || int(binary.BigEndian.Uint32(contents[8:12])) != len(contents) || binary.BigEndian.Uint16(contents[12:14]) == 0 || binary.BigEndian.Uint32(contents[16:20]) == 0 { + return false + } + return true +} + +func digest(contents []byte) string { + sum := sha256.Sum256(contents) + return hex.EncodeToString(sum[:])[:12] +} + +func stringsToAny(values []string) []any { + result := make([]any, len(values)) + for index, value := range values { + result[index] = value + } + return result +} + func (workspace *Workspace) view(record record) (Draft, error) { paths, err := themerepo.AssetPaths(record.Theme) if err != nil { @@ -420,6 +924,8 @@ func (workspace *Workspace) view(record record) (Draft, error) { } func validationIssues(theme json.RawMessage) []Issue { + // A draft may be edited while incomplete, but the inspector must still + // surface anything that would block its explicit immutable save. if err := themerepo.ValidateDocument(theme); err != nil { return []Issue{{Code: themerepo.ErrorCodeFrom(err), Path: "theme", Message: err.Error(), Severity: "error"}} } @@ -555,7 +1061,7 @@ func (workspace *Workspace) findByGroup(group string) (*record, error) { } var latest *record for index := range records { - _, ref, err := themerepo.NormalizeDocument(records[index].Theme) + _, ref, err := themerepo.NormalizeDraftDocument(records[index].Theme) if err != nil || workspace.groupKey(ref.ID) != group { continue } @@ -577,7 +1083,7 @@ func (workspace *Workspace) removeDuplicateDrafts() error { }) seen := map[string]bool{} for _, record := range records { - _, ref, err := themerepo.NormalizeDocument(record.Theme) + _, ref, err := themerepo.NormalizeDraftDocument(record.Theme) if err != nil { return err } @@ -663,11 +1169,11 @@ func validateRecord(record record) error { if record.SchemaVersion != 1 || !safeDraftID(record.DraftID) || record.Revision < 0 || record.UpdatedAt == "" || len(record.Past) > historyLimit || len(record.Future) > historyLimit { return errors.New("draft record envelope is invalid") } - if _, _, err := themerepo.NormalizeDocument(record.Theme); err != nil { + if _, _, err := themerepo.NormalizeDraftDocument(record.Theme); err != nil { return err } for _, snapshot := range append(append([]json.RawMessage{}, record.Past...), record.Future...) { - if _, _, err := themerepo.NormalizeDocument(snapshot); err != nil { + if _, _, err := themerepo.NormalizeDraftDocument(snapshot); err != nil { return err } } diff --git a/host/go/internal/workspace/workspace_test.go b/host/go/internal/workspace/workspace_test.go index e9ccd8b..c4a7304 100644 --- a/host/go/internal/workspace/workspace_test.go +++ b/host/go/internal/workspace/workspace_test.go @@ -1,7 +1,12 @@ package workspace import ( + "bytes" + "encoding/binary" "encoding/json" + stdimage "image" + "image/color" + "image/png" "os" "path/filepath" "testing" @@ -23,7 +28,7 @@ func writeBuiltin(t *testing.T, root string) { if err := os.WriteFile(filepath.Join(directory, "theme.json"), []byte(theme), 0o600); err != nil { t.Fatal(err) } - if err := os.WriteFile(filepath.Join(directory, "assets", "background.webp"), []byte("RIFF0000WEBP"), 0o600); err != nil { + if err := os.WriteFile(filepath.Join(directory, "assets", "background.webp"), uploadPNG(t), 0o600); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(directory, "preview.webp"), []byte("RIFF0000WEBP"), 0o600); err != nil { @@ -124,9 +129,13 @@ func TestWorkspaceSaveCreatesImmutablePersonalVersionAndExports(t *testing.T) { if err != nil || len(archive) == 0 { t.Fatalf("export bytes=%d error=%v", len(archive), err) } - if _, err := repository.Read("personal", ref); err != nil { + bundle, err := repository.Read("personal", ref) + if err != nil { t.Fatal(err) } + if preview := bundle.Files["preview.webp"]; len(preview) < 12 || string(preview[:4]) != "RIFF" || string(preview[8:12]) != "WEBP" { + t.Fatal("saved personal theme has no generated WebP preview") + } idempotent, sameRef, err := workspace.Save(saved.DraftID, saved.Revision) if err != nil || sameRef != ref || idempotent.Revision != saved.Revision { t.Fatalf("idempotent save draft=%+v ref=%+v error=%v", idempotent, sameRef, err) @@ -151,3 +160,97 @@ func TestWorkspaceRejectsMalformedDraftAndPreservesEvidence(t *testing.T) { t.Fatalf("invalid evidence = %v error=%v", evidence, err) } } + +func uploadPNG(t *testing.T) []byte { + t.Helper() + image := stdimage.NewNRGBA(stdimage.Rect(0, 0, 8, 4)) + image.SetNRGBA(1, 1, color.NRGBA{R: 255, G: 80, B: 160, A: 150}) + var output bytes.Buffer + if err := png.Encode(&output, image); err != nil { + t.Fatal(err) + } + return output.Bytes() +} + +func TestWorkspaceProcessesSlotAssetsOnceAndClearKeepsDraftHistory(t *testing.T) { + workspace, _, root := newWorkspace(t) + draft, err := workspace.Create(CreateInput{Source: "builtin", Ref: themerepo.Ref{ID: "mountain-mist", Version: "1.3.0"}}) + if err != nil { + t.Fatal(err) + } + updated, err := workspace.Upload(UploadInput{ + DraftID: draft.DraftID, ExpectedRevision: draft.Revision, Slot: "profile-avatar", + FileName: "avatar.png", MIMEType: "image/png", Bytes: uploadPNG(t), + }) + if err != nil || updated.Revision != draft.Revision+1 || len(updated.AssetURLs) != 2 { + t.Fatalf("upload draft=%+v error=%v", updated, err) + } + var theme struct { + Assets struct { + ProfileAvatar string `json:"profileAvatar"` + } `json:"assets"` + } + if err := json.Unmarshal(updated.Theme, &theme); err != nil { + t.Fatal(err) + } + contents, err := os.ReadFile(filepath.Join(root, draft.DraftID, filepath.FromSlash(theme.Assets.ProfileAvatar))) + if err != nil || len(contents) < 12 || string(contents[:4]) != "RIFF" || string(contents[8:12]) != "WEBP" { + t.Fatalf("processed avatar bytes=%d error=%v", len(contents), err) + } + if _, err := workspace.Upload(UploadInput{ + DraftID: draft.DraftID, ExpectedRevision: updated.Revision, Slot: "profile-avatar", + FileName: "bad.txt", MIMEType: "text/plain", Bytes: []byte("bad"), + }); ErrorCode(err) != "STUDIO_ASSET_INVALID" { + t.Fatalf("invalid upload error = %v", err) + } + afterInvalid, err := workspace.Open(draft.DraftID) + if err != nil || afterInvalid.Revision != updated.Revision { + t.Fatalf("invalid upload changed draft=%+v error=%v", afterInvalid, err) + } + cleared, err := workspace.ClearAsset(ClearAssetInput{DraftID: draft.DraftID, ExpectedRevision: updated.Revision, Slot: "background"}) + if err != nil || cleared.Revision != updated.Revision+1 || len(cleared.Issues) != 1 { + t.Fatalf("cleared draft=%+v error=%v", cleared, err) + } + if _, _, err := workspace.Save(cleared.DraftID, cleared.Revision); ErrorCode(err) != "STUDIO_DRAFT_INVALID" { + t.Fatalf("saved incomplete draft error = %v", err) + } +} + +func TestWorkspaceAcceptsBoundedWOFF2Slots(t *testing.T) { + font := make([]byte, 48) + copy(font, "wOF2") + binary.BigEndian.PutUint32(font[8:12], uint32(len(font))) + binary.BigEndian.PutUint16(font[12:14], 1) + binary.BigEndian.PutUint32(font[16:20], 1) + if !validWOFF2(font) { + t.Fatal("well-formed bounded WOFF2 header was rejected") + } + if validWOFF2(font[:20]) { + t.Fatal("truncated WOFF2 header was accepted") + } +} + +func TestWorkspaceImportsArchiveIntoOnePersonalDraft(t *testing.T) { + first, _, _ := newWorkspace(t) + draft, err := first.Create(CreateInput{Source: "builtin", Ref: themerepo.Ref{ID: "mountain-mist", Version: "1.3.0"}}) + if err != nil { + t.Fatal(err) + } + _, ref, err := first.Save(draft.DraftID, draft.Revision) + if err != nil { + t.Fatal(err) + } + archive, err := first.Export(ref) + if err != nil { + t.Fatal(err) + } + second, _, _ := newWorkspace(t) + imported, err := second.Import(archive) + if err != nil || imported.SavedRef == nil || *imported.SavedRef != ref || imported.Dirty { + t.Fatalf("imported draft=%+v error=%v", imported, err) + } + loaded, err := second.Create(CreateInput{Source: "personal", Ref: ref, ThemeID: ref.ID, ConflictResolution: "load-existing"}) + if err != nil || loaded.DraftID != imported.DraftID { + t.Fatalf("loaded draft=%+v error=%v", loaded, err) + } +} From c73082b0979733e44ace19d6a8486e997c1acba6 Mon Sep 17 00:00:00 2001 From: u2bo <285077078@qq.com> Date: Fri, 24 Jul 2026 22:39:51 +0800 Subject: [PATCH 10/23] feat: add controller state machine and runtime baseline --- host/go/internal/app/contract.go | 83 +++++++++++ host/go/internal/app/controller.go | 174 ++++++++++++++++++++---- host/go/internal/app/controller_test.go | 34 +++++ host/go/internal/control/dispatcher.go | 13 ++ 4 files changed, 281 insertions(+), 23 deletions(-) diff --git a/host/go/internal/app/contract.go b/host/go/internal/app/contract.go index 69807b8..7872d76 100644 --- a/host/go/internal/app/contract.go +++ b/host/go/internal/app/contract.go @@ -15,6 +15,7 @@ import ( "strings" "time" + "github.com/u2bo/OpenChatGPTSkin/host/go/internal/control" "github.com/u2bo/OpenChatGPTSkin/host/go/internal/themerepo" ) @@ -40,6 +41,8 @@ func runContractBaseline(ctx context.Context, arguments []string) (map[string]an switch suite { case "studio": return runStudioBaseline(ctx) + case "runtime": + return runRuntimeBaseline(ctx) case "theme": return runThemeBaseline() default: @@ -47,6 +50,86 @@ func runContractBaseline(ctx context.Context, arguments []string) (map[string]an } } +func runRuntimeBaseline(ctx context.Context) (map[string]any, error) { + dataRoot, err := os.MkdirTemp("", "openchatgptskin-go-runtime-baseline-") + if err != nil { + return nil, err + } + defer os.RemoveAll(dataRoot) + startupID := "00000000-0000-4000-8000-000000000201" + startupFile := filepath.Join(dataRoot, "startup.json") + controllerContext, cancel := context.WithCancel(ctx) + defer cancel() + done := make(chan error, 1) + go func() { + done <- runController(controllerContext, controllerOptions{startupID: startupID, startupFile: startupFile, dataRoot: dataRoot}) + }() + handshakeContext, cancelHandshake := context.WithTimeout(ctx, 5*time.Second) + defer cancelHandshake() + handshake, err := waitForHandshake(handshakeContext, startupFile, startupID) + if err != nil { + return nil, err + } + dial := func() (control.Connection, error) { return dialControl(handshake.Endpoint) } + request := func(id, command, params string) (control.Response, error) { + return control.RoundTrip(handshakeContext, dial, control.Request{ProtocolVersion: control.ProtocolVersion, RequestID: id, Command: command, Params: json.RawMessage(params)}) + } + status, err := request("00000000-0000-4000-8000-000000000202", "status", `{}`) + if err != nil { + return nil, err + } + launched, err := request("00000000-0000-4000-8000-000000000203", "launch", `{"themeId":"mountain-mist","themeVersion":"1.3.0"}`) + if err != nil { + return nil, err + } + replayed, err := request("00000000-0000-4000-8000-000000000203", "launch", `{"themeId":"mountain-mist","themeVersion":"1.3.0"}`) + if err != nil { + return nil, err + } + conflicting, err := request("00000000-0000-4000-8000-000000000203", "pause", `{}`) + if err != nil { + return nil, err + } + if _, err := request("00000000-0000-4000-8000-000000000204", "restore", `{}`); err != nil { + return nil, err + } + select { + case err := <-done: + if err != nil { + return nil, err + } + case <-handshakeContext.Done(): + return nil, handshakeContext.Err() + } + statusValue, launchValue := runtimeStatusFromResponse(status), runtimeStatusFromResponse(launched) + if !status.OK || !launched.OK || !replayed.OK || conflicting.OK { + return nil, commandError{code: "INTERNAL", message: "Runtime baseline responses have unexpected success states"} + } + conflictCode := "" + if conflicting.Error != nil { + conflictCode = conflicting.Error.Code + } + return map[string]any{ + "protocolVersion": status.ProtocolVersion, + "frameLimitBytes": control.MaxFrameBytes, + "transportSecurityVerified": true, + "status": statusValue, + "launchStatus": launchValue, + "replayed": bytes.Equal(replayed.Result, launched.Result), + "conflictingRequestCode": conflictCode, + }, nil +} + +func runtimeStatusFromResponse(response control.Response) string { + var value struct { + Status string `json:"status"` + } + if !response.OK || json.Unmarshal(response.Result, &value) != nil { + return "" + } + return value.Status +} + func runStudioBaseline(ctx context.Context) (map[string]any, error) { dataRoot, err := os.MkdirTemp("", "openchatgptskin-go-studio-baseline-") if err != nil { diff --git a/host/go/internal/app/controller.go b/host/go/internal/app/controller.go index c8c9813..07f9750 100644 --- a/host/go/internal/app/controller.go +++ b/host/go/internal/app/controller.go @@ -1,10 +1,12 @@ package app import ( + "bytes" "context" "encoding/json" "errors" "fmt" + "io" "net" "os" "path/filepath" @@ -30,45 +32,166 @@ type controllerModel struct { themeID string version string terminal bool + state string } -func newControllerDispatcher() (*control.Dispatcher, *controllerModel) { - model := &controllerModel{status: "stopped"} +type persistedControllerState struct { + SchemaVersion int `json:"schemaVersion"` + Status string `json:"status"` + ThemeID string `json:"themeId,omitempty"` + ThemeVersion string `json:"themeVersion,omitempty"` + UpdatedAt string `json:"updatedAt"` +} + +func loadControllerModel(path string) (*controllerModel, error) { + model := &controllerModel{status: "stopped", state: path} + contents, err := os.ReadFile(path) + if errors.Is(err, os.ErrNotExist) { + return model, nil + } + if err != nil { + return nil, err + } + var persisted persistedControllerState + decoder := json.NewDecoder(bytesReader(contents)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&persisted); err != nil || persisted.SchemaVersion != 1 || persisted.Status == "" || persisted.UpdatedAt == "" { + return nil, control.CommandError{Code: "RUNTIME_SESSION_STALE", Message: "Runtime session evidence is invalid"} + } + if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { + return nil, control.CommandError{Code: "RUNTIME_SESSION_STALE", Message: "Runtime session evidence has trailing data"} + } + model.status, model.themeID, model.version = persisted.Status, persisted.ThemeID, persisted.ThemeVersion + if model.status != "stopped" && model.status != "restored-awaiting-exit" { + model.status = "recovery-required" + if err := model.persistLocked(); err != nil { + return nil, err + } + } + return model, nil +} + +func bytesReader(contents []byte) *bytes.Reader { return bytes.NewReader(contents) } + +func newControllerDispatcher(model *controllerModel) *control.Dispatcher { dispatcher := control.NewDispatcher(func(_ context.Context, request control.Request) (control.Result, error) { if request.Command == "status" { model.mu.RLock() defer model.mu.RUnlock() - return control.Result{"status": model.status, "themeId": model.themeID, "themeVersion": model.version}, nil + return model.result(), nil } model.mu.Lock() defer model.mu.Unlock() + next := controllerModel{status: model.status, themeID: model.themeID, version: model.version, terminal: model.terminal, state: model.state} switch request.Command { - case "launch", "switch", "resume": - params := control.ThemeParameters{} - if request.Command != "resume" { - var err error - params, err = control.DecodeThemeParameters(request) - if err != nil { - return nil, err - } + case "launch": + if model.status != "stopped" { + return nil, control.CommandError{Code: "RUNTIME_INVALID_TRANSITION", Message: "Runtime must be stopped before launch"} + } + params, err := control.DecodeThemeParameters(request) + if err != nil { + return nil, err } - if params.ThemeID != "" { - model.themeID = params.ThemeID + next.status, next.themeID, next.version = "active", params.ThemeID, params.ThemeVersion + case "switch": + if model.status != "active" && model.status != "paused" { + return nil, control.CommandError{Code: "RUNTIME_INVALID_TRANSITION", Message: "Runtime must be active or paused before switching theme"} } - if params.ThemeVersion != "" { - model.version = params.ThemeVersion + params, err := control.DecodeThemeParameters(request) + if err != nil { + return nil, err } - model.status = "active" + next.status, next.themeID, next.version = "active", params.ThemeID, params.ThemeVersion + case "resume": + if model.status != "paused" { + return nil, control.CommandError{Code: "RUNTIME_INVALID_TRANSITION", Message: "Runtime must be paused before resume"} + } + next.status = "active" case "pause": - model.status = "paused" + if model.status != "active" { + return nil, control.CommandError{Code: "RUNTIME_INVALID_TRANSITION", Message: "Runtime must be active before pause"} + } + next.status = "paused" case "restore": - model.status = "stopped" - model.themeID, model.version = "", "" - model.terminal = true + if model.status == "restored-awaiting-exit" { + return nil, control.CommandError{Code: "RUNTIME_INVALID_TRANSITION", Message: "Runtime restore is already pending exit"} + } + next.status, next.themeID, next.version, next.terminal = "restored-awaiting-exit", "", "", true + default: + return nil, control.CommandError{Code: "RUNTIME_INVALID_TRANSITION", Message: "Runtime command is unavailable in the current state"} + } + if err := next.persistLocked(); err != nil { + return nil, err } - return control.Result{"status": model.status, "themeId": model.themeID, "themeVersion": model.version}, nil + model.status, model.themeID, model.version, model.terminal = next.status, next.themeID, next.version, next.terminal + return model.result(), nil }) - return dispatcher, model + return dispatcher +} + +func (model *controllerModel) persistLocked() error { + if model.state == "" { + return nil + } + contents, err := json.MarshalIndent(persistedControllerState{SchemaVersion: 1, Status: model.status, ThemeID: model.themeID, ThemeVersion: model.version, UpdatedAt: time.Now().UTC().Format(time.RFC3339Nano)}, "", " ") + if err != nil { + return err + } + contents = append(contents, '\n') + return atomicStateWrite(model.state, contents) +} + +func atomicStateWrite(path string, contents []byte) error { + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return err + } + temporary, err := os.CreateTemp(filepath.Dir(path), ".runtime-session-*") + if err != nil { + return err + } + temporaryPath := temporary.Name() + committed := false + defer func() { + if !committed { + _ = os.Remove(temporaryPath) + } + }() + if _, err := temporary.Write(contents); err != nil { + _ = temporary.Close() + return err + } + if err := temporary.Sync(); err != nil { + _ = temporary.Close() + return err + } + if err := temporary.Close(); err != nil { + return err + } + if err := os.Rename(temporaryPath, path); err != nil { + return err + } + committed = true + return nil +} + +func (model *controllerModel) result() control.Result { + result := control.Result{"status": model.status, "themeId": model.themeID, "themeVersion": model.version} + if model.themeID == "" { + result["selectedTheme"] = nil + result["appliedTheme"] = nil + result["skinApplied"] = false + } else { + ref := map[string]string{"id": model.themeID, "version": model.version} + result["selectedTheme"] = ref + if model.status == "active" || model.status == "paused" { + result["appliedTheme"] = ref + result["skinApplied"] = true + } else { + result["appliedTheme"] = nil + result["skinApplied"] = false + } + } + return result } func (model *controllerModel) isTerminal() bool { @@ -151,10 +274,15 @@ func runController(ctx context.Context, options controllerOptions) error { return err } defer listener.Close() + model, err := loadControllerModel(filepath.Join(options.dataRoot, "runtime-session.json")) + if err != nil { + _ = writeHandshake(options.startupFile, startupHandshake{OK: false, StartupID: options.startupID, ErrorCode: "RUNTIME_SESSION_STALE"}) + return err + } if err := writeHandshake(options.startupFile, startupHandshake{OK: true, StartupID: options.startupID, Endpoint: endpoint}); err != nil { return err } - dispatcher, model := newControllerDispatcher() + dispatcher := newControllerDispatcher(model) err = control.Serve(ctx, listener, dispatcher, func() { if options.once || model.isTerminal() { _ = listener.Close() diff --git a/host/go/internal/app/controller_test.go b/host/go/internal/app/controller_test.go index a57ae5a..b228dc9 100644 --- a/host/go/internal/app/controller_test.go +++ b/host/go/internal/app/controller_test.go @@ -1,6 +1,7 @@ package app import ( + "bytes" "context" "encoding/json" "errors" @@ -95,3 +96,36 @@ func TestRestoreRespondsBeforeControllerTerminalCleanup(t *testing.T) { t.Fatalf("controller lock remains after restore: %v", err) } } + +func TestControllerStateMachinePersistsAndRequiresRecoveryAfterCrash(t *testing.T) { + statePath := filepath.Join(t.TempDir(), "runtime-session.json") + model, err := loadControllerModel(statePath) + if err != nil { + t.Fatal(err) + } + dispatcher := newControllerDispatcher(model) + launch := dispatcher.Dispatch(context.Background(), control.Request{ + ProtocolVersion: 1, RequestID: "00000000-0000-4000-8000-000000000506", Command: "launch", + Params: json.RawMessage(`{"themeId":"mountain-mist","themeVersion":"1.3.0"}`), + }) + if !launch.OK { + t.Fatalf("launch response = %+v", launch) + } + invalid := dispatcher.Dispatch(context.Background(), control.Request{ + ProtocolVersion: 1, RequestID: "00000000-0000-4000-8000-000000000507", Command: "launch", + Params: json.RawMessage(`{"themeId":"mountain-mist","themeVersion":"1.3.0"}`), + }) + if invalid.OK || invalid.Error == nil || invalid.Error.Code != "RUNTIME_INVALID_TRANSITION" { + t.Fatalf("invalid transition = %+v", invalid) + } + recovered, err := loadControllerModel(statePath) + if err != nil { + t.Fatal(err) + } + recoveryStatus := newControllerDispatcher(recovered).Dispatch(context.Background(), control.Request{ + ProtocolVersion: 1, RequestID: "00000000-0000-4000-8000-000000000508", Command: "status", Params: json.RawMessage(`{}`), + }) + if !recoveryStatus.OK || !bytes.Contains(recoveryStatus.Result, []byte(`"recovery-required"`)) { + t.Fatalf("recovery status = %+v", recoveryStatus) + } +} diff --git a/host/go/internal/control/dispatcher.go b/host/go/internal/control/dispatcher.go index 0a3d2b5..5c30df5 100644 --- a/host/go/internal/control/dispatcher.go +++ b/host/go/internal/control/dispatcher.go @@ -11,6 +11,15 @@ import ( type Handler func(context.Context, Request) (Result, error) +// CommandError keeps deliberate state-machine rejections distinguishable from +// unexpected host failures at the authenticated control boundary. +type CommandError struct { + Code string + Message string +} + +func (err CommandError) Error() string { return err.Message } + type cachedResponse struct { command string response Response @@ -159,6 +168,10 @@ func (dispatcher *Dispatcher) execute(ctx context.Context, request Request) Resp } result, err := dispatcher.handler(ctx, request) if err != nil { + var commandErr CommandError + if errors.As(err, &commandErr) { + return rejected(request, commandErr.Code, commandErr.Message) + } return rejected(request, "INTERNAL", "The Runtime command could not be completed.") } payload, err := json.Marshal(result) From 2c5b2c79bf9145a1f705183a754c2b0d452c2253 Mon Sep 17 00:00:00 2001 From: u2bo <285077078@qq.com> Date: Fri, 24 Jul 2026 22:46:07 +0800 Subject: [PATCH 11/23] fix: preserve node draft path and honest runtime status --- host/go/internal/app/controller.go | 14 +++++--------- host/go/internal/app/studio.go | 10 ++++++---- 2 files changed, 11 insertions(+), 13 deletions(-) diff --git a/host/go/internal/app/controller.go b/host/go/internal/app/controller.go index 07f9750..ecd972e 100644 --- a/host/go/internal/app/controller.go +++ b/host/go/internal/app/controller.go @@ -178,19 +178,15 @@ func (model *controllerModel) result() control.Result { result := control.Result{"status": model.status, "themeId": model.themeID, "themeVersion": model.version} if model.themeID == "" { result["selectedTheme"] = nil - result["appliedTheme"] = nil - result["skinApplied"] = false } else { ref := map[string]string{"id": model.themeID, "version": model.version} result["selectedTheme"] = ref - if model.status == "active" || model.status == "paused" { - result["appliedTheme"] = ref - result["skinApplied"] = true - } else { - result["appliedTheme"] = nil - result["skinApplied"] = false - } } + // A controller transition alone is not a visual application. The platform + // CDP adapter must verify the exact renderer before it can set these fields. + result["appliedTheme"] = nil + result["skinApplied"] = false + result["nextAction"] = "A verified platform adapter must apply and confirm the theme." return result } diff --git a/host/go/internal/app/studio.go b/host/go/internal/app/studio.go index 160b6ba..acfabaf 100644 --- a/host/go/internal/app/studio.go +++ b/host/go/internal/app/studio.go @@ -46,10 +46,12 @@ func startStudio(ctx context.Context, viteOrigin, configuredDataRoot string) (*R } } running, err := studio.Start(ctx, studio.Config{ - IndexHTML: indexHTML, - ThemeRoot: filepath.Join(installRoot, "themes"), - PersonalRoot: filepath.Join(dataRoot, "theme-store"), - DraftRoot: filepath.Join(dataRoot, "theme-studio-drafts"), + IndexHTML: indexHTML, + ThemeRoot: filepath.Join(installRoot, "themes"), + PersonalRoot: filepath.Join(dataRoot, "theme-store"), + // Keep the v0.2 Node path exactly: Go and Node must share one draft + // history during the provisional cutover and rollback window. + DraftRoot: filepath.Join(dataRoot, "theme-studio", "drafts"), StudioVersion: goHostVersion, RepositoryURL: &repositoryURL, ViteOrigin: viteOrigin, From c67dc210c7876f823b9ec33382e17301d9ac2065 Mon Sep 17 00:00:00 2001 From: u2bo <285077078@qq.com> Date: Fri, 24 Jul 2026 22:53:11 +0800 Subject: [PATCH 12/23] feat: verify official Windows Codex Appx identity --- .../platform/windows/adapter_windows.go | 163 ++++++++++++++++++ .../platform/windows/adapter_windows_test.go | 79 +++++++++ 2 files changed, 242 insertions(+) create mode 100644 host/go/internal/platform/windows/adapter_windows.go create mode 100644 host/go/internal/platform/windows/adapter_windows_test.go diff --git a/host/go/internal/platform/windows/adapter_windows.go b/host/go/internal/platform/windows/adapter_windows.go new file mode 100644 index 0000000..7f32f84 --- /dev/null +++ b/host/go/internal/platform/windows/adapter_windows.go @@ -0,0 +1,163 @@ +//go:build windows + +// Package windows verifies the official Windows Appx installation before any +// launch or CDP action is considered. It deliberately owns no theme/DOM logic. +package windows + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "os/exec" + "regexp" + "strings" + "time" +) + +const ( + expectedIdentity = "OpenAI.Codex" + expectedPublisher = "CN=50BDFD77-8903-4850-9FFE-6E8522F64D5B" + expectedSigner = "50BDFD77-8903-4850-9FFE-6E8522F64D5B" + expectedResource = "OpenAI OpCo, LLC" + expectedEntry = "app/ChatGPT.exe" +) + +var versionPattern = regexp.MustCompile(`^\d+\.\d+\.\d+\.\d+$`) + +type Install struct { + PackageRoot string `json:"packageRoot"` + EntryPath string `json:"entryPath"` + IdentityName string `json:"identityName"` + PackageVersion string `json:"packageVersion"` + PackagePublisher string `json:"packagePublisher"` + AppID string `json:"appId"` + EntryRelativePath string `json:"entryRelativePath"` + EntryPoint string `json:"entryPoint"` + PackageSignatureStatus string `json:"packageSignatureStatus"` + PackageSignerCommonName string `json:"packageSignerCommonName"` + CatalogSignatureStatus string `json:"catalogSignatureStatus"` + CatalogSignerCommonName string `json:"catalogSignerCommonName"` + EntryBlockMapValid bool `json:"entryBlockMapValid"` + ResourceSignatureStatus string `json:"resourceSignatureStatus"` + ResourceSignerCommonName string `json:"resourceSignerCommonName"` +} + +type Error struct { + Code string + Message string +} + +func (err Error) Error() string { return err.Message } + +type PowerShellRunner interface { + Run(context.Context, string) ([]byte, error) +} + +type defaultRunner struct{} + +func (defaultRunner) Run(ctx context.Context, script string) ([]byte, error) { + command := exec.CommandContext(ctx, "powershell.exe", "-NoLogo", "-NoProfile", "-NonInteractive", "-Command", "& ([scriptblock]::Create([Console]::In.ReadToEnd()))") + command.Stdin = strings.NewReader(script) + var stdout, stderr bytes.Buffer + command.Stdout, command.Stderr = &stdout, &stderr + if err := command.Run(); err != nil { + message := strings.TrimSpace(stderr.String()) + if message == "" { + message = err.Error() + } + return nil, Error{Code: "PROCESS_INSPECTION_DENIED", Message: message} + } + return stdout.Bytes(), nil +} + +// InspectOfficialCodex performs a fresh Appx inspection. Cached evidence is +// never accepted as an identity substitute. +func InspectOfficialCodex(ctx context.Context) (Install, error) { + return inspect(ctx, defaultRunner{}) +} + +func inspect(ctx context.Context, runner PowerShellRunner) (Install, error) { + inspectionContext, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + contents, err := runner.Run(inspectionContext, inspectionScript) + if err != nil { + return Install{}, err + } + var install Install + decoder := json.NewDecoder(bytes.NewReader(contents)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&install); err != nil { + return Install{}, Error{Code: "PROCESS_INSPECTION_DENIED", Message: "Windows Appx inspection returned invalid JSON"} + } + if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { + return Install{}, Error{Code: "PROCESS_INSPECTION_DENIED", Message: "Windows Appx inspection returned trailing data"} + } + if err := verify(install); err != nil { + return Install{}, err + } + return install, nil +} + +func verify(install Install) error { + valid := install.PackageRoot != "" && install.EntryPath != "" && + install.IdentityName == expectedIdentity && versionPattern.MatchString(install.PackageVersion) && + install.PackagePublisher == expectedPublisher && install.AppID == "App" && + normalizePath(install.EntryRelativePath) == expectedEntry && install.EntryPoint == "Windows.FullTrustApplication" && + install.PackageSignatureStatus == "Valid" && install.PackageSignerCommonName == expectedSigner && + install.CatalogSignatureStatus == "Valid" && install.CatalogSignerCommonName == expectedSigner && + install.EntryBlockMapValid && install.ResourceSignatureStatus == "Valid" && + install.ResourceSignerCommonName == expectedResource && + strings.EqualFold(normalizePath(install.EntryPath), normalizePath(install.PackageRoot)+"/"+expectedEntry) + if !valid { + return Error{Code: "CODEX_IDENTITY_INVALID", Message: "Codex Appx identity, signature, block map, or entry path is invalid"} + } + return nil +} + +func normalizePath(path string) string { + return strings.TrimRight(strings.ReplaceAll(path, "\\", "/"), "/") +} + +const inspectionScript = `$ErrorActionPreference = "Stop" +$packages = @(Get-AppxPackage -Name "OpenAI.Codex" -ErrorAction SilentlyContinue) +if ($packages.Count -ne 1) { throw "Expected exactly one OpenAI.Codex Appx registration" } +$package = $packages[0] +$root = [IO.Path]::GetFullPath([string]$package.InstallLocation) +[xml]$manifest = Get-Content -Raw -LiteralPath ([IO.Path]::Combine($root, "AppxManifest.xml")) +$app = @($manifest.Package.Applications.Application) | Where-Object { $_.Id -eq "App" } | Select-Object -First 1 +if ($null -eq $app) { throw "Official Appx application entry is missing" } +$entry = [IO.Path]::GetFullPath([IO.Path]::Combine($root, [string]$app.Executable)) +function Signature([string]$path) { + $value = Get-AuthenticodeSignature -LiteralPath $path + $name = if ($null -eq $value.SignerCertificate) { "" } else { $value.SignerCertificate.GetNameInfo([Security.Cryptography.X509Certificates.X509NameType]::SimpleName, $false) } + return [pscustomobject]@{ status = [string]$value.Status; signer = $name } +} +function Test-BlockMap([string]$packageRoot, [string]$relativePath) { + [xml]$blockMap = Get-Content -Raw -LiteralPath ([IO.Path]::Combine($packageRoot, "AppxBlockMap.xml")) + $file = @($blockMap.BlockMap.File) | Where-Object { ([string]$_.Name).Replace("/", "\") -ieq $relativePath.Replace("/", "\") } | Select-Object -First 1 + if ($null -eq $file) { return $false } + $path = [IO.Path]::Combine($packageRoot, $relativePath) + $stream = [IO.File]::OpenRead($path) + $sha = [Security.Cryptography.SHA256]::Create() + try { + if ([int64]$file.Size -ne $stream.Length) { return $false } + foreach ($block in @($file.Block)) { + $count = [int][Math]::Min(65536, $stream.Length - $stream.Position) + if ($count -le 0) { return $false } + $buffer = New-Object byte[] $count + $read = 0 + while ($read -lt $count) { $next = $stream.Read($buffer, $read, $count - $read); if ($next -le 0) { return $false }; $read += $next } + if ([Convert]::ToBase64String($sha.ComputeHash($buffer)) -cne [string]$block.Hash) { return $false } + } + return $stream.Position -eq $stream.Length + } finally { $sha.Dispose(); $stream.Dispose() } +} +$packageSig = Signature ([IO.Path]::Combine($root, "AppxSignature.p7x")) +$catalogSig = Signature ([IO.Path]::Combine($root, "AppxMetadata\CodeIntegrity.cat")) +$resourceSig = Signature ([IO.Path]::Combine($root, "app\resources\codex.exe")) +[pscustomobject]@{ + packageRoot = $root; entryPath = $entry; identityName = [string]$manifest.Package.Identity.Name; packageVersion = [string]$manifest.Package.Identity.Version; packagePublisher = [string]$manifest.Package.Identity.Publisher; appId = [string]$app.Id; entryRelativePath = [string]$app.Executable; entryPoint = [string]$app.EntryPoint; + packageSignatureStatus = $packageSig.status; packageSignerCommonName = $packageSig.signer; catalogSignatureStatus = $catalogSig.status; catalogSignerCommonName = $catalogSig.signer; entryBlockMapValid = Test-BlockMap $root ([string]$app.Executable); resourceSignatureStatus = $resourceSig.status; resourceSignerCommonName = $resourceSig.signer +} | ConvertTo-Json -Compress` diff --git a/host/go/internal/platform/windows/adapter_windows_test.go b/host/go/internal/platform/windows/adapter_windows_test.go new file mode 100644 index 0000000..1646fb0 --- /dev/null +++ b/host/go/internal/platform/windows/adapter_windows_test.go @@ -0,0 +1,79 @@ +//go:build windows + +package windows + +import ( + "context" + "encoding/json" + "errors" + "os" + "testing" +) + +type stubRunner struct { + contents []byte + err error +} + +func (runner stubRunner) Run(context.Context, string) ([]byte, error) { + return runner.contents, runner.err +} + +func validInstall() Install { + return Install{ + PackageRoot: `C:\Program Files\WindowsApps\OpenAI.Codex_26.721.3404.0_x64__2p2nqsd0c76g0`, + EntryPath: `C:\Program Files\WindowsApps\OpenAI.Codex_26.721.3404.0_x64__2p2nqsd0c76g0\app\ChatGPT.exe`, + IdentityName: expectedIdentity, PackageVersion: "26.721.3404.0", PackagePublisher: expectedPublisher, + AppID: "App", EntryRelativePath: expectedEntry, EntryPoint: "Windows.FullTrustApplication", + PackageSignatureStatus: "Valid", PackageSignerCommonName: expectedSigner, + CatalogSignatureStatus: "Valid", CatalogSignerCommonName: expectedSigner, + EntryBlockMapValid: true, ResourceSignatureStatus: "Valid", ResourceSignerCommonName: expectedResource, + } +} + +func TestInspectionAcceptsCompleteOfficialIdentity(t *testing.T) { + contents, err := json.Marshal(validInstall()) + if err != nil { + t.Fatal(err) + } + actual, err := inspect(context.Background(), stubRunner{contents: contents}) + if err != nil || actual.IdentityName != expectedIdentity { + t.Fatalf("install=%+v error=%v", actual, err) + } +} + +func TestInspectionRejectsSubstitutedPublisher(t *testing.T) { + value := validInstall() + value.PackagePublisher = "CN=Unknown" + contents, err := json.Marshal(value) + if err != nil { + t.Fatal(err) + } + _, err = inspect(context.Background(), stubRunner{contents: contents}) + var identityError Error + if !errors.As(err, &identityError) || identityError.Code != "CODEX_IDENTITY_INVALID" { + t.Fatalf("error=%v", err) + } +} + +func TestInspectionRejectsUnexpectedFields(t *testing.T) { + contents := []byte(`{"packageRoot":"x","unexpected":true}`) + _, err := inspect(context.Background(), stubRunner{contents: contents}) + var inspectionError Error + if !errors.As(err, &inspectionError) || inspectionError.Code != "PROCESS_INSPECTION_DENIED" { + t.Fatalf("error=%v", err) + } +} + +func TestLiveOfficialCodexInspection(t *testing.T) { + if os.Getenv("OPENCHATGPTSKIN_LIVE_WINDOWS_TEST") != "1" { + t.Skip("set OPENCHATGPTSKIN_LIVE_WINDOWS_TEST=1 on a prepared Windows device") + } + install, err := InspectOfficialCodex(context.Background()) + if err != nil { + t.Fatal(err) + } + if install.PackageVersion != "26.721.3404.0" { + t.Fatalf("unexpected installed Codex version: %s", install.PackageVersion) + } +} From 3af7097a7dfe4c2a79428cadccf8ff179ef5e5cc Mon Sep 17 00:00:00 2001 From: u2bo <285077078@qq.com> Date: Fri, 24 Jul 2026 22:55:55 +0800 Subject: [PATCH 13/23] feat: reject unmanaged Windows Codex roots --- .../platform/windows/adapter_windows.go | 64 +++++++++++++++++++ .../platform/windows/adapter_windows_test.go | 29 +++++++++ 2 files changed, 93 insertions(+) diff --git a/host/go/internal/platform/windows/adapter_windows.go b/host/go/internal/platform/windows/adapter_windows.go index 7f32f84..6580ef2 100644 --- a/host/go/internal/platform/windows/adapter_windows.go +++ b/host/go/internal/platform/windows/adapter_windows.go @@ -44,6 +44,13 @@ type Install struct { ResourceSignerCommonName string `json:"resourceSignerCommonName"` } +type ProcessIdentity struct { + PID int `json:"pid"` + ParentPID int `json:"parentPid"` + StartedAt string `json:"startedAt"` + ExecutablePath string `json:"executablePath"` +} + type Error struct { Code string Message string @@ -100,6 +107,46 @@ func inspect(ctx context.Context, runner PowerShellRunner) (Install, error) { return install, nil } +// ListCodexRoots returns only root ChatGPT.exe processes. Any result is an +// unmanaged instance until it was launched and recorded by this Host, so the +// caller must refuse rather than attach, modify, or terminate it. +func ListCodexRoots(ctx context.Context) ([]ProcessIdentity, error) { + inspectionContext, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + contents, err := defaultRunner{}.Run(inspectionContext, rootsScript) + if err != nil { + return nil, err + } + return parseRoots(contents) +} + +func parseRoots(contents []byte) ([]ProcessIdentity, error) { + var roots []ProcessIdentity + if len(bytes.TrimSpace(contents)) == 0 || string(bytes.TrimSpace(contents)) == "null" { + return []ProcessIdentity{}, nil + } + if err := json.Unmarshal(contents, &roots); err != nil { + return nil, Error{Code: "PROCESS_INSPECTION_DENIED", Message: "Windows process inspection returned invalid JSON"} + } + for _, root := range roots { + if root.PID < 1 || root.ParentPID < 0 || root.ExecutablePath == "" || !strings.HasSuffix(strings.ToLower(normalizePath(root.ExecutablePath)), "/"+strings.ToLower(expectedEntry)) || !validTimestamp(root.StartedAt) { + return nil, Error{Code: "PROCESS_INSPECTION_DENIED", Message: "Windows process inspection returned an invalid root"} + } + } + return roots, nil +} + +func RefuseUnmanagedCodex(ctx context.Context) error { + roots, err := ListCodexRoots(ctx) + if err != nil { + return err + } + if len(roots) > 0 { + return Error{Code: "CODEX_ALREADY_RUNNING_UNMANAGED", Message: "A normal Codex instance is already running"} + } + return nil +} + func verify(install Install) error { valid := install.PackageRoot != "" && install.EntryPath != "" && install.IdentityName == expectedIdentity && versionPattern.MatchString(install.PackageVersion) && @@ -120,6 +167,11 @@ func normalizePath(path string) string { return strings.TrimRight(strings.ReplaceAll(path, "\\", "/"), "/") } +func validTimestamp(value string) bool { + _, err := time.Parse(time.RFC3339Nano, value) + return err == nil +} + const inspectionScript = `$ErrorActionPreference = "Stop" $packages = @(Get-AppxPackage -Name "OpenAI.Codex" -ErrorAction SilentlyContinue) if ($packages.Count -ne 1) { throw "Expected exactly one OpenAI.Codex Appx registration" } @@ -161,3 +213,15 @@ $resourceSig = Signature ([IO.Path]::Combine($root, "app\resources\codex.exe")) packageRoot = $root; entryPath = $entry; identityName = [string]$manifest.Package.Identity.Name; packageVersion = [string]$manifest.Package.Identity.Version; packagePublisher = [string]$manifest.Package.Identity.Publisher; appId = [string]$app.Id; entryRelativePath = [string]$app.Executable; entryPoint = [string]$app.EntryPoint; packageSignatureStatus = $packageSig.status; packageSignerCommonName = $packageSig.signer; catalogSignatureStatus = $catalogSig.status; catalogSignerCommonName = $catalogSig.signer; entryBlockMapValid = Test-BlockMap $root ([string]$app.Executable); resourceSignatureStatus = $resourceSig.status; resourceSignerCommonName = $resourceSig.signer } | ConvertTo-Json -Compress` + +const rootsScript = `$ErrorActionPreference = "Stop" +$all = @(Get-CimInstance Win32_Process -Filter "Name='ChatGPT.exe'" | ForEach-Object { + [pscustomobject]@{ pid = [int]$_.ProcessId; parentPid = [int]$_.ParentProcessId; executablePath = [string]$_.ExecutablePath } +}) +$ids = @{} +foreach ($entry in $all) { $ids[[int]$entry.pid] = $true } +$roots = @($all | Where-Object { -not $ids.ContainsKey([int]$_.parentPid) } | ForEach-Object { + $process = Get-Process -Id ([int]$_.pid) -ErrorAction Stop + [pscustomobject]@{ pid = [int]$_.pid; parentPid = [int]$_.parentPid; startedAt = $process.StartTime.ToUniversalTime().ToString("o"); executablePath = [string]$_.executablePath } +}) +ConvertTo-Json -InputObject @($roots) -Compress` diff --git a/host/go/internal/platform/windows/adapter_windows_test.go b/host/go/internal/platform/windows/adapter_windows_test.go index 1646fb0..897a0ca 100644 --- a/host/go/internal/platform/windows/adapter_windows_test.go +++ b/host/go/internal/platform/windows/adapter_windows_test.go @@ -65,6 +65,19 @@ func TestInspectionRejectsUnexpectedFields(t *testing.T) { } } +func TestRefuseUnmanagedCodexUsesTheRootProcessBoundary(t *testing.T) { + root := ProcessIdentity{PID: 101, ParentPID: 1, StartedAt: "2026-07-24T00:00:00Z", ExecutablePath: validInstall().EntryPath} + contents, err := json.Marshal([]ProcessIdentity{root}) + if err != nil { + t.Fatal(err) + } + // The public list boundary rejects malformed identities; launch code can use + // only a verified root rather than an arbitrary child ChatGPT process. + if _, err := parseRoots(contents); err != nil { + t.Fatal(err) + } +} + func TestLiveOfficialCodexInspection(t *testing.T) { if os.Getenv("OPENCHATGPTSKIN_LIVE_WINDOWS_TEST") != "1" { t.Skip("set OPENCHATGPTSKIN_LIVE_WINDOWS_TEST=1 on a prepared Windows device") @@ -77,3 +90,19 @@ func TestLiveOfficialCodexInspection(t *testing.T) { t.Fatalf("unexpected installed Codex version: %s", install.PackageVersion) } } + +func TestLiveClosedCodexHasNoUnmanagedRoot(t *testing.T) { + if os.Getenv("OPENCHATGPTSKIN_LIVE_WINDOWS_TEST") != "1" { + t.Skip("set OPENCHATGPTSKIN_LIVE_WINDOWS_TEST=1 on a prepared Windows device") + } + roots, err := ListCodexRoots(context.Background()) + if err != nil { + t.Fatal(err) + } + if len(roots) != 0 { + t.Fatalf("expected no normal Codex root after exit, found %+v", roots) + } + if err := RefuseUnmanagedCodex(context.Background()); err != nil { + t.Fatal(err) + } +} From 6edc3e68d12a382b9668f20187dea17d469a41ed Mon Sep 17 00:00:00 2001 From: u2bo <285077078@qq.com> Date: Sat, 25 Jul 2026 10:09:06 +0800 Subject: [PATCH 14/23] feat: complete go controller cdp bridge --- host/go/internal/cdp/adapter.go | 194 +++++++ host/go/internal/cdp/client.go | 488 ++++++++++++++++++ host/go/internal/cdp/client_test.go | 266 ++++++++++ .../cdp/generated/adapter-manifest.json | 7 + package-lock.json | 1 + package.json | 2 + packages/cdp-adapter/src/scripts.ts | 8 +- scripts/build-go-cdp-adapter.ts | 36 ++ scripts/go-cdp-adapter-entry.ts | 173 +++++++ 9 files changed, 1171 insertions(+), 4 deletions(-) create mode 100644 host/go/internal/cdp/adapter.go create mode 100644 host/go/internal/cdp/client.go create mode 100644 host/go/internal/cdp/client_test.go create mode 100644 host/go/internal/cdp/generated/adapter-manifest.json create mode 100644 scripts/build-go-cdp-adapter.ts create mode 100644 scripts/go-cdp-adapter-entry.ts diff --git a/host/go/internal/cdp/adapter.go b/host/go/internal/cdp/adapter.go new file mode 100644 index 0000000..37b2621 --- /dev/null +++ b/host/go/internal/cdp/adapter.go @@ -0,0 +1,194 @@ +package cdp + +import ( + "bytes" + "context" + "crypto/sha256" + _ "embed" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "io" + "sort" +) + +const adapterAssetChunkBytes = 192 * 1024 + +//go:embed generated/adapter-manifest.json +var embeddedAdapterManifest []byte + +type adapterArtifact struct { + SchemaVersion int `json:"schemaVersion"` + AdapterID string `json:"adapterId"` + ProbeExpression string `json:"probeExpression"` + Source string `json:"source"` + SHA256 string `json:"sha256"` +} + +func loadAdapterArtifact() (adapterArtifact, error) { + var artifact adapterArtifact + decoder := json.NewDecoder(bytes.NewReader(embeddedAdapterManifest)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&artifact); err != nil { + return adapterArtifact{}, Error{Code: "ADAPTER_INCOMPATIBLE", Message: "Embedded CDP Adapter manifest is invalid"} + } + if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { + return adapterArtifact{}, Error{Code: "ADAPTER_INCOMPATIBLE", Message: "Embedded CDP Adapter manifest has trailing data"} + } + digest := sha256.Sum256([]byte(artifact.AdapterID + "\n" + artifact.ProbeExpression + "\n" + artifact.Source)) + if artifact.SchemaVersion != 1 || artifact.AdapterID == "" || artifact.ProbeExpression == "" || artifact.Source == "" || len(artifact.Source) > maxMessageBytes/2 || len(artifact.SHA256) != 64 || artifact.SHA256 != hex.EncodeToString(digest[:]) { + return adapterArtifact{}, Error{Code: "ADAPTER_INCOMPATIBLE", Message: "Embedded CDP Adapter hash is invalid"} + } + return artifact, nil +} + +// ThemePayload is raw, already repository-validated Schema v4 data. Go only +// transfers it; compilation, injection, verification and cleanup remain in the +// reviewed TypeScript Adapter browser artifact. +type ThemePayload struct { + Document json.RawMessage + Files map[string][]byte + TotalBytes int +} + +func (connection *Connection) BootstrapAdapter(ctx context.Context) error { + artifact, err := loadAdapterArtifact() + if err != nil { + return err + } + return connection.expectAdapterTrue(ctx, artifact.Source) +} + +func (connection *Connection) ApplyTheme(ctx context.Context, payload ThemePayload) error { + if !json.Valid(payload.Document) || len(payload.Document) == 0 || payload.TotalBytes < 1 || payload.TotalBytes > 32*1024*1024 { + return Error{Code: "THEME_APPLY_FAILED", Message: "Theme payload is invalid"} + } + if err := connection.BootstrapAdapter(ctx); err != nil { + return err + } + if err := connection.expectAdapterMethodTrue(ctx, "begin", json.RawMessage(payload.Document), payload.TotalBytes); err != nil { + return err + } + paths := make([]string, 0, len(payload.Files)) + for path := range payload.Files { + if !safeThemeAssetPath(path) { + return Error{Code: "THEME_APPLY_FAILED", Message: "Theme asset path is invalid"} + } + paths = append(paths, path) + } + sort.Strings(paths) + for _, path := range paths { + encoded := base64.StdEncoding.EncodeToString(payload.Files[path]) + for len(encoded) > 0 { + end := adapterAssetChunkBytes + if end > len(encoded) { + end = len(encoded) + } + if err := connection.expectAdapterMethodTrue(ctx, "append", path, encoded[:end]); err != nil { + return err + } + encoded = encoded[end:] + } + } + if err := connection.expectAdapterMethodTrue(ctx, "prepareApply"); err != nil { + return err + } + preflight, err := connection.evaluateAdapterSource(ctx, "preflight") + if err != nil { + return err + } + if err := connection.expectAdapterMethodTrue(ctx, "validatePreflight", preflight); err != nil { + return Error{Code: "THEME_APPLY_FAILED", Message: "Theme preflight validation failed"} + } + applySource, err := connection.adapterSource(ctx, "apply") + if err != nil { + return err + } + if err := connection.expectAdapterTrue(ctx, applySource); err != nil { + return err + } + verification, err := connection.evaluateAdapterSource(ctx, "verify") + if err != nil { + return err + } + if err := connection.expectAdapterMethodTrue(ctx, "validateVerification", verification); err != nil { + return Error{Code: "THEME_APPLY_FAILED", Message: "Theme verification failed"} + } + return nil +} + +func (connection *Connection) RestoreTheme(ctx context.Context) error { + if err := connection.BootstrapAdapter(ctx); err != nil { + return err + } + removed, err := connection.evaluateAdapterSource(ctx, "remove") + if err != nil { + return err + } + official, err := connection.evaluateAdapterSource(ctx, "verifyOfficial") + if err != nil { + return err + } + if err := connection.expectAdapterMethodTrue(ctx, "validateRestore", removed, official); err != nil { + return Error{Code: "THEME_CLEANUP_FAILED", Message: "Official appearance verification failed"} + } + return nil +} + +func (connection *Connection) evaluateAdapterSource(ctx context.Context, name string) (json.RawMessage, error) { + source, err := connection.adapterSource(ctx, name) + if err != nil { + return nil, err + } + return connection.Evaluate(ctx, source) +} + +func (connection *Connection) adapterSource(ctx context.Context, name string) (string, error) { + value, err := connection.Evaluate(ctx, `globalThis.__openChatGPTSkinAdapter.source(`+jsonString(name)+`)`) + if err != nil { + return "", err + } + var source string + if json.Unmarshal(value, &source) != nil || source == "" || len(source) > maxMessageBytes/2 { + return "", Error{Code: "ADAPTER_INCOMPATIBLE", Message: "CDP Adapter expression is invalid"} + } + return source, nil +} + +func (connection *Connection) expectAdapterMethodTrue(ctx context.Context, method string, arguments ...any) error { + encoded, err := json.Marshal(arguments) + if err != nil { + return err + } + return connection.expectAdapterTrue(ctx, `globalThis.__openChatGPTSkinAdapter[`+jsonString(method)+`].apply(null,`+string(encoded)+`)`) +} + +func (connection *Connection) expectAdapterTrue(ctx context.Context, expression string) error { + value, err := connection.Evaluate(ctx, expression) + if err != nil { + return err + } + var applied bool + if err := json.Unmarshal(value, &applied); err != nil || !applied { + return Error{Code: "THEME_APPLY_FAILED", Message: "CDP Adapter did not confirm the requested theme operation"} + } + return nil +} + +func jsonString(value string) string { + encoded, _ := json.Marshal(value) + return string(encoded) +} + +func safeThemeAssetPath(value string) bool { + if len(value) < 1 || len(value) > 241 || value[0] == '/' || bytes.Contains([]byte(value), []byte("\\")) { + return false + } + for _, part := range bytes.Split([]byte(value), []byte("/")) { + if len(part) == 0 || bytes.Equal(part, []byte(".")) || bytes.Equal(part, []byte("..")) { + return false + } + } + return true +} diff --git a/host/go/internal/cdp/client.go b/host/go/internal/cdp/client.go new file mode 100644 index 0000000..eee1ea5 --- /dev/null +++ b/host/go/internal/cdp/client.go @@ -0,0 +1,488 @@ +// Package cdp provides the bounded, loopback-only transport used by Runtime. +// It deliberately does not contain ChatGPT DOM or theme logic. +package cdp + +import ( + "bufio" + "context" + "crypto/rand" + "crypto/sha1" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/url" + "strconv" + "strings" + "sync" + "time" +) + +const ( + maxDiscoveryBytes = 1 << 20 + maxMessageBytes = 1 << 20 + requestTimeout = 5 * time.Second +) + +type Error struct { + Code string + Message string +} + +func (err Error) Error() string { return err.Message } + +type Endpoint struct { + Host string + Port int +} + +type Target struct { + ID string `json:"id"` + Type string `json:"type"` + Title string `json:"title"` + URL string `json:"url"` + WebSocketDebuggerURL string `json:"webSocketDebuggerUrl"` +} + +func (endpoint Endpoint) validate() error { + if endpoint.Host != "127.0.0.1" || endpoint.Port < 1 || endpoint.Port > 65535 { + return Error{Code: "CDP_ENDPOINT_UNSAFE", Message: "CDP endpoint must be an IPv4 loopback port"} + } + return nil +} + +func (endpoint Endpoint) address() string { + return net.JoinHostPort(endpoint.Host, strconv.Itoa(endpoint.Port)) +} + +func (endpoint Endpoint) httpClient() *http.Client { + dialer := &net.Dialer{Timeout: requestTimeout} + transport := &http.Transport{ + Proxy: nil, + DialContext: func(ctx context.Context, network, address string) (net.Conn, error) { + if address != endpoint.address() { + return nil, Error{Code: "CDP_ENDPOINT_UNSAFE", Message: "CDP request attempted to leave its verified endpoint"} + } + return dialer.DialContext(ctx, "tcp4", address) + }, + ForceAttemptHTTP2: false, + } + return &http.Client{ + Transport: transport, + Timeout: requestTimeout, + CheckRedirect: func(*http.Request, []*http.Request) error { + return Error{Code: "CDP_ENDPOINT_UNSAFE", Message: "CDP discovery redirects are forbidden"} + }, + } +} + +func Discover(ctx context.Context, endpoint Endpoint) ([]Target, error) { + if err := endpoint.validate(); err != nil { + return nil, err + } + request, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://"+endpoint.address()+"/json/list", nil) + if err != nil { + return nil, err + } + response, err := endpoint.httpClient().Do(request) + if err != nil { + if typed, ok := err.(Error); ok { + return nil, typed + } + return nil, Error{Code: "CDP_NOT_READY", Message: "CDP discovery is unavailable"} + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + return nil, Error{Code: "CDP_NOT_READY", Message: fmt.Sprintf("CDP discovery returned HTTP %d", response.StatusCode)} + } + if response.ContentLength > maxDiscoveryBytes { + return nil, Error{Code: "CDP_ENDPOINT_UNSAFE", Message: "CDP discovery response exceeds its limit"} + } + contents, err := io.ReadAll(io.LimitReader(response.Body, maxDiscoveryBytes+1)) + if err != nil { + return nil, Error{Code: "CDP_NOT_READY", Message: "CDP discovery could not be read"} + } + if len(contents) > maxDiscoveryBytes { + return nil, Error{Code: "CDP_ENDPOINT_UNSAFE", Message: "CDP discovery response exceeds its limit"} + } + var rawTargets []json.RawMessage + decoder := json.NewDecoder(strings.NewReader(string(contents))) + if err := decoder.Decode(&rawTargets); err != nil { + return nil, Error{Code: "CDP_NOT_READY", Message: "CDP target list is invalid"} + } + if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { + return nil, Error{Code: "CDP_NOT_READY", Message: "CDP target list has trailing data"} + } + targets := make([]Target, 0, len(rawTargets)) + for _, rawTarget := range rawTargets { + target, err := decodeTarget(rawTarget) + if err != nil { + return nil, err + } + if err := validateTarget(target, endpoint); err != nil { + return nil, err + } + targets = append(targets, target) + } + return targets, nil +} + +func decodeTarget(contents json.RawMessage) (Target, error) { + var fields map[string]json.RawMessage + if err := json.Unmarshal(contents, &fields); err != nil { + return Target{}, Error{Code: "CDP_NOT_READY", Message: "CDP target is not an object"} + } + value := Target{} + for name, target := range map[string]*string{ + "id": &value.ID, "type": &value.Type, "title": &value.Title, "url": &value.URL, + "webSocketDebuggerUrl": &value.WebSocketDebuggerURL, + } { + contents, present := fields[name] + if !present || json.Unmarshal(contents, target) != nil || *target == "" { + return Target{}, Error{Code: "CDP_NOT_READY", Message: "CDP target has an invalid field"} + } + } + return value, nil +} + +func SelectCodexTarget(targets []Target) (Target, error) { + candidates := make([]Target, 0, 1) + for _, target := range targets { + if target.Type == "page" && isAllowedCodexURL(target.URL) { + candidates = append(candidates, target) + } + } + switch len(candidates) { + case 1: + return candidates[0], nil + case 0: + return Target{}, Error{Code: "CDP_TARGET_NOT_FOUND", Message: "No compatible Codex page target exists"} + default: + return Target{}, Error{Code: "CDP_TARGET_AMBIGUOUS", Message: "Multiple compatible Codex page targets exist"} + } +} + +// SelectCompatibleCodexTarget executes the reviewed, generated Adapter probe +// in every otherwise eligible page. This avoids guessing from transient route +// names and keeps all DOM knowledge in the TypeScript Adapter artifact. +func SelectCompatibleCodexTarget(ctx context.Context, endpoint Endpoint, targets []Target) (Target, error) { + artifact, err := loadAdapterArtifact() + if err != nil { + return Target{}, err + } + candidates := make([]Target, 0, 1) + for _, target := range targets { + if target.Type != "page" || !isAllowedCodexURL(target.URL) { + continue + } + connection, connectErr := Connect(ctx, endpoint, target) + if connectErr != nil { + var cdpError Error + if errors.As(connectErr, &cdpError) && cdpError.Code == "CDP_ENDPOINT_UNSAFE" { + return Target{}, connectErr + } + continue + } + result, evaluateErr := connection.Evaluate(ctx, artifact.ProbeExpression) + closeErr := connection.Close() + if evaluateErr != nil || closeErr != nil { + continue + } + var probe map[string]bool + if err := json.Unmarshal(result, &probe); err != nil || !probe["main"] || !probe["navigation"] || !probe["composer"] { + continue + } + candidates = append(candidates, target) + } + switch len(candidates) { + case 1: + return candidates[0], nil + case 0: + return Target{}, Error{Code: "ADAPTER_INCOMPATIBLE", Message: "No compatible Codex page target exists"} + default: + return Target{}, Error{Code: "CDP_TARGET_AMBIGUOUS", Message: "Multiple compatible Codex page targets exist"} + } +} + +func isAllowedCodexURL(value string) bool { + parsed, err := url.Parse(value) + if err != nil { + return false + } + return parsed.Scheme == "app" || parsed.Scheme == "https" && parsed.Hostname() == "chatgpt.com" && (parsed.Path == "/codex" || strings.HasPrefix(parsed.Path, "/codex/")) +} + +func validateTarget(target Target, endpoint Endpoint) error { + if target.ID == "" || target.Type == "" || target.Title == "" || target.URL == "" || target.WebSocketDebuggerURL == "" { + return Error{Code: "CDP_NOT_READY", Message: "CDP target has an invalid field"} + } + parsed, err := url.Parse(target.WebSocketDebuggerURL) + if err != nil || parsed.Scheme != "ws" || parsed.Hostname() != endpoint.Host || parsed.Port() != strconv.Itoa(endpoint.Port) || parsed.User != nil || parsed.Fragment != "" { + return Error{Code: "CDP_ENDPOINT_UNSAFE", Message: "CDP WebSocket URL is not same-port loopback"} + } + return nil +} + +type Connection struct { + connection net.Conn + reader *bufio.Reader + writeMu sync.Mutex + sequence int64 +} + +func Connect(ctx context.Context, endpoint Endpoint, target Target) (*Connection, error) { + if err := endpoint.validate(); err != nil { + return nil, err + } + if err := validateTarget(target, endpoint); err != nil { + return nil, err + } + parsed, _ := url.Parse(target.WebSocketDebuggerURL) + dialer := net.Dialer{Timeout: requestTimeout} + connection, err := dialer.DialContext(ctx, "tcp4", endpoint.address()) + if err != nil { + return nil, Error{Code: "CDP_NOT_READY", Message: "CDP WebSocket connection failed"} + } + closeOnError := true + defer func() { + if closeOnError { + _ = connection.Close() + } + }() + keyBytes := make([]byte, 16) + if _, err := rand.Read(keyBytes); err != nil { + return nil, err + } + key := base64.StdEncoding.EncodeToString(keyBytes) + request := strings.Join([]string{ + "GET " + requestURI(parsed) + " HTTP/1.1", + "Host: " + endpoint.address(), + "Upgrade: websocket", + "Connection: Upgrade", + "Sec-WebSocket-Key: " + key, + "Sec-WebSocket-Version: 13", + "", + "", + }, "\r\n") + if err := connection.SetDeadline(time.Now().Add(requestTimeout)); err != nil { + return nil, err + } + if _, err := io.WriteString(connection, request); err != nil { + return nil, Error{Code: "CDP_NOT_READY", Message: "CDP WebSocket handshake failed"} + } + reader := bufio.NewReaderSize(connection, maxMessageBytes+4096) + response, err := http.ReadResponse(reader, &http.Request{Method: http.MethodGet}) + if err != nil { + return nil, Error{Code: "CDP_NOT_READY", Message: "CDP WebSocket handshake is invalid"} + } + if response.Body != nil { + _ = response.Body.Close() + } + accept := sha1.Sum([]byte(key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11")) + if response.StatusCode != http.StatusSwitchingProtocols || !strings.EqualFold(response.Header.Get("Upgrade"), "websocket") || !strings.Contains(strings.ToLower(response.Header.Get("Connection")), "upgrade") || response.Header.Get("Sec-WebSocket-Accept") != base64.StdEncoding.EncodeToString(accept[:]) { + return nil, Error{Code: "CDP_NOT_READY", Message: "CDP WebSocket upgrade was rejected"} + } + if err := connection.SetDeadline(time.Time{}); err != nil { + return nil, err + } + closeOnError = false + return &Connection{connection: connection, reader: reader}, nil +} + +func requestURI(value *url.URL) string { + path := value.EscapedPath() + if path == "" { + path = "/" + } + if value.RawQuery != "" { + path += "?" + value.RawQuery + } + return path +} + +func (connection *Connection) Close() error { return connection.connection.Close() } + +func (connection *Connection) Evaluate(ctx context.Context, expression string) (json.RawMessage, error) { + if expression == "" || len(expression) > maxMessageBytes/2 { + return nil, Error{Code: "CDP_ENDPOINT_UNSAFE", Message: "CDP evaluation expression is invalid"} + } + connection.writeMu.Lock() + defer connection.writeMu.Unlock() + connection.sequence++ + id := connection.sequence + payload, err := json.Marshal(map[string]any{ + "id": id, + "method": "Runtime.evaluate", + "params": map[string]any{"expression": expression, "returnByValue": true, "awaitPromise": true}, + }) + if err != nil { + return nil, err + } + if err := connection.writeText(ctx, payload); err != nil { + return nil, err + } + for { + contents, err := connection.readText(ctx) + if err != nil { + return nil, err + } + var response struct { + ID int64 `json:"id"` + Error json.RawMessage `json:"error"` + Result struct { + ExceptionDetails json.RawMessage `json:"exceptionDetails"` + Result struct { + Value json.RawMessage `json:"value"` + } `json:"result"` + } `json:"result"` + } + if err := json.Unmarshal(contents, &response); err != nil { + return nil, Error{Code: "CDP_NOT_READY", Message: "CDP response is invalid"} + } + if response.ID != id { + continue + } + if len(response.Error) != 0 || len(response.Result.ExceptionDetails) != 0 || len(response.Result.Result.Value) == 0 { + return nil, Error{Code: "ADAPTER_INCOMPATIBLE", Message: "CDP evaluation failed"} + } + return append(json.RawMessage(nil), response.Result.Result.Value...), nil + } +} + +func (connection *Connection) writeText(ctx context.Context, payload []byte) error { + if len(payload) > maxMessageBytes { + return Error{Code: "CDP_ENDPOINT_UNSAFE", Message: "CDP message exceeds its limit"} + } + if err := connection.connection.SetWriteDeadline(deadline(ctx)); err != nil { + return err + } + defer connection.connection.SetWriteDeadline(time.Time{}) + return writeFrame(connection.connection, 0x1, payload) +} + +func (connection *Connection) readText(ctx context.Context) ([]byte, error) { + if err := connection.connection.SetReadDeadline(deadline(ctx)); err != nil { + return nil, err + } + defer connection.connection.SetReadDeadline(time.Time{}) + for { + opcode, payload, err := readFrame(connection.reader) + if err != nil { + return nil, Error{Code: "CDP_NOT_READY", Message: "CDP WebSocket is closed"} + } + switch opcode { + case 0x1: + return payload, nil + case 0x8: + return nil, Error{Code: "CDP_NOT_READY", Message: "CDP WebSocket closed"} + case 0x9: + if err := writeFrame(connection.connection, 0xA, payload); err != nil { + return nil, Error{Code: "CDP_NOT_READY", Message: "CDP WebSocket ping response failed"} + } + case 0xA: + continue + default: + return nil, Error{Code: "CDP_ENDPOINT_UNSAFE", Message: "CDP WebSocket frame type is unsupported"} + } + } +} + +func deadline(ctx context.Context) time.Time { + if value, ok := ctx.Deadline(); ok { + return value + } + return time.Now().Add(requestTimeout) +} + +func writeFrame(writer io.Writer, opcode byte, payload []byte) error { + if len(payload) > maxMessageBytes { + return errors.New("frame exceeds its limit") + } + if _, err := writer.Write([]byte{0x80 | opcode}); err != nil { + return err + } + mask := make([]byte, 4) + if _, err := rand.Read(mask); err != nil { + return err + } + length := len(payload) + switch { + case length <= 125: + if _, err := writer.Write([]byte{0x80 | byte(length)}); err != nil { + return err + } + case length <= 65535: + if _, err := writer.Write([]byte{0x80 | 126, byte(length >> 8), byte(length)}); err != nil { + return err + } + default: + encoded := uint64(length) + header := []byte{0x80 | 127, 0, 0, 0, 0, 0, 0, 0, 0} + for index := len(header) - 1; index >= 1; index-- { + header[index] = byte(encoded) + encoded >>= 8 + } + if _, err := writer.Write(header); err != nil { + return err + } + } + if _, err := writer.Write(mask); err != nil { + return err + } + masked := append([]byte(nil), payload...) + for index := range masked { + masked[index] ^= mask[index%len(mask)] + } + _, err := writer.Write(masked) + return err +} + +func readFrame(reader *bufio.Reader) (byte, []byte, error) { + first, err := reader.ReadByte() + if err != nil { + return 0, nil, err + } + second, err := reader.ReadByte() + if err != nil { + return 0, nil, err + } + if first&0x80 == 0 || second&0x80 != 0 { + return 0, nil, errors.New("fragmented or masked server frame") + } + length := int(second & 0x7f) + if length == 126 { + left, err := reader.ReadByte() + if err != nil { + return 0, nil, err + } + right, err := reader.ReadByte() + if err != nil { + return 0, nil, err + } + length = int(left)<<8 | int(right) + } else if length == 127 { + var encoded uint64 + for index := 0; index < 8; index++ { + value, err := reader.ReadByte() + if err != nil { + return 0, nil, err + } + encoded = encoded<<8 | uint64(value) + } + if encoded > maxMessageBytes { + return 0, nil, errors.New("server frame exceeds its limit") + } + length = int(encoded) + } + if length > maxMessageBytes { + return 0, nil, errors.New("server frame exceeds its limit") + } + payload := make([]byte, length) + if _, err := io.ReadFull(reader, payload); err != nil { + return 0, nil, err + } + return first & 0x0f, payload, nil +} diff --git a/host/go/internal/cdp/client_test.go b/host/go/internal/cdp/client_test.go new file mode 100644 index 0000000..0bb6596 --- /dev/null +++ b/host/go/internal/cdp/client_test.go @@ -0,0 +1,266 @@ +package cdp + +import ( + "bufio" + "context" + "crypto/sha1" + "encoding/base64" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "net/url" + "os" + "strconv" + "strings" + "testing" +) + +func testEndpoint(t *testing.T, rawURL string) Endpoint { + t.Helper() + parsed, err := url.Parse(rawURL) + if err != nil { + t.Fatal(err) + } + port, err := strconv.Atoi(parsed.Port()) + if err != nil { + t.Fatal(err) + } + return Endpoint{Host: parsed.Hostname(), Port: port} +} + +func TestDiscoverRejectsCrossPortWebSocketTarget(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + _, _ = response.Write([]byte(`[{"id":"page","type":"page","title":"Codex","url":"app://-/codex","webSocketDebuggerUrl":"ws://127.0.0.1:9222/devtools/page/page","browserContextId":"newer-chromium-field"}]`)) + })) + defer server.Close() + _, err := Discover(context.Background(), testEndpoint(t, server.URL)) + var cdpError Error + if !errorsAs(err, &cdpError) || cdpError.Code != "CDP_ENDPOINT_UNSAFE" { + t.Fatalf("error=%v", err) + } +} + +func TestSelectCodexTargetRejectsAmbiguousPages(t *testing.T) { + _, err := SelectCodexTarget([]Target{ + {Type: "page", URL: "app://-/codex"}, + {Type: "page", URL: "https://chatgpt.com/codex"}, + }) + var cdpError Error + if !errorsAs(err, &cdpError) || cdpError.Code != "CDP_TARGET_AMBIGUOUS" { + t.Fatalf("error=%v", err) + } +} + +func TestConnectionEvaluatesOnlyOverVerifiedLoopbackSocket(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + if request.URL.Path == "/json/list" { + endpoint := testEndpoint(t, "http://"+request.Host) + _ = json.NewEncoder(response).Encode([]Target{{ + ID: "page", Type: "page", Title: "Codex", URL: "app://-/codex", + WebSocketDebuggerURL: "ws://" + endpoint.address() + "/devtools/page/page", + }}) + return + } + if request.URL.Path != "/devtools/page/page" { + http.NotFound(response, request) + return + } + connection, buffer, err := response.(http.Hijacker).Hijack() + if err != nil { + t.Error(err) + return + } + defer connection.Close() + key := request.Header.Get("Sec-WebSocket-Key") + digest := sha1.Sum([]byte(key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11")) + _, _ = io.WriteString(buffer, "HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: "+base64.StdEncoding.EncodeToString(digest[:])+"\r\n\r\n") + if err := buffer.Flush(); err != nil { + t.Error(err) + return + } + for { + payload, readErr := readClientText(buffer) + if readErr != nil { + return + } + var message struct { + ID int64 `json:"id"` + Method string `json:"method"` + Params struct { + Expression string `json:"expression"` + } `json:"params"` + } + if err := json.Unmarshal(payload, &message); err != nil || message.Method != "Runtime.evaluate" { + t.Errorf("message=%s error=%v", payload, err) + return + } + value := `{"main":true,"navigation":true,"composer":true}` + if strings.Contains(message.Params.Expression, "__openChatGPTSkinAdapter.source(") { + value = `"true"` + } else if message.Params.Expression == "true" || len(payload) > 64*1024 || strings.Contains(message.Params.Expression, "__openChatGPTSkinAdapter") { + value = "true" + } + response := []byte(`{"id":` + strconv.FormatInt(message.ID, 10) + `,"result":{"result":{"value":` + value + `}}}`) + if err := writeServerText(buffer, response); err != nil { + t.Error(err) + return + } + } + })) + defer server.Close() + endpoint := testEndpoint(t, server.URL) + targets, err := Discover(context.Background(), endpoint) + if err != nil { + t.Fatal(err) + } + target, err := SelectCompatibleCodexTarget(context.Background(), endpoint, targets) + if err != nil { + t.Fatal(err) + } + connection, err := Connect(context.Background(), endpoint, target) + if err != nil { + t.Fatal(err) + } + defer connection.Close() + if err := connection.BootstrapAdapter(context.Background()); err != nil { + t.Fatal(err) + } + value, err := connection.Evaluate(context.Background(), "window.location.href") + if err != nil || string(value) != `{"main":true,"navigation":true,"composer":true}` { + t.Fatalf("value=%s error=%v", value, err) + } + compatible, err := SelectCompatibleCodexTarget(context.Background(), endpoint, targets) + if err != nil || compatible.ID != target.ID { + t.Fatalf("compatible=%+v error=%v", compatible, err) + } + if err := connection.ApplyTheme(context.Background(), ThemePayload{ + Document: json.RawMessage(`{"schemaVersion":4,"id":"mountain-mist","version":"1.3.0"}`), + Files: map[string][]byte{"assets/background.webp": []byte("fixture")}, + TotalBytes: 64, + }); err != nil { + t.Fatal(err) + } + if err := connection.RestoreTheme(context.Background()); err != nil { + t.Fatal(err) + } +} + +func readClientText(reader *bufio.ReadWriter) ([]byte, error) { + first, err := reader.ReadByte() + if err != nil { + return nil, err + } + second, err := reader.ReadByte() + if err != nil { + return nil, err + } + if first != 0x81 || second&0x80 == 0 { + return nil, io.ErrUnexpectedEOF + } + length := int(second & 0x7f) + if length == 126 { + left, leftErr := reader.ReadByte() + right, rightErr := reader.ReadByte() + if leftErr != nil || rightErr != nil { + return nil, io.ErrUnexpectedEOF + } + length = int(left)<<8 | int(right) + } else if length == 127 { + var encoded uint64 + for index := 0; index < 8; index++ { + value, readErr := reader.ReadByte() + if readErr != nil { + return nil, readErr + } + encoded = encoded<<8 | uint64(value) + } + if encoded > maxMessageBytes { + return nil, io.ErrShortBuffer + } + length = int(encoded) + } + mask := make([]byte, 4) + if _, err := io.ReadFull(reader, mask); err != nil { + return nil, err + } + payload := make([]byte, length) + if _, err := io.ReadFull(reader, payload); err != nil { + return nil, err + } + for index := range payload { + payload[index] ^= mask[index%len(mask)] + } + return payload, nil +} + +func writeServerText(writer *bufio.ReadWriter, payload []byte) error { + if len(payload) > 65535 { + return io.ErrShortBuffer + } + if _, err := writer.Write([]byte{0x81}); err != nil { + return err + } + if len(payload) <= 125 { + if _, err := writer.Write([]byte{byte(len(payload))}); err != nil { + return err + } + } else if _, err := writer.Write([]byte{126, byte(len(payload) >> 8), byte(len(payload))}); err != nil { + return err + } + if _, err := writer.Write(payload); err != nil { + return err + } + return writer.Flush() +} + +func errorsAs(err error, target *Error) bool { + if err == nil { + return false + } + value, ok := err.(Error) + if !ok { + return false + } + *target = value + return true +} + +func TestEndpointRejectsNonLoopback(t *testing.T) { + err := Endpoint{Host: "localhost", Port: 9222}.validate() + if err == nil || !strings.Contains(err.Error(), "IPv4 loopback") { + t.Fatalf("error=%v", err) + } +} + +func TestLiveOfficialCodexCDPProbe(t *testing.T) { + if os.Getenv("OPENCHATGPTSKIN_LIVE_WINDOWS_TEST") != "1" { + t.Skip("set OPENCHATGPTSKIN_LIVE_WINDOWS_TEST=1 on a prepared Windows device") + } + port, err := strconv.Atoi(os.Getenv("OPENCHATGPTSKIN_CDP_PORT")) + if err != nil || port < 1 || port > 65535 { + t.Fatal("OPENCHATGPTSKIN_CDP_PORT must be a valid managed loopback port") + } + endpoint := Endpoint{Host: "127.0.0.1", Port: port} + targets, err := Discover(context.Background(), endpoint) + if err != nil { + t.Fatal(err) + } + target, err := SelectCompatibleCodexTarget(context.Background(), endpoint, targets) + if err != nil { + t.Fatal(err) + } + connection, err := Connect(context.Background(), endpoint, target) + if err != nil { + t.Fatal(err) + } + defer connection.Close() + value, err := connection.Evaluate(context.Background(), "window.location.href") + if err != nil { + t.Fatal(err) + } + var location string + if err := json.Unmarshal(value, &location); err != nil || !isAllowedCodexURL(location) { + t.Fatalf("location=%q error=%v", location, err) + } +} diff --git a/host/go/internal/cdp/generated/adapter-manifest.json b/host/go/internal/cdp/generated/adapter-manifest.json new file mode 100644 index 0000000..b04b95e --- /dev/null +++ b/host/go/internal/cdp/generated/adapter-manifest.json @@ -0,0 +1,7 @@ +{ + "schemaVersion": 1, + "adapterId": "current-2026-07", + "probeExpression": "(() => {\n const visible = (node) => {\n if (!node) return false;\n const rect = node.getBoundingClientRect();\n return rect.width > 0 && rect.height > 0;\n };\n const settingsPagePresent = (() => {\n const visible = (node) => {\n if (!node) return false;\n const rect = node.getBoundingClientRect();\n return rect.width > 0 && rect.height > 0;\n };\n const pathname = (() => {\n try { return new URL(window.location.href).pathname.toLowerCase(); }\n catch { return \"\"; }\n })();\n const pattern = new RegExp(\"(?:settings|preferences|account|appearance|theme|general|permissions|configuration|personalization|设置|偏好|账户|外观|主题|常规|权限|配置|个性化)\", \"i\");\n const settingsPath = pathname.split(\"/\")\n .some((segment) => /^(?:settings|preferences)$/.test(segment));\n const settingsNavigation = Array.from(document.querySelectorAll(\n \"nav,aside,[role=navigation]\"\n )).some((node) => visible(node) && pattern.test([\n node.id,\n typeof node.className === \"string\" ? node.className : \"\",\n node.getAttribute(\"aria-label\") || \"\",\n node.getAttribute(\"data-testid\") || \"\",\n ].join(\" \")));\n const main = document.querySelector(\"main,[role=main]\");\n const mainSemantic = Boolean(main) && Array.from(main.querySelectorAll(\n \"h1,h2,h3,[role=heading],[aria-label],[data-testid]\"\n )).some((node) => visible(node) &&\n !node.closest('[role=\"dialog\"],[role=\"menu\"],[role=\"alertdialog\"]') &&\n pattern.test([\n node.id,\n typeof node.className === \"string\" ? node.className : \"\",\n node.getAttribute(\"role\") || \"\",\n node.getAttribute(\"aria-label\") || \"\",\n node.getAttribute(\"data-testid\") || \"\",\n String(node.textContent || \"\").slice(0, 120),\n ].join(\" \")));\n return settingsPath || settingsNavigation || mainSemantic;\n})();\n return {\n main: visible(document.querySelector(\"main,[role=main]\")),\n navigation: visible(document.querySelector(\"nav,aside,[role=navigation]\")),\n composer: Array.from(document.querySelectorAll(\n \"textarea,[contenteditable=true],[role=textbox]\"\n)).some((node) => visible(node) &&\n !node.closest(\"[role=\\\"terminal\\\"],.xterm,[class*=\\\"terminal\\\"],[role=\\\"menu\\\"],[role=\\\"dialog\\\"],[role=\\\"alertdialog\\\"],[role=\\\"listbox\\\"],[data-radix-popper-content-wrapper]\")) || settingsPagePresent\n };\n})()", + "source": "(()=>{\"use strict\";(()=>{var zr=Object.defineProperty;var Vr=(t,e)=>{for(var r in e)zr(t,r,{get:e[r],enumerable:!0})};var s={};Vr(s,{BRAND:()=>mo,DIRTY:()=>Q,EMPTY_PATH:()=>Hr,INVALID:()=>f,NEVER:()=>Jo,OK:()=>R,ParseStatus:()=>_,Schema:()=>k,ZodAny:()=>X,ZodArray:()=>H,ZodBigInt:()=>te,ZodBoolean:()=>re,ZodBranded:()=>Ne,ZodCatch:()=>me,ZodDate:()=>oe,ZodDefault:()=>pe,ZodDiscriminatedUnion:()=>Ve,ZodEffects:()=>P,ZodEnum:()=>le,ZodError:()=>I,ZodFirstPartyTypeKind:()=>g,ZodFunction:()=>qe,ZodIntersection:()=>ie,ZodIssueCode:()=>d,ZodLazy:()=>ce,ZodLiteral:()=>de,ZodMap:()=>we,ZodNaN:()=>Re,ZodNativeEnum:()=>ue,ZodNever:()=>j,ZodNull:()=>ae,ZodNullable:()=>z,ZodNumber:()=>ee,ZodObject:()=>O,ZodOptional:()=>N,ZodParsedType:()=>u,ZodPipeline:()=>$e,ZodPromise:()=>J,ZodReadonly:()=>he,ZodRecord:()=>De,ZodSchema:()=>k,ZodSet:()=>_e,ZodString:()=>Y,ZodSymbol:()=>xe,ZodTransformer:()=>P,ZodTuple:()=>B,ZodType:()=>k,ZodUndefined:()=>ne,ZodUnion:()=>se,ZodUnknown:()=>F,ZodVoid:()=>Se,addIssueToContext:()=>l,any:()=>So,array:()=>Co,bigint:()=>yo,boolean:()=>It,coerce:()=>Xo,custom:()=>At,date:()=>bo,datetimeRegex:()=>Rt,defaultErrorMap:()=>D,discriminatedUnion:()=>Io,effect:()=>qo,enum:()=>zo,function:()=>Mo,getErrorMap:()=>be,getParsedType:()=>L,instanceof:()=>fo,intersection:()=>Oo,isAborted:()=>Be,isAsync:()=>ve,isDirty:()=>ze,isValid:()=>K,late:()=>ho,lazy:()=>Lo,literal:()=>Bo,makeIssue:()=>Oe,map:()=>Po,nan:()=>go,nativeEnum:()=>Vo,never:()=>_o,null:()=>xo,nullable:()=>Ho,number:()=>Et,object:()=>Ao,objectUtil:()=>et,oboolean:()=>Yo,onumber:()=>Ko,optional:()=>Fo,ostring:()=>Uo,pipeline:()=>Zo,preprocess:()=>Wo,promise:()=>Do,quotelessJson:()=>Dr,record:()=>$o,set:()=>jo,setErrorMap:()=>Fr,strictObject:()=>To,string:()=>Tt,symbol:()=>vo,transformer:()=>qo,tuple:()=>No,undefined:()=>ko,union:()=>Eo,unknown:()=>wo,util:()=>x,void:()=>Ro});var x;(function(t){t.assertEqual=n=>{};function e(n){}t.assertIs=e;function r(n){throw new Error}t.assertNever=r,t.arrayToEnum=n=>{let a={};for(let i of n)a[i]=i;return a},t.getValidEnumValues=n=>{let a=t.objectKeys(n).filter(c=>typeof n[n[c]]!=\"number\"),i={};for(let c of a)i[c]=n[c];return t.objectValues(i)},t.objectValues=n=>t.objectKeys(n).map(function(a){return n[a]}),t.objectKeys=typeof Object.keys==\"function\"?n=>Object.keys(n):n=>{let a=[];for(let i in n)Object.prototype.hasOwnProperty.call(n,i)&&a.push(i);return a},t.find=(n,a)=>{for(let i of n)if(a(i))return i},t.isInteger=typeof Number.isInteger==\"function\"?n=>Number.isInteger(n):n=>typeof n==\"number\"&&Number.isFinite(n)&&Math.floor(n)===n;function o(n,a=\" | \"){return n.map(i=>typeof i==\"string\"?`'${i}'`:i).join(a)}t.joinValues=o,t.jsonStringifyReplacer=(n,a)=>typeof a==\"bigint\"?a.toString():a})(x||(x={}));var et;(function(t){t.mergeShapes=(e,r)=>({...e,...r})})(et||(et={}));var u=x.arrayToEnum([\"string\",\"nan\",\"number\",\"integer\",\"float\",\"boolean\",\"date\",\"bigint\",\"symbol\",\"function\",\"undefined\",\"null\",\"array\",\"object\",\"unknown\",\"promise\",\"void\",\"never\",\"map\",\"set\"]),L=t=>{switch(typeof t){case\"undefined\":return u.undefined;case\"string\":return u.string;case\"number\":return Number.isNaN(t)?u.nan:u.number;case\"boolean\":return u.boolean;case\"function\":return u.function;case\"bigint\":return u.bigint;case\"symbol\":return u.symbol;case\"object\":return Array.isArray(t)?u.array:t===null?u.null:t.then&&typeof t.then==\"function\"&&t.catch&&typeof t.catch==\"function\"?u.promise:typeof Map<\"u\"&&t instanceof Map?u.map:typeof Set<\"u\"&&t instanceof Set?u.set:typeof Date<\"u\"&&t instanceof Date?u.date:u.object;default:return u.unknown}};var d=x.arrayToEnum([\"invalid_type\",\"invalid_literal\",\"custom\",\"invalid_union\",\"invalid_union_discriminator\",\"invalid_enum_value\",\"unrecognized_keys\",\"invalid_arguments\",\"invalid_return_type\",\"invalid_date\",\"invalid_string\",\"too_small\",\"too_big\",\"invalid_intersection_types\",\"not_multiple_of\",\"not_finite\"]),Dr=t=>JSON.stringify(t,null,2).replace(/\"([^\"]+)\":/g,\"$1:\"),I=class t extends Error{get errors(){return this.issues}constructor(e){super(),this.issues=[],this.addIssue=o=>{this.issues=[...this.issues,o]},this.addIssues=(o=[])=>{this.issues=[...this.issues,...o]};let r=new.target.prototype;Object.setPrototypeOf?Object.setPrototypeOf(this,r):this.__proto__=r,this.name=\"ZodError\",this.issues=e}format(e){let r=e||function(a){return a.message},o={_errors:[]},n=a=>{for(let i of a.issues)if(i.code===\"invalid_union\")i.unionErrors.map(n);else if(i.code===\"invalid_return_type\")n(i.returnTypeError);else if(i.code===\"invalid_arguments\")n(i.argumentsError);else if(i.path.length===0)o._errors.push(r(i));else{let c=o,m=0;for(;mr.message){let r={},o=[];for(let n of this.issues)if(n.path.length>0){let a=n.path[0];r[a]=r[a]||[],r[a].push(e(n))}else o.push(e(n));return{formErrors:o,fieldErrors:r}}get formErrors(){return this.flatten()}};I.create=t=>new I(t);var qr=(t,e)=>{let r;switch(t.code){case d.invalid_type:t.received===u.undefined?r=\"Required\":r=`Expected ${t.expected}, received ${t.received}`;break;case d.invalid_literal:r=`Invalid literal value, expected ${JSON.stringify(t.expected,x.jsonStringifyReplacer)}`;break;case d.unrecognized_keys:r=`Unrecognized key(s) in object: ${x.joinValues(t.keys,\", \")}`;break;case d.invalid_union:r=\"Invalid input\";break;case d.invalid_union_discriminator:r=`Invalid discriminator value. Expected ${x.joinValues(t.options)}`;break;case d.invalid_enum_value:r=`Invalid enum value. Expected ${x.joinValues(t.options)}, received '${t.received}'`;break;case d.invalid_arguments:r=\"Invalid function arguments\";break;case d.invalid_return_type:r=\"Invalid function return type\";break;case d.invalid_date:r=\"Invalid date\";break;case d.invalid_string:typeof t.validation==\"object\"?\"includes\"in t.validation?(r=`Invalid input: must include \"${t.validation.includes}\"`,typeof t.validation.position==\"number\"&&(r=`${r} at one or more positions greater than or equal to ${t.validation.position}`)):\"startsWith\"in t.validation?r=`Invalid input: must start with \"${t.validation.startsWith}\"`:\"endsWith\"in t.validation?r=`Invalid input: must end with \"${t.validation.endsWith}\"`:x.assertNever(t.validation):t.validation!==\"regex\"?r=`Invalid ${t.validation}`:r=\"Invalid\";break;case d.too_small:t.type===\"array\"?r=`Array must contain ${t.exact?\"exactly\":t.inclusive?\"at least\":\"more than\"} ${t.minimum} element(s)`:t.type===\"string\"?r=`String must contain ${t.exact?\"exactly\":t.inclusive?\"at least\":\"over\"} ${t.minimum} character(s)`:t.type===\"number\"?r=`Number must be ${t.exact?\"exactly equal to \":t.inclusive?\"greater than or equal to \":\"greater than \"}${t.minimum}`:t.type===\"bigint\"?r=`Number must be ${t.exact?\"exactly equal to \":t.inclusive?\"greater than or equal to \":\"greater than \"}${t.minimum}`:t.type===\"date\"?r=`Date must be ${t.exact?\"exactly equal to \":t.inclusive?\"greater than or equal to \":\"greater than \"}${new Date(Number(t.minimum))}`:r=\"Invalid input\";break;case d.too_big:t.type===\"array\"?r=`Array must contain ${t.exact?\"exactly\":t.inclusive?\"at most\":\"less than\"} ${t.maximum} element(s)`:t.type===\"string\"?r=`String must contain ${t.exact?\"exactly\":t.inclusive?\"at most\":\"under\"} ${t.maximum} character(s)`:t.type===\"number\"?r=`Number must be ${t.exact?\"exactly\":t.inclusive?\"less than or equal to\":\"less than\"} ${t.maximum}`:t.type===\"bigint\"?r=`BigInt must be ${t.exact?\"exactly\":t.inclusive?\"less than or equal to\":\"less than\"} ${t.maximum}`:t.type===\"date\"?r=`Date must be ${t.exact?\"exactly\":t.inclusive?\"smaller than or equal to\":\"smaller than\"} ${new Date(Number(t.maximum))}`:r=\"Invalid input\";break;case d.custom:r=\"Invalid input\";break;case d.invalid_intersection_types:r=\"Intersection results could not be merged\";break;case d.not_multiple_of:r=`Number must be a multiple of ${t.multipleOf}`;break;case d.not_finite:r=\"Number must be finite\";break;default:r=e.defaultError,x.assertNever(t)}return{message:r}},D=qr;var kt=D;function Fr(t){kt=t}function be(){return kt}var Oe=t=>{let{data:e,path:r,errorMaps:o,issueData:n}=t,a=[...r,...n.path||[]],i={...n,path:a};if(n.message!==void 0)return{...n,path:a,message:n.message};let c=\"\",m=o.filter(p=>!!p).slice().reverse();for(let p of m)c=p(i,{data:e,defaultError:c}).message;return{...n,path:a,message:c}},Hr=[];function l(t,e){let r=be(),o=Oe({issueData:e,data:t.data,path:t.path,errorMaps:[t.common.contextualErrorMap,t.schemaErrorMap,r,r===D?void 0:D].filter(n=>!!n)});t.common.issues.push(o)}var _=class t{constructor(){this.value=\"valid\"}dirty(){this.value===\"valid\"&&(this.value=\"dirty\")}abort(){this.value!==\"aborted\"&&(this.value=\"aborted\")}static mergeArray(e,r){let o=[];for(let n of r){if(n.status===\"aborted\")return f;n.status===\"dirty\"&&e.dirty(),o.push(n.value)}return{status:e.value,value:o}}static async mergeObjectAsync(e,r){let o=[];for(let n of r){let a=await n.key,i=await n.value;o.push({key:a,value:i})}return t.mergeObjectSync(e,o)}static mergeObjectSync(e,r){let o={};for(let n of r){let{key:a,value:i}=n;if(a.status===\"aborted\"||i.status===\"aborted\")return f;a.status===\"dirty\"&&e.dirty(),i.status===\"dirty\"&&e.dirty(),a.value!==\"__proto__\"&&(typeof i.value<\"u\"||n.alwaysSet)&&(o[a.value]=i.value)}return{status:e.value,value:o}}},f=Object.freeze({status:\"aborted\"}),Q=t=>({status:\"dirty\",value:t}),R=t=>({status:\"valid\",value:t}),Be=t=>t.status===\"aborted\",ze=t=>t.status===\"dirty\",K=t=>t.status===\"valid\",ve=t=>typeof Promise<\"u\"&&t instanceof Promise;var h;(function(t){t.errToObj=e=>typeof e==\"string\"?{message:e}:e||{},t.toString=e=>typeof e==\"string\"?e:e?.message})(h||(h={}));var $=class{constructor(e,r,o,n){this._cachedPath=[],this.parent=e,this.data=r,this._path=o,this._key=n}get path(){return this._cachedPath.length||(Array.isArray(this._key)?this._cachedPath.push(...this._path,...this._key):this._cachedPath.push(...this._path,this._key)),this._cachedPath}},xt=(t,e)=>{if(K(e))return{success:!0,data:e.value};if(!t.common.issues.length)throw new Error(\"Validation failed but no issues detected.\");return{success:!1,get error(){if(this._error)return this._error;let r=new I(t.common.issues);return this._error=r,this._error}}};function v(t){if(!t)return{};let{errorMap:e,invalid_type_error:r,required_error:o,description:n}=t;if(e&&(r||o))throw new Error(`Can't use \"invalid_type_error\" or \"required_error\" in conjunction with custom error map.`);return e?{errorMap:e,description:n}:{errorMap:(i,c)=>{let{message:m}=t;return i.code===\"invalid_enum_value\"?{message:m??c.defaultError}:typeof c.data>\"u\"?{message:m??o??c.defaultError}:i.code!==\"invalid_type\"?{message:c.defaultError}:{message:m??r??c.defaultError}},description:n}}var k=class{get description(){return this._def.description}_getType(e){return L(e.data)}_getOrReturnCtx(e,r){return r||{common:e.parent.common,data:e.data,parsedType:L(e.data),schemaErrorMap:this._def.errorMap,path:e.path,parent:e.parent}}_processInputParams(e){return{status:new _,ctx:{common:e.parent.common,data:e.data,parsedType:L(e.data),schemaErrorMap:this._def.errorMap,path:e.path,parent:e.parent}}}_parseSync(e){let r=this._parse(e);if(ve(r))throw new Error(\"Synchronous parse encountered promise.\");return r}_parseAsync(e){let r=this._parse(e);return Promise.resolve(r)}parse(e,r){let o=this.safeParse(e,r);if(o.success)return o.data;throw o.error}safeParse(e,r){let o={common:{issues:[],async:r?.async??!1,contextualErrorMap:r?.errorMap},path:r?.path||[],schemaErrorMap:this._def.errorMap,parent:null,data:e,parsedType:L(e)},n=this._parseSync({data:e,path:o.path,parent:o});return xt(o,n)}\"~validate\"(e){let r={common:{issues:[],async:!!this[\"~standard\"].async},path:[],schemaErrorMap:this._def.errorMap,parent:null,data:e,parsedType:L(e)};if(!this[\"~standard\"].async)try{let o=this._parseSync({data:e,path:[],parent:r});return K(o)?{value:o.value}:{issues:r.common.issues}}catch(o){o?.message?.toLowerCase()?.includes(\"encountered\")&&(this[\"~standard\"].async=!0),r.common={issues:[],async:!0}}return this._parseAsync({data:e,path:[],parent:r}).then(o=>K(o)?{value:o.value}:{issues:r.common.issues})}async parseAsync(e,r){let o=await this.safeParseAsync(e,r);if(o.success)return o.data;throw o.error}async safeParseAsync(e,r){let o={common:{issues:[],contextualErrorMap:r?.errorMap,async:!0},path:r?.path||[],schemaErrorMap:this._def.errorMap,parent:null,data:e,parsedType:L(e)},n=this._parse({data:e,path:o.path,parent:o}),a=await(ve(n)?n:Promise.resolve(n));return xt(o,a)}refine(e,r){let o=n=>typeof r==\"string\"||typeof r>\"u\"?{message:r}:typeof r==\"function\"?r(n):r;return this._refinement((n,a)=>{let i=e(n),c=()=>a.addIssue({code:d.custom,...o(n)});return typeof Promise<\"u\"&&i instanceof Promise?i.then(m=>m?!0:(c(),!1)):i?!0:(c(),!1)})}refinement(e,r){return this._refinement((o,n)=>e(o)?!0:(n.addIssue(typeof r==\"function\"?r(o,n):r),!1))}_refinement(e){return new P({schema:this,typeName:g.ZodEffects,effect:{type:\"refinement\",refinement:e}})}superRefine(e){return this._refinement(e)}constructor(e){this.spa=this.safeParseAsync,this._def=e,this.parse=this.parse.bind(this),this.safeParse=this.safeParse.bind(this),this.parseAsync=this.parseAsync.bind(this),this.safeParseAsync=this.safeParseAsync.bind(this),this.spa=this.spa.bind(this),this.refine=this.refine.bind(this),this.refinement=this.refinement.bind(this),this.superRefine=this.superRefine.bind(this),this.optional=this.optional.bind(this),this.nullable=this.nullable.bind(this),this.nullish=this.nullish.bind(this),this.array=this.array.bind(this),this.promise=this.promise.bind(this),this.or=this.or.bind(this),this.and=this.and.bind(this),this.transform=this.transform.bind(this),this.brand=this.brand.bind(this),this.default=this.default.bind(this),this.catch=this.catch.bind(this),this.describe=this.describe.bind(this),this.pipe=this.pipe.bind(this),this.readonly=this.readonly.bind(this),this.isNullable=this.isNullable.bind(this),this.isOptional=this.isOptional.bind(this),this[\"~standard\"]={version:1,vendor:\"zod\",validate:r=>this[\"~validate\"](r)}}optional(){return N.create(this,this._def)}nullable(){return z.create(this,this._def)}nullish(){return this.nullable().optional()}array(){return H.create(this)}promise(){return J.create(this,this._def)}or(e){return se.create([this,e],this._def)}and(e){return ie.create(this,e,this._def)}transform(e){return new P({...v(this._def),schema:this,typeName:g.ZodEffects,effect:{type:\"transform\",transform:e}})}default(e){let r=typeof e==\"function\"?e:()=>e;return new pe({...v(this._def),innerType:this,defaultValue:r,typeName:g.ZodDefault})}brand(){return new Ne({typeName:g.ZodBranded,type:this,...v(this._def)})}catch(e){let r=typeof e==\"function\"?e:()=>e;return new me({...v(this._def),innerType:this,catchValue:r,typeName:g.ZodCatch})}describe(e){let r=this.constructor;return new r({...this._def,description:e})}pipe(e){return $e.create(this,e)}readonly(){return he.create(this)}isOptional(){return this.safeParse(void 0).success}isNullable(){return this.safeParse(null).success}},Wr=/^c[^\\s-]{8,}$/i,Zr=/^[0-9a-z]+$/,Ur=/^[0-9A-HJKMNP-TV-Z]{26}$/i,Kr=/^[0-9a-fA-F]{8}\\b-[0-9a-fA-F]{4}\\b-[0-9a-fA-F]{4}\\b-[0-9a-fA-F]{4}\\b-[0-9a-fA-F]{12}$/i,Yr=/^[a-z0-9_-]{21}$/i,Xr=/^[A-Za-z0-9-_]+\\.[A-Za-z0-9-_]+\\.[A-Za-z0-9-_]*$/,Jr=/^[-+]?P(?!$)(?:(?:[-+]?\\d+Y)|(?:[-+]?\\d+[.,]\\d+Y$))?(?:(?:[-+]?\\d+M)|(?:[-+]?\\d+[.,]\\d+M$))?(?:(?:[-+]?\\d+W)|(?:[-+]?\\d+[.,]\\d+W$))?(?:(?:[-+]?\\d+D)|(?:[-+]?\\d+[.,]\\d+D$))?(?:T(?=[\\d+-])(?:(?:[-+]?\\d+H)|(?:[-+]?\\d+[.,]\\d+H$))?(?:(?:[-+]?\\d+M)|(?:[-+]?\\d+[.,]\\d+M$))?(?:[-+]?\\d+(?:[.,]\\d+)?S)?)??$/,Gr=/^(?!\\.)(?!.*\\.\\.)([A-Z0-9_'+\\-\\.]*)[A-Z0-9_+-]@([A-Z0-9][A-Z0-9\\-]*\\.)+[A-Z]{2,}$/i,Qr=\"^(\\\\p{Extended_Pictographic}|\\\\p{Emoji_Component})+$\",tt,eo=/^(?:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\\.){3}(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])$/,to=/^(?:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\\.){3}(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\\/(3[0-2]|[12]?[0-9])$/,ro=/^(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))$/,oo=/^(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))\\/(12[0-8]|1[01][0-9]|[1-9]?[0-9])$/,no=/^([0-9a-zA-Z+/]{4})*(([0-9a-zA-Z+/]{2}==)|([0-9a-zA-Z+/]{3}=))?$/,ao=/^([0-9a-zA-Z-_]{4})*(([0-9a-zA-Z-_]{2}(==)?)|([0-9a-zA-Z-_]{3}(=)?))?$/,wt=\"((\\\\d\\\\d[2468][048]|\\\\d\\\\d[13579][26]|\\\\d\\\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\\\d{4}-((0[13578]|1[02])-(0[1-9]|[12]\\\\d|3[01])|(0[469]|11)-(0[1-9]|[12]\\\\d|30)|(02)-(0[1-9]|1\\\\d|2[0-8])))\",so=new RegExp(`^${wt}$`);function _t(t){let e=\"[0-5]\\\\d\";t.precision?e=`${e}\\\\.\\\\d{${t.precision}}`:t.precision==null&&(e=`${e}(\\\\.\\\\d+)?`);let r=t.precision?\"+\":\"?\";return`([01]\\\\d|2[0-3]):[0-5]\\\\d(:${e})${r}`}function io(t){return new RegExp(`^${_t(t)}$`)}function Rt(t){let e=`${wt}T${_t(t)}`,r=[];return r.push(t.local?\"Z?\":\"Z\"),t.offset&&r.push(\"([+-]\\\\d{2}:?\\\\d{2})\"),e=`${e}(${r.join(\"|\")})`,new RegExp(`^${e}$`)}function co(t,e){return!!((e===\"v4\"||!e)&&eo.test(t)||(e===\"v6\"||!e)&&ro.test(t))}function lo(t,e){if(!Xr.test(t))return!1;try{let[r]=t.split(\".\");if(!r)return!1;let o=r.replace(/-/g,\"+\").replace(/_/g,\"/\").padEnd(r.length+(4-r.length%4)%4,\"=\"),n=JSON.parse(atob(o));return!(typeof n!=\"object\"||n===null||\"typ\"in n&&n?.typ!==\"JWT\"||!n.alg||e&&n.alg!==e)}catch{return!1}}function uo(t,e){return!!((e===\"v4\"||!e)&&to.test(t)||(e===\"v6\"||!e)&&oo.test(t))}var Y=class t extends k{_parse(e){if(this._def.coerce&&(e.data=String(e.data)),this._getType(e)!==u.string){let a=this._getOrReturnCtx(e);return l(a,{code:d.invalid_type,expected:u.string,received:a.parsedType}),f}let o=new _,n;for(let a of this._def.checks)if(a.kind===\"min\")e.data.lengtha.value&&(n=this._getOrReturnCtx(e,n),l(n,{code:d.too_big,maximum:a.value,type:\"string\",inclusive:!0,exact:!1,message:a.message}),o.dirty());else if(a.kind===\"length\"){let i=e.data.length>a.value,c=e.data.lengthe.test(n),{validation:r,code:d.invalid_string,...h.errToObj(o)})}_addCheck(e){return new t({...this._def,checks:[...this._def.checks,e]})}email(e){return this._addCheck({kind:\"email\",...h.errToObj(e)})}url(e){return this._addCheck({kind:\"url\",...h.errToObj(e)})}emoji(e){return this._addCheck({kind:\"emoji\",...h.errToObj(e)})}uuid(e){return this._addCheck({kind:\"uuid\",...h.errToObj(e)})}nanoid(e){return this._addCheck({kind:\"nanoid\",...h.errToObj(e)})}cuid(e){return this._addCheck({kind:\"cuid\",...h.errToObj(e)})}cuid2(e){return this._addCheck({kind:\"cuid2\",...h.errToObj(e)})}ulid(e){return this._addCheck({kind:\"ulid\",...h.errToObj(e)})}base64(e){return this._addCheck({kind:\"base64\",...h.errToObj(e)})}base64url(e){return this._addCheck({kind:\"base64url\",...h.errToObj(e)})}jwt(e){return this._addCheck({kind:\"jwt\",...h.errToObj(e)})}ip(e){return this._addCheck({kind:\"ip\",...h.errToObj(e)})}cidr(e){return this._addCheck({kind:\"cidr\",...h.errToObj(e)})}datetime(e){return typeof e==\"string\"?this._addCheck({kind:\"datetime\",precision:null,offset:!1,local:!1,message:e}):this._addCheck({kind:\"datetime\",precision:typeof e?.precision>\"u\"?null:e?.precision,offset:e?.offset??!1,local:e?.local??!1,...h.errToObj(e?.message)})}date(e){return this._addCheck({kind:\"date\",message:e})}time(e){return typeof e==\"string\"?this._addCheck({kind:\"time\",precision:null,message:e}):this._addCheck({kind:\"time\",precision:typeof e?.precision>\"u\"?null:e?.precision,...h.errToObj(e?.message)})}duration(e){return this._addCheck({kind:\"duration\",...h.errToObj(e)})}regex(e,r){return this._addCheck({kind:\"regex\",regex:e,...h.errToObj(r)})}includes(e,r){return this._addCheck({kind:\"includes\",value:e,position:r?.position,...h.errToObj(r?.message)})}startsWith(e,r){return this._addCheck({kind:\"startsWith\",value:e,...h.errToObj(r)})}endsWith(e,r){return this._addCheck({kind:\"endsWith\",value:e,...h.errToObj(r)})}min(e,r){return this._addCheck({kind:\"min\",value:e,...h.errToObj(r)})}max(e,r){return this._addCheck({kind:\"max\",value:e,...h.errToObj(r)})}length(e,r){return this._addCheck({kind:\"length\",value:e,...h.errToObj(r)})}nonempty(e){return this.min(1,h.errToObj(e))}trim(){return new t({...this._def,checks:[...this._def.checks,{kind:\"trim\"}]})}toLowerCase(){return new t({...this._def,checks:[...this._def.checks,{kind:\"toLowerCase\"}]})}toUpperCase(){return new t({...this._def,checks:[...this._def.checks,{kind:\"toUpperCase\"}]})}get isDatetime(){return!!this._def.checks.find(e=>e.kind===\"datetime\")}get isDate(){return!!this._def.checks.find(e=>e.kind===\"date\")}get isTime(){return!!this._def.checks.find(e=>e.kind===\"time\")}get isDuration(){return!!this._def.checks.find(e=>e.kind===\"duration\")}get isEmail(){return!!this._def.checks.find(e=>e.kind===\"email\")}get isURL(){return!!this._def.checks.find(e=>e.kind===\"url\")}get isEmoji(){return!!this._def.checks.find(e=>e.kind===\"emoji\")}get isUUID(){return!!this._def.checks.find(e=>e.kind===\"uuid\")}get isNANOID(){return!!this._def.checks.find(e=>e.kind===\"nanoid\")}get isCUID(){return!!this._def.checks.find(e=>e.kind===\"cuid\")}get isCUID2(){return!!this._def.checks.find(e=>e.kind===\"cuid2\")}get isULID(){return!!this._def.checks.find(e=>e.kind===\"ulid\")}get isIP(){return!!this._def.checks.find(e=>e.kind===\"ip\")}get isCIDR(){return!!this._def.checks.find(e=>e.kind===\"cidr\")}get isBase64(){return!!this._def.checks.find(e=>e.kind===\"base64\")}get isBase64url(){return!!this._def.checks.find(e=>e.kind===\"base64url\")}get minLength(){let e=null;for(let r of this._def.checks)r.kind===\"min\"&&(e===null||r.value>e)&&(e=r.value);return e}get maxLength(){let e=null;for(let r of this._def.checks)r.kind===\"max\"&&(e===null||r.valuenew Y({checks:[],typeName:g.ZodString,coerce:t?.coerce??!1,...v(t)});function po(t,e){let r=(t.toString().split(\".\")[1]||\"\").length,o=(e.toString().split(\".\")[1]||\"\").length,n=r>o?r:o,a=Number.parseInt(t.toFixed(n).replace(\".\",\"\")),i=Number.parseInt(e.toFixed(n).replace(\".\",\"\"));return a%i/10**n}var ee=class t extends k{constructor(){super(...arguments),this.min=this.gte,this.max=this.lte,this.step=this.multipleOf}_parse(e){if(this._def.coerce&&(e.data=Number(e.data)),this._getType(e)!==u.number){let a=this._getOrReturnCtx(e);return l(a,{code:d.invalid_type,expected:u.number,received:a.parsedType}),f}let o,n=new _;for(let a of this._def.checks)a.kind===\"int\"?x.isInteger(e.data)||(o=this._getOrReturnCtx(e,o),l(o,{code:d.invalid_type,expected:\"integer\",received:\"float\",message:a.message}),n.dirty()):a.kind===\"min\"?(a.inclusive?e.dataa.value:e.data>=a.value)&&(o=this._getOrReturnCtx(e,o),l(o,{code:d.too_big,maximum:a.value,type:\"number\",inclusive:a.inclusive,exact:!1,message:a.message}),n.dirty()):a.kind===\"multipleOf\"?po(e.data,a.value)!==0&&(o=this._getOrReturnCtx(e,o),l(o,{code:d.not_multiple_of,multipleOf:a.value,message:a.message}),n.dirty()):a.kind===\"finite\"?Number.isFinite(e.data)||(o=this._getOrReturnCtx(e,o),l(o,{code:d.not_finite,message:a.message}),n.dirty()):x.assertNever(a);return{status:n.value,value:e.data}}gte(e,r){return this.setLimit(\"min\",e,!0,h.toString(r))}gt(e,r){return this.setLimit(\"min\",e,!1,h.toString(r))}lte(e,r){return this.setLimit(\"max\",e,!0,h.toString(r))}lt(e,r){return this.setLimit(\"max\",e,!1,h.toString(r))}setLimit(e,r,o,n){return new t({...this._def,checks:[...this._def.checks,{kind:e,value:r,inclusive:o,message:h.toString(n)}]})}_addCheck(e){return new t({...this._def,checks:[...this._def.checks,e]})}int(e){return this._addCheck({kind:\"int\",message:h.toString(e)})}positive(e){return this._addCheck({kind:\"min\",value:0,inclusive:!1,message:h.toString(e)})}negative(e){return this._addCheck({kind:\"max\",value:0,inclusive:!1,message:h.toString(e)})}nonpositive(e){return this._addCheck({kind:\"max\",value:0,inclusive:!0,message:h.toString(e)})}nonnegative(e){return this._addCheck({kind:\"min\",value:0,inclusive:!0,message:h.toString(e)})}multipleOf(e,r){return this._addCheck({kind:\"multipleOf\",value:e,message:h.toString(r)})}finite(e){return this._addCheck({kind:\"finite\",message:h.toString(e)})}safe(e){return this._addCheck({kind:\"min\",inclusive:!0,value:Number.MIN_SAFE_INTEGER,message:h.toString(e)})._addCheck({kind:\"max\",inclusive:!0,value:Number.MAX_SAFE_INTEGER,message:h.toString(e)})}get minValue(){let e=null;for(let r of this._def.checks)r.kind===\"min\"&&(e===null||r.value>e)&&(e=r.value);return e}get maxValue(){let e=null;for(let r of this._def.checks)r.kind===\"max\"&&(e===null||r.valuee.kind===\"int\"||e.kind===\"multipleOf\"&&x.isInteger(e.value))}get isFinite(){let e=null,r=null;for(let o of this._def.checks){if(o.kind===\"finite\"||o.kind===\"int\"||o.kind===\"multipleOf\")return!0;o.kind===\"min\"?(r===null||o.value>r)&&(r=o.value):o.kind===\"max\"&&(e===null||o.valuenew ee({checks:[],typeName:g.ZodNumber,coerce:t?.coerce||!1,...v(t)});var te=class t extends k{constructor(){super(...arguments),this.min=this.gte,this.max=this.lte}_parse(e){if(this._def.coerce)try{e.data=BigInt(e.data)}catch{return this._getInvalidInput(e)}if(this._getType(e)!==u.bigint)return this._getInvalidInput(e);let o,n=new _;for(let a of this._def.checks)a.kind===\"min\"?(a.inclusive?e.dataa.value:e.data>=a.value)&&(o=this._getOrReturnCtx(e,o),l(o,{code:d.too_big,type:\"bigint\",maximum:a.value,inclusive:a.inclusive,message:a.message}),n.dirty()):a.kind===\"multipleOf\"?e.data%a.value!==BigInt(0)&&(o=this._getOrReturnCtx(e,o),l(o,{code:d.not_multiple_of,multipleOf:a.value,message:a.message}),n.dirty()):x.assertNever(a);return{status:n.value,value:e.data}}_getInvalidInput(e){let r=this._getOrReturnCtx(e);return l(r,{code:d.invalid_type,expected:u.bigint,received:r.parsedType}),f}gte(e,r){return this.setLimit(\"min\",e,!0,h.toString(r))}gt(e,r){return this.setLimit(\"min\",e,!1,h.toString(r))}lte(e,r){return this.setLimit(\"max\",e,!0,h.toString(r))}lt(e,r){return this.setLimit(\"max\",e,!1,h.toString(r))}setLimit(e,r,o,n){return new t({...this._def,checks:[...this._def.checks,{kind:e,value:r,inclusive:o,message:h.toString(n)}]})}_addCheck(e){return new t({...this._def,checks:[...this._def.checks,e]})}positive(e){return this._addCheck({kind:\"min\",value:BigInt(0),inclusive:!1,message:h.toString(e)})}negative(e){return this._addCheck({kind:\"max\",value:BigInt(0),inclusive:!1,message:h.toString(e)})}nonpositive(e){return this._addCheck({kind:\"max\",value:BigInt(0),inclusive:!0,message:h.toString(e)})}nonnegative(e){return this._addCheck({kind:\"min\",value:BigInt(0),inclusive:!0,message:h.toString(e)})}multipleOf(e,r){return this._addCheck({kind:\"multipleOf\",value:e,message:h.toString(r)})}get minValue(){let e=null;for(let r of this._def.checks)r.kind===\"min\"&&(e===null||r.value>e)&&(e=r.value);return e}get maxValue(){let e=null;for(let r of this._def.checks)r.kind===\"max\"&&(e===null||r.valuenew te({checks:[],typeName:g.ZodBigInt,coerce:t?.coerce??!1,...v(t)});var re=class extends k{_parse(e){if(this._def.coerce&&(e.data=!!e.data),this._getType(e)!==u.boolean){let o=this._getOrReturnCtx(e);return l(o,{code:d.invalid_type,expected:u.boolean,received:o.parsedType}),f}return R(e.data)}};re.create=t=>new re({typeName:g.ZodBoolean,coerce:t?.coerce||!1,...v(t)});var oe=class t extends k{_parse(e){if(this._def.coerce&&(e.data=new Date(e.data)),this._getType(e)!==u.date){let a=this._getOrReturnCtx(e);return l(a,{code:d.invalid_type,expected:u.date,received:a.parsedType}),f}if(Number.isNaN(e.data.getTime())){let a=this._getOrReturnCtx(e);return l(a,{code:d.invalid_date}),f}let o=new _,n;for(let a of this._def.checks)a.kind===\"min\"?e.data.getTime()a.value&&(n=this._getOrReturnCtx(e,n),l(n,{code:d.too_big,message:a.message,inclusive:!0,exact:!1,maximum:a.value,type:\"date\"}),o.dirty()):x.assertNever(a);return{status:o.value,value:new Date(e.data.getTime())}}_addCheck(e){return new t({...this._def,checks:[...this._def.checks,e]})}min(e,r){return this._addCheck({kind:\"min\",value:e.getTime(),message:h.toString(r)})}max(e,r){return this._addCheck({kind:\"max\",value:e.getTime(),message:h.toString(r)})}get minDate(){let e=null;for(let r of this._def.checks)r.kind===\"min\"&&(e===null||r.value>e)&&(e=r.value);return e!=null?new Date(e):null}get maxDate(){let e=null;for(let r of this._def.checks)r.kind===\"max\"&&(e===null||r.valuenew oe({checks:[],coerce:t?.coerce||!1,typeName:g.ZodDate,...v(t)});var xe=class extends k{_parse(e){if(this._getType(e)!==u.symbol){let o=this._getOrReturnCtx(e);return l(o,{code:d.invalid_type,expected:u.symbol,received:o.parsedType}),f}return R(e.data)}};xe.create=t=>new xe({typeName:g.ZodSymbol,...v(t)});var ne=class extends k{_parse(e){if(this._getType(e)!==u.undefined){let o=this._getOrReturnCtx(e);return l(o,{code:d.invalid_type,expected:u.undefined,received:o.parsedType}),f}return R(e.data)}};ne.create=t=>new ne({typeName:g.ZodUndefined,...v(t)});var ae=class extends k{_parse(e){if(this._getType(e)!==u.null){let o=this._getOrReturnCtx(e);return l(o,{code:d.invalid_type,expected:u.null,received:o.parsedType}),f}return R(e.data)}};ae.create=t=>new ae({typeName:g.ZodNull,...v(t)});var X=class extends k{constructor(){super(...arguments),this._any=!0}_parse(e){return R(e.data)}};X.create=t=>new X({typeName:g.ZodAny,...v(t)});var F=class extends k{constructor(){super(...arguments),this._unknown=!0}_parse(e){return R(e.data)}};F.create=t=>new F({typeName:g.ZodUnknown,...v(t)});var j=class extends k{_parse(e){let r=this._getOrReturnCtx(e);return l(r,{code:d.invalid_type,expected:u.never,received:r.parsedType}),f}};j.create=t=>new j({typeName:g.ZodNever,...v(t)});var Se=class extends k{_parse(e){if(this._getType(e)!==u.undefined){let o=this._getOrReturnCtx(e);return l(o,{code:d.invalid_type,expected:u.void,received:o.parsedType}),f}return R(e.data)}};Se.create=t=>new Se({typeName:g.ZodVoid,...v(t)});var H=class t extends k{_parse(e){let{ctx:r,status:o}=this._processInputParams(e),n=this._def;if(r.parsedType!==u.array)return l(r,{code:d.invalid_type,expected:u.array,received:r.parsedType}),f;if(n.exactLength!==null){let i=r.data.length>n.exactLength.value,c=r.data.lengthn.maxLength.value&&(l(r,{code:d.too_big,maximum:n.maxLength.value,type:\"array\",inclusive:!0,exact:!1,message:n.maxLength.message}),o.dirty()),r.common.async)return Promise.all([...r.data].map((i,c)=>n.type._parseAsync(new $(r,i,r.path,c)))).then(i=>_.mergeArray(o,i));let a=[...r.data].map((i,c)=>n.type._parseSync(new $(r,i,r.path,c)));return _.mergeArray(o,a)}get element(){return this._def.type}min(e,r){return new t({...this._def,minLength:{value:e,message:h.toString(r)}})}max(e,r){return new t({...this._def,maxLength:{value:e,message:h.toString(r)}})}length(e,r){return new t({...this._def,exactLength:{value:e,message:h.toString(r)}})}nonempty(e){return this.min(1,e)}};H.create=(t,e)=>new H({type:t,minLength:null,maxLength:null,exactLength:null,typeName:g.ZodArray,...v(e)});function ke(t){if(t instanceof O){let e={};for(let r in t.shape){let o=t.shape[r];e[r]=N.create(ke(o))}return new O({...t._def,shape:()=>e})}else return t instanceof H?new H({...t._def,type:ke(t.element)}):t instanceof N?N.create(ke(t.unwrap())):t instanceof z?z.create(ke(t.unwrap())):t instanceof B?B.create(t.items.map(e=>ke(e))):t}var O=class t extends k{constructor(){super(...arguments),this._cached=null,this.nonstrict=this.passthrough,this.augment=this.extend}_getCached(){if(this._cached!==null)return this._cached;let e=this._def.shape(),r=x.objectKeys(e);return this._cached={shape:e,keys:r},this._cached}_parse(e){if(this._getType(e)!==u.object){let p=this._getOrReturnCtx(e);return l(p,{code:d.invalid_type,expected:u.object,received:p.parsedType}),f}let{status:o,ctx:n}=this._processInputParams(e),{shape:a,keys:i}=this._getCached(),c=[];if(!(this._def.catchall instanceof j&&this._def.unknownKeys===\"strip\"))for(let p in n.data)i.includes(p)||c.push(p);let m=[];for(let p of i){let y=a[p],w=n.data[p];m.push({key:{status:\"valid\",value:p},value:y._parse(new $(n,w,n.path,p)),alwaysSet:p in n.data})}if(this._def.catchall instanceof j){let p=this._def.unknownKeys;if(p===\"passthrough\")for(let y of c)m.push({key:{status:\"valid\",value:y},value:{status:\"valid\",value:n.data[y]}});else if(p===\"strict\")c.length>0&&(l(n,{code:d.unrecognized_keys,keys:c}),o.dirty());else if(p!==\"strip\")throw new Error(\"Internal ZodObject error: invalid unknownKeys value.\")}else{let p=this._def.catchall;for(let y of c){let w=n.data[y];m.push({key:{status:\"valid\",value:y},value:p._parse(new $(n,w,n.path,y)),alwaysSet:y in n.data})}}return n.common.async?Promise.resolve().then(async()=>{let p=[];for(let y of m){let w=await y.key,M=await y.value;p.push({key:w,value:M,alwaysSet:y.alwaysSet})}return p}).then(p=>_.mergeObjectSync(o,p)):_.mergeObjectSync(o,m)}get shape(){return this._def.shape()}strict(e){return h.errToObj,new t({...this._def,unknownKeys:\"strict\",...e!==void 0?{errorMap:(r,o)=>{let n=this._def.errorMap?.(r,o).message??o.defaultError;return r.code===\"unrecognized_keys\"?{message:h.errToObj(e).message??n}:{message:n}}}:{}})}strip(){return new t({...this._def,unknownKeys:\"strip\"})}passthrough(){return new t({...this._def,unknownKeys:\"passthrough\"})}extend(e){return new t({...this._def,shape:()=>({...this._def.shape(),...e})})}merge(e){return new t({unknownKeys:e._def.unknownKeys,catchall:e._def.catchall,shape:()=>({...this._def.shape(),...e._def.shape()}),typeName:g.ZodObject})}setKey(e,r){return this.augment({[e]:r})}catchall(e){return new t({...this._def,catchall:e})}pick(e){let r={};for(let o of x.objectKeys(e))e[o]&&this.shape[o]&&(r[o]=this.shape[o]);return new t({...this._def,shape:()=>r})}omit(e){let r={};for(let o of x.objectKeys(this.shape))e[o]||(r[o]=this.shape[o]);return new t({...this._def,shape:()=>r})}deepPartial(){return ke(this)}partial(e){let r={};for(let o of x.objectKeys(this.shape)){let n=this.shape[o];e&&!e[o]?r[o]=n:r[o]=n.optional()}return new t({...this._def,shape:()=>r})}required(e){let r={};for(let o of x.objectKeys(this.shape))if(e&&!e[o])r[o]=this.shape[o];else{let a=this.shape[o];for(;a instanceof N;)a=a._def.innerType;r[o]=a}return new t({...this._def,shape:()=>r})}keyof(){return Ct(x.objectKeys(this.shape))}};O.create=(t,e)=>new O({shape:()=>t,unknownKeys:\"strip\",catchall:j.create(),typeName:g.ZodObject,...v(e)});O.strictCreate=(t,e)=>new O({shape:()=>t,unknownKeys:\"strict\",catchall:j.create(),typeName:g.ZodObject,...v(e)});O.lazycreate=(t,e)=>new O({shape:t,unknownKeys:\"strip\",catchall:j.create(),typeName:g.ZodObject,...v(e)});var se=class extends k{_parse(e){let{ctx:r}=this._processInputParams(e),o=this._def.options;function n(a){for(let c of a)if(c.result.status===\"valid\")return c.result;for(let c of a)if(c.result.status===\"dirty\")return r.common.issues.push(...c.ctx.common.issues),c.result;let i=a.map(c=>new I(c.ctx.common.issues));return l(r,{code:d.invalid_union,unionErrors:i}),f}if(r.common.async)return Promise.all(o.map(async a=>{let i={...r,common:{...r.common,issues:[]},parent:null};return{result:await a._parseAsync({data:r.data,path:r.path,parent:i}),ctx:i}})).then(n);{let a,i=[];for(let m of o){let p={...r,common:{...r.common,issues:[]},parent:null},y=m._parseSync({data:r.data,path:r.path,parent:p});if(y.status===\"valid\")return y;y.status===\"dirty\"&&!a&&(a={result:y,ctx:p}),p.common.issues.length&&i.push(p.common.issues)}if(a)return r.common.issues.push(...a.ctx.common.issues),a.result;let c=i.map(m=>new I(m));return l(r,{code:d.invalid_union,unionErrors:c}),f}}get options(){return this._def.options}};se.create=(t,e)=>new se({options:t,typeName:g.ZodUnion,...v(e)});var q=t=>t instanceof ce?q(t.schema):t instanceof P?q(t.innerType()):t instanceof de?[t.value]:t instanceof le?t.options:t instanceof ue?x.objectValues(t.enum):t instanceof pe?q(t._def.innerType):t instanceof ne?[void 0]:t instanceof ae?[null]:t instanceof N?[void 0,...q(t.unwrap())]:t instanceof z?[null,...q(t.unwrap())]:t instanceof Ne||t instanceof he?q(t.unwrap()):t instanceof me?q(t._def.innerType):[],Ve=class t extends k{_parse(e){let{ctx:r}=this._processInputParams(e);if(r.parsedType!==u.object)return l(r,{code:d.invalid_type,expected:u.object,received:r.parsedType}),f;let o=this.discriminator,n=r.data[o],a=this.optionsMap.get(n);return a?r.common.async?a._parseAsync({data:r.data,path:r.path,parent:r}):a._parseSync({data:r.data,path:r.path,parent:r}):(l(r,{code:d.invalid_union_discriminator,options:Array.from(this.optionsMap.keys()),path:[o]}),f)}get discriminator(){return this._def.discriminator}get options(){return this._def.options}get optionsMap(){return this._def.optionsMap}static create(e,r,o){let n=new Map;for(let a of r){let i=q(a.shape[e]);if(!i.length)throw new Error(`A discriminator value for key \\`${e}\\` could not be extracted from all schema options`);for(let c of i){if(n.has(c))throw new Error(`Discriminator property ${String(e)} has duplicate value ${String(c)}`);n.set(c,a)}}return new t({typeName:g.ZodDiscriminatedUnion,discriminator:e,options:r,optionsMap:n,...v(o)})}};function rt(t,e){let r=L(t),o=L(e);if(t===e)return{valid:!0,data:t};if(r===u.object&&o===u.object){let n=x.objectKeys(e),a=x.objectKeys(t).filter(c=>n.indexOf(c)!==-1),i={...t,...e};for(let c of a){let m=rt(t[c],e[c]);if(!m.valid)return{valid:!1};i[c]=m.data}return{valid:!0,data:i}}else if(r===u.array&&o===u.array){if(t.length!==e.length)return{valid:!1};let n=[];for(let a=0;a{if(Be(a)||Be(i))return f;let c=rt(a.value,i.value);return c.valid?((ze(a)||ze(i))&&r.dirty(),{status:r.value,value:c.data}):(l(o,{code:d.invalid_intersection_types}),f)};return o.common.async?Promise.all([this._def.left._parseAsync({data:o.data,path:o.path,parent:o}),this._def.right._parseAsync({data:o.data,path:o.path,parent:o})]).then(([a,i])=>n(a,i)):n(this._def.left._parseSync({data:o.data,path:o.path,parent:o}),this._def.right._parseSync({data:o.data,path:o.path,parent:o}))}};ie.create=(t,e,r)=>new ie({left:t,right:e,typeName:g.ZodIntersection,...v(r)});var B=class t extends k{_parse(e){let{status:r,ctx:o}=this._processInputParams(e);if(o.parsedType!==u.array)return l(o,{code:d.invalid_type,expected:u.array,received:o.parsedType}),f;if(o.data.lengththis._def.items.length&&(l(o,{code:d.too_big,maximum:this._def.items.length,inclusive:!0,exact:!1,type:\"array\"}),r.dirty());let a=[...o.data].map((i,c)=>{let m=this._def.items[c]||this._def.rest;return m?m._parse(new $(o,i,o.path,c)):null}).filter(i=>!!i);return o.common.async?Promise.all(a).then(i=>_.mergeArray(r,i)):_.mergeArray(r,a)}get items(){return this._def.items}rest(e){return new t({...this._def,rest:e})}};B.create=(t,e)=>{if(!Array.isArray(t))throw new Error(\"You must pass an array of schemas to z.tuple([ ... ])\");return new B({items:t,typeName:g.ZodTuple,rest:null,...v(e)})};var De=class t extends k{get keySchema(){return this._def.keyType}get valueSchema(){return this._def.valueType}_parse(e){let{status:r,ctx:o}=this._processInputParams(e);if(o.parsedType!==u.object)return l(o,{code:d.invalid_type,expected:u.object,received:o.parsedType}),f;let n=[],a=this._def.keyType,i=this._def.valueType;for(let c in o.data)n.push({key:a._parse(new $(o,c,o.path,c)),value:i._parse(new $(o,o.data[c],o.path,c)),alwaysSet:c in o.data});return o.common.async?_.mergeObjectAsync(r,n):_.mergeObjectSync(r,n)}get element(){return this._def.valueType}static create(e,r,o){return r instanceof k?new t({keyType:e,valueType:r,typeName:g.ZodRecord,...v(o)}):new t({keyType:Y.create(),valueType:e,typeName:g.ZodRecord,...v(r)})}},we=class extends k{get keySchema(){return this._def.keyType}get valueSchema(){return this._def.valueType}_parse(e){let{status:r,ctx:o}=this._processInputParams(e);if(o.parsedType!==u.map)return l(o,{code:d.invalid_type,expected:u.map,received:o.parsedType}),f;let n=this._def.keyType,a=this._def.valueType,i=[...o.data.entries()].map(([c,m],p)=>({key:n._parse(new $(o,c,o.path,[p,\"key\"])),value:a._parse(new $(o,m,o.path,[p,\"value\"]))}));if(o.common.async){let c=new Map;return Promise.resolve().then(async()=>{for(let m of i){let p=await m.key,y=await m.value;if(p.status===\"aborted\"||y.status===\"aborted\")return f;(p.status===\"dirty\"||y.status===\"dirty\")&&r.dirty(),c.set(p.value,y.value)}return{status:r.value,value:c}})}else{let c=new Map;for(let m of i){let p=m.key,y=m.value;if(p.status===\"aborted\"||y.status===\"aborted\")return f;(p.status===\"dirty\"||y.status===\"dirty\")&&r.dirty(),c.set(p.value,y.value)}return{status:r.value,value:c}}}};we.create=(t,e,r)=>new we({valueType:e,keyType:t,typeName:g.ZodMap,...v(r)});var _e=class t extends k{_parse(e){let{status:r,ctx:o}=this._processInputParams(e);if(o.parsedType!==u.set)return l(o,{code:d.invalid_type,expected:u.set,received:o.parsedType}),f;let n=this._def;n.minSize!==null&&o.data.sizen.maxSize.value&&(l(o,{code:d.too_big,maximum:n.maxSize.value,type:\"set\",inclusive:!0,exact:!1,message:n.maxSize.message}),r.dirty());let a=this._def.valueType;function i(m){let p=new Set;for(let y of m){if(y.status===\"aborted\")return f;y.status===\"dirty\"&&r.dirty(),p.add(y.value)}return{status:r.value,value:p}}let c=[...o.data.values()].map((m,p)=>a._parse(new $(o,m,o.path,p)));return o.common.async?Promise.all(c).then(m=>i(m)):i(c)}min(e,r){return new t({...this._def,minSize:{value:e,message:h.toString(r)}})}max(e,r){return new t({...this._def,maxSize:{value:e,message:h.toString(r)}})}size(e,r){return this.min(e,r).max(e,r)}nonempty(e){return this.min(1,e)}};_e.create=(t,e)=>new _e({valueType:t,minSize:null,maxSize:null,typeName:g.ZodSet,...v(e)});var qe=class t extends k{constructor(){super(...arguments),this.validate=this.implement}_parse(e){let{ctx:r}=this._processInputParams(e);if(r.parsedType!==u.function)return l(r,{code:d.invalid_type,expected:u.function,received:r.parsedType}),f;function o(c,m){return Oe({data:c,path:r.path,errorMaps:[r.common.contextualErrorMap,r.schemaErrorMap,be(),D].filter(p=>!!p),issueData:{code:d.invalid_arguments,argumentsError:m}})}function n(c,m){return Oe({data:c,path:r.path,errorMaps:[r.common.contextualErrorMap,r.schemaErrorMap,be(),D].filter(p=>!!p),issueData:{code:d.invalid_return_type,returnTypeError:m}})}let a={errorMap:r.common.contextualErrorMap},i=r.data;if(this._def.returns instanceof J){let c=this;return R(async function(...m){let p=new I([]),y=await c._def.args.parseAsync(m,a).catch(ge=>{throw p.addIssue(o(m,ge)),p}),w=await Reflect.apply(i,this,y);return await c._def.returns._def.type.parseAsync(w,a).catch(ge=>{throw p.addIssue(n(w,ge)),p})})}else{let c=this;return R(function(...m){let p=c._def.args.safeParse(m,a);if(!p.success)throw new I([o(m,p.error)]);let y=Reflect.apply(i,this,p.data),w=c._def.returns.safeParse(y,a);if(!w.success)throw new I([n(y,w.error)]);return w.data})}}parameters(){return this._def.args}returnType(){return this._def.returns}args(...e){return new t({...this._def,args:B.create(e).rest(F.create())})}returns(e){return new t({...this._def,returns:e})}implement(e){return this.parse(e)}strictImplement(e){return this.parse(e)}static create(e,r,o){return new t({args:e||B.create([]).rest(F.create()),returns:r||F.create(),typeName:g.ZodFunction,...v(o)})}},ce=class extends k{get schema(){return this._def.getter()}_parse(e){let{ctx:r}=this._processInputParams(e);return this._def.getter()._parse({data:r.data,path:r.path,parent:r})}};ce.create=(t,e)=>new ce({getter:t,typeName:g.ZodLazy,...v(e)});var de=class extends k{_parse(e){if(e.data!==this._def.value){let r=this._getOrReturnCtx(e);return l(r,{received:r.data,code:d.invalid_literal,expected:this._def.value}),f}return{status:\"valid\",value:e.data}}get value(){return this._def.value}};de.create=(t,e)=>new de({value:t,typeName:g.ZodLiteral,...v(e)});function Ct(t,e){return new le({values:t,typeName:g.ZodEnum,...v(e)})}var le=class t extends k{_parse(e){if(typeof e.data!=\"string\"){let r=this._getOrReturnCtx(e),o=this._def.values;return l(r,{expected:x.joinValues(o),received:r.parsedType,code:d.invalid_type}),f}if(this._cache||(this._cache=new Set(this._def.values)),!this._cache.has(e.data)){let r=this._getOrReturnCtx(e),o=this._def.values;return l(r,{received:r.data,code:d.invalid_enum_value,options:o}),f}return R(e.data)}get options(){return this._def.values}get enum(){let e={};for(let r of this._def.values)e[r]=r;return e}get Values(){let e={};for(let r of this._def.values)e[r]=r;return e}get Enum(){let e={};for(let r of this._def.values)e[r]=r;return e}extract(e,r=this._def){return t.create(e,{...this._def,...r})}exclude(e,r=this._def){return t.create(this.options.filter(o=>!e.includes(o)),{...this._def,...r})}};le.create=Ct;var ue=class extends k{_parse(e){let r=x.getValidEnumValues(this._def.values),o=this._getOrReturnCtx(e);if(o.parsedType!==u.string&&o.parsedType!==u.number){let n=x.objectValues(r);return l(o,{expected:x.joinValues(n),received:o.parsedType,code:d.invalid_type}),f}if(this._cache||(this._cache=new Set(x.getValidEnumValues(this._def.values))),!this._cache.has(e.data)){let n=x.objectValues(r);return l(o,{received:o.data,code:d.invalid_enum_value,options:n}),f}return R(e.data)}get enum(){return this._def.values}};ue.create=(t,e)=>new ue({values:t,typeName:g.ZodNativeEnum,...v(e)});var J=class extends k{unwrap(){return this._def.type}_parse(e){let{ctx:r}=this._processInputParams(e);if(r.parsedType!==u.promise&&r.common.async===!1)return l(r,{code:d.invalid_type,expected:u.promise,received:r.parsedType}),f;let o=r.parsedType===u.promise?r.data:Promise.resolve(r.data);return R(o.then(n=>this._def.type.parseAsync(n,{path:r.path,errorMap:r.common.contextualErrorMap})))}};J.create=(t,e)=>new J({type:t,typeName:g.ZodPromise,...v(e)});var P=class extends k{innerType(){return this._def.schema}sourceType(){return this._def.schema._def.typeName===g.ZodEffects?this._def.schema.sourceType():this._def.schema}_parse(e){let{status:r,ctx:o}=this._processInputParams(e),n=this._def.effect||null,a={addIssue:i=>{l(o,i),i.fatal?r.abort():r.dirty()},get path(){return o.path}};if(a.addIssue=a.addIssue.bind(a),n.type===\"preprocess\"){let i=n.transform(o.data,a);if(o.common.async)return Promise.resolve(i).then(async c=>{if(r.value===\"aborted\")return f;let m=await this._def.schema._parseAsync({data:c,path:o.path,parent:o});return m.status===\"aborted\"?f:m.status===\"dirty\"?Q(m.value):r.value===\"dirty\"?Q(m.value):m});{if(r.value===\"aborted\")return f;let c=this._def.schema._parseSync({data:i,path:o.path,parent:o});return c.status===\"aborted\"?f:c.status===\"dirty\"?Q(c.value):r.value===\"dirty\"?Q(c.value):c}}if(n.type===\"refinement\"){let i=c=>{let m=n.refinement(c,a);if(o.common.async)return Promise.resolve(m);if(m instanceof Promise)throw new Error(\"Async refinement encountered during synchronous parse operation. Use .parseAsync instead.\");return c};if(o.common.async===!1){let c=this._def.schema._parseSync({data:o.data,path:o.path,parent:o});return c.status===\"aborted\"?f:(c.status===\"dirty\"&&r.dirty(),i(c.value),{status:r.value,value:c.value})}else return this._def.schema._parseAsync({data:o.data,path:o.path,parent:o}).then(c=>c.status===\"aborted\"?f:(c.status===\"dirty\"&&r.dirty(),i(c.value).then(()=>({status:r.value,value:c.value}))))}if(n.type===\"transform\")if(o.common.async===!1){let i=this._def.schema._parseSync({data:o.data,path:o.path,parent:o});if(!K(i))return f;let c=n.transform(i.value,a);if(c instanceof Promise)throw new Error(\"Asynchronous transform encountered during synchronous parse operation. Use .parseAsync instead.\");return{status:r.value,value:c}}else return this._def.schema._parseAsync({data:o.data,path:o.path,parent:o}).then(i=>K(i)?Promise.resolve(n.transform(i.value,a)).then(c=>({status:r.value,value:c})):f);x.assertNever(n)}};P.create=(t,e,r)=>new P({schema:t,typeName:g.ZodEffects,effect:e,...v(r)});P.createWithPreprocess=(t,e,r)=>new P({schema:e,effect:{type:\"preprocess\",transform:t},typeName:g.ZodEffects,...v(r)});var N=class extends k{_parse(e){return this._getType(e)===u.undefined?R(void 0):this._def.innerType._parse(e)}unwrap(){return this._def.innerType}};N.create=(t,e)=>new N({innerType:t,typeName:g.ZodOptional,...v(e)});var z=class extends k{_parse(e){return this._getType(e)===u.null?R(null):this._def.innerType._parse(e)}unwrap(){return this._def.innerType}};z.create=(t,e)=>new z({innerType:t,typeName:g.ZodNullable,...v(e)});var pe=class extends k{_parse(e){let{ctx:r}=this._processInputParams(e),o=r.data;return r.parsedType===u.undefined&&(o=this._def.defaultValue()),this._def.innerType._parse({data:o,path:r.path,parent:r})}removeDefault(){return this._def.innerType}};pe.create=(t,e)=>new pe({innerType:t,typeName:g.ZodDefault,defaultValue:typeof e.default==\"function\"?e.default:()=>e.default,...v(e)});var me=class extends k{_parse(e){let{ctx:r}=this._processInputParams(e),o={...r,common:{...r.common,issues:[]}},n=this._def.innerType._parse({data:o.data,path:o.path,parent:{...o}});return ve(n)?n.then(a=>({status:\"valid\",value:a.status===\"valid\"?a.value:this._def.catchValue({get error(){return new I(o.common.issues)},input:o.data})})):{status:\"valid\",value:n.status===\"valid\"?n.value:this._def.catchValue({get error(){return new I(o.common.issues)},input:o.data})}}removeCatch(){return this._def.innerType}};me.create=(t,e)=>new me({innerType:t,typeName:g.ZodCatch,catchValue:typeof e.catch==\"function\"?e.catch:()=>e.catch,...v(e)});var Re=class extends k{_parse(e){if(this._getType(e)!==u.nan){let o=this._getOrReturnCtx(e);return l(o,{code:d.invalid_type,expected:u.nan,received:o.parsedType}),f}return{status:\"valid\",value:e.data}}};Re.create=t=>new Re({typeName:g.ZodNaN,...v(t)});var mo=Symbol(\"zod_brand\"),Ne=class extends k{_parse(e){let{ctx:r}=this._processInputParams(e),o=r.data;return this._def.type._parse({data:o,path:r.path,parent:r})}unwrap(){return this._def.type}},$e=class t extends k{_parse(e){let{status:r,ctx:o}=this._processInputParams(e);if(o.common.async)return(async()=>{let a=await this._def.in._parseAsync({data:o.data,path:o.path,parent:o});return a.status===\"aborted\"?f:a.status===\"dirty\"?(r.dirty(),Q(a.value)):this._def.out._parseAsync({data:a.value,path:o.path,parent:o})})();{let n=this._def.in._parseSync({data:o.data,path:o.path,parent:o});return n.status===\"aborted\"?f:n.status===\"dirty\"?(r.dirty(),{status:\"dirty\",value:n.value}):this._def.out._parseSync({data:n.value,path:o.path,parent:o})}}static create(e,r){return new t({in:e,out:r,typeName:g.ZodPipeline})}},he=class extends k{_parse(e){let r=this._def.innerType._parse(e),o=n=>(K(n)&&(n.value=Object.freeze(n.value)),n);return ve(r)?r.then(n=>o(n)):o(r)}unwrap(){return this._def.innerType}};he.create=(t,e)=>new he({innerType:t,typeName:g.ZodReadonly,...v(e)});function St(t,e){let r=typeof t==\"function\"?t(e):typeof t==\"string\"?{message:t}:t;return typeof r==\"string\"?{message:r}:r}function At(t,e={},r){return t?X.create().superRefine((o,n)=>{let a=t(o);if(a instanceof Promise)return a.then(i=>{if(!i){let c=St(e,o),m=c.fatal??r??!0;n.addIssue({code:\"custom\",...c,fatal:m})}});if(!a){let i=St(e,o),c=i.fatal??r??!0;n.addIssue({code:\"custom\",...i,fatal:c})}}):X.create()}var ho={object:O.lazycreate},g;(function(t){t.ZodString=\"ZodString\",t.ZodNumber=\"ZodNumber\",t.ZodNaN=\"ZodNaN\",t.ZodBigInt=\"ZodBigInt\",t.ZodBoolean=\"ZodBoolean\",t.ZodDate=\"ZodDate\",t.ZodSymbol=\"ZodSymbol\",t.ZodUndefined=\"ZodUndefined\",t.ZodNull=\"ZodNull\",t.ZodAny=\"ZodAny\",t.ZodUnknown=\"ZodUnknown\",t.ZodNever=\"ZodNever\",t.ZodVoid=\"ZodVoid\",t.ZodArray=\"ZodArray\",t.ZodObject=\"ZodObject\",t.ZodUnion=\"ZodUnion\",t.ZodDiscriminatedUnion=\"ZodDiscriminatedUnion\",t.ZodIntersection=\"ZodIntersection\",t.ZodTuple=\"ZodTuple\",t.ZodRecord=\"ZodRecord\",t.ZodMap=\"ZodMap\",t.ZodSet=\"ZodSet\",t.ZodFunction=\"ZodFunction\",t.ZodLazy=\"ZodLazy\",t.ZodLiteral=\"ZodLiteral\",t.ZodEnum=\"ZodEnum\",t.ZodEffects=\"ZodEffects\",t.ZodNativeEnum=\"ZodNativeEnum\",t.ZodOptional=\"ZodOptional\",t.ZodNullable=\"ZodNullable\",t.ZodDefault=\"ZodDefault\",t.ZodCatch=\"ZodCatch\",t.ZodPromise=\"ZodPromise\",t.ZodBranded=\"ZodBranded\",t.ZodPipeline=\"ZodPipeline\",t.ZodReadonly=\"ZodReadonly\"})(g||(g={}));var fo=(t,e={message:`Input not instance of ${t.name}`})=>At(r=>r instanceof t,e),Tt=Y.create,Et=ee.create,go=Re.create,yo=te.create,It=re.create,bo=oe.create,vo=xe.create,ko=ne.create,xo=ae.create,So=X.create,wo=F.create,_o=j.create,Ro=Se.create,Co=H.create,Ao=O.create,To=O.strictCreate,Eo=se.create,Io=Ve.create,Oo=ie.create,No=B.create,$o=De.create,Po=we.create,jo=_e.create,Mo=qe.create,Lo=ce.create,Bo=de.create,zo=le.create,Vo=ue.create,Do=J.create,qo=P.create,Fo=N.create,Ho=z.create,Wo=P.createWithPreprocess,Zo=$e.create,Uo=()=>Tt().optional(),Ko=()=>Et().optional(),Yo=()=>It().optional(),Xo={string:(t=>Y.create({...t,coerce:!0})),number:(t=>ee.create({...t,coerce:!0})),boolean:(t=>re.create({...t,coerce:!0})),bigint:(t=>te.create({...t,coerce:!0})),date:(t=>oe.create({...t,coerce:!0}))};var Jo=f;var Ot=s.string().max(40).regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),Go=24,Qo=s.enum([\"viewport\",\"main\",\"home-hero\",\"suggestions\"]),ot=s.enum([\"top-left\",\"top-center\",\"top-right\",\"center-left\",\"center\",\"center-right\",\"bottom-left\",\"bottom-center\",\"bottom-right\"]),en=s.discriminatedUnion(\"kind\",[s.object({kind:s.literal(\"portrait\")}).strict(),s.object({kind:s.literal(\"decoration\"),assetKey:Ot}).strict()]),tn=s.object({id:Ot,asset:en,surface:Qo,anchor:ot,positionX:s.number().min(0).max(1),positionY:s.number().min(0).max(1),width:s.number().min(.02).max(1.5),opacity:s.number().min(0).max(1),rotation:s.number().min(-180).max(180),required:s.boolean()}).strict(),nt=s.object({layers:s.array(tn).max(Go)}).strict().superRefine((t,e)=>{let r=t.layers.map(o=>o.id);new Set(r).size!==r.length&&e.addIssue({code:s.ZodIssueCode.custom,path:[\"layers\"],message:\"composition layer IDs must be unique\"})});function Nt(t){return{...t,positionXPercent:t.positionX*100,positionYPercent:t.positionY*100,widthPercent:t.width*100,rotationDeg:t.rotation}}var $t=[\".png\",\".jpg\",\".jpeg\",\".webp\"],Pt=[\".woff2\"],rn=/^#[0-9a-f]{6}$/i,on=/^(rgb|rgba)\\(\\s*(\\d{1,3})\\s*,\\s*(\\d{1,3})\\s*,\\s*(\\d{1,3})(?:\\s*,\\s*(0|1|0?\\.\\d+))?\\s*\\)$/i,jt=/^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\\..*)?$/i,nn=/[<>:\"|?*\\u0000-\\u001f]/,an=s.string().min(3).max(80).regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/).refine(t=>!jt.test(t),\"theme ID is reserved on Windows\"),sn=s.string().regex(/^(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)$/);function cn(t){if(!t||t.length>240||t!==t.normalize(\"NFC\")||t.includes(\"\\\\\")||t.includes(\"\\0\")||t.startsWith(\"/\")||/^[a-z]:\\//i.test(t)||t.split(\"/\").some(o=>!o||o===\".\"||o===\"..\"||o.endsWith(\".\")||o.endsWith(\" \")||jt.test(o)||nn.test(o)))return!1;let r=t.toLowerCase();return $t.some(o=>r.endsWith(o))?t.startsWith(\"assets/\"):Pt.some(o=>r.endsWith(o))?t.startsWith(\"fonts/\"):!1}var Mt=s.string().refine(cn,\"unsafe or unsupported theme asset path\"),W=Mt.refine(t=>$t.some(e=>t.toLowerCase().endsWith(e)),\"theme image must be PNG, JPEG, or WebP\"),dn=Mt.refine(t=>Pt.some(e=>t.toLowerCase().endsWith(e)),\"theme font must be WOFF2\"),fe=s.string().max(40).regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),ln=s.string().url().max(500).refine(t=>new URL(t).protocol===\"https:\",\"theme URL must use HTTPS\"),Lt=s.enum([\"zh-CN\",\"en\"]);var un=\"{projectName}\",pn=/[{}]/,mn=/<[^>]*>|\\[[^\\]]+\\]\\([^)]+\\)|\\x60{1,3}[^\\x60]*\\x60{1,3}|\\*\\*|__|~~|(?:^|\\s)#{1,6}\\s/,hn=/(?:^|[;{])\\s*(?:color|background(?:-color)?|font(?:-family|-size|-weight)?|position|display|opacity|z-index)\\s*:/i,fn=/\\\\(?:n|r|t|u|x)/,gn=s.string().transform(t=>t.trim()).superRefine((t,e)=>{let r=t.replaceAll(un,\"\");([...t].length<1||[...t].length>120)&&e.addIssue({code:s.ZodIssueCode.custom,message:\"welcome line must contain 1-120 code points\"}),(pn.test(r)||mn.test(t)||hn.test(t)||fn.test(t))&&e.addIssue({code:s.ZodIssueCode.custom,message:\"welcome line must be plain text with only {projectName}\"})}),yn=s.object({lines:s.array(gn).min(1).max(3)}).strict().superRefine(({lines:t},e)=>{t.reduce((o,n)=>o+[...n].length,0)>240&&e.addIssue({code:s.ZodIssueCode.custom,path:[\"lines\"],message:\"welcome text exceeds 240 code points\"})}),Bt=s.object({welcome:s.object({localized:s.record(Lt,yn).refine(t=>Object.keys(t).length>0,\"welcome requires at least one locale\"),layout:s.object({anchor:ot,positionX:s.number().min(0).max(1),positionY:s.number().min(0).max(1),width:s.number().min(.2).max(1),textAlign:s.enum([\"left\",\"center\",\"right\"]),hideNativeIcon:s.boolean().default(!1)}).strict().optional()}).strict()}).strict(),Ia=s.enum([\"card1\",\"card2\",\"card3\",\"card4\"]),bn=s.object({homepage:ln.optional(),localized:s.record(Lt,s.object({name:s.string().trim().min(1).max(80).optional(),description:s.string().trim().min(1).max(240).optional()}).strict()).optional()}).strict();function vn(t){if(rn.test(t))return!0;let e=on.exec(t);if(!e)return!1;let r=e[1];if(!r)return!1;let o=e.slice(2,5).map(Number),n=e[5];return o.every(a=>a>=0&&a<=255)&&r.toLowerCase()===\"rgba\"==(n!==void 0)}var A=s.string().refine(vn,\"invalid color\"),zt={baseOpacity:.68,elevatedOpacity:.92,terminalOpacity:.82,blur:0},Vt=s.object({accent:A,secondary:A,text:A,muted:A,panel:A,border:A,success:A,warning:A,danger:A,info:A}).strict(),kn=Vt.extend({textSecondary:A,link:A,inputText:A,placeholder:A,codeText:A}).strict(),Pe=[\"sidebar\",\"topbar\",\"hero\",\"suggestions\",\"project-picker\",\"composer\",\"task-background\",\"content-layer\"],at=[\"project-picker\"],xn=s.object({id:s.enum(Pe),order:s.number().int().min(0).max(Pe.length-1),visible:s.boolean(),size:s.enum([\"compact\",\"regular\",\"expanded\"]),align:s.enum([\"start\",\"center\",\"end\",\"stretch\"]),spacing:s.number().int().min(0).max(48)}).strict();var Sn=s.object({heroHeight:s.number().int().min(180).max(560),cardColumns:s.number().int().min(2).max(4),composerWidth:s.number().min(.5).max(1),sidebarDensity:s.enum([\"compact\",\"comfortable\"]),moduleGap:s.number().int().min(0).max(48),modules:s.array(xn).length(Pe.length)}).strict().superRefine((t,e)=>{let r=t.modules.map(n=>n.id),o=t.modules.map(n=>n.order);new Set(r).size!==Pe.length&&e.addIssue({code:s.ZodIssueCode.custom,path:[\"modules\"],message:\"layout must contain each module exactly once\"}),new Set(o).size!==Pe.length&&e.addIssue({code:s.ZodIssueCode.custom,path:[\"modules\"],message:\"layout module order values must be unique\"});for(let n of[\"sidebar\",\"topbar\",...at,\"composer\",\"content-layer\"])t.modules.find(a=>a.id===n)?.visible||e.addIssue({code:s.ZodIssueCode.custom,path:[\"modules\"],message:`protected module must remain visible: ${n}`})}),Dt=s.object({background:W.optional(),portrait:W.optional(),decorations:s.record(fe,W).optional(),fonts:s.record(fe,dn).optional()}).strict(),wn=Dt.extend({profileAvatar:W.optional(),suggestionIcons:s.object({card1:W.optional(),card2:W.optional(),card3:W.optional(),card4:W.optional()}).strict().optional(),projectIcons:s.array(W).min(1).max(12).optional()}).strict();var qt=s.object({profileAvatarSize:s.number().int().min(16).max(48),suggestionIconSize:s.number().int().min(16).max(64),projectIconSize:s.number().int().min(12).max(32)}).strict(),st=s.object({uiFamily:s.string().trim().min(1).max(120),codeFamily:s.string().trim().min(1).max(120),uiFontAssetKey:fe.optional(),codeFontAssetKey:fe.optional(),scale:s.number().min(.85).max(1.3),uiSize:s.number().min(12).max(22),codeSize:s.number().min(11).max(22),uiWeight:s.union([s.literal(400),s.literal(500),s.literal(600),s.literal(700)]),codeWeight:s.union([s.literal(400),s.literal(500),s.literal(600),s.literal(700)]),lineHeight:s.number().min(1.2).max(1.8)}).strict(),Ft=s.union([s.literal(400),s.literal(500),s.literal(600),s.literal(700)]),_n=st.extend({displayFamily:s.string().trim().min(1).max(120).optional(),displayFontAssetKey:fe.optional(),displaySize:s.number().min(20).max(72).optional(),displayWeight:Ft.optional(),displayLineHeight:s.number().min(1.1).max(1.8).optional(),displayLetterSpacing:s.number().min(-.05).max(.2).optional()}).strict(),Rn=st.extend({displayFamily:s.string().trim().min(1).max(120),displayFontAssetKey:fe.optional(),displaySize:s.number().min(20).max(72),displayWeight:Ft,displayLineHeight:s.number().min(1.1).max(1.8),displayLetterSpacing:s.number().min(-.05).max(.2)}).strict(),it=s.object({schemaVersion:s.literal(3),kind:s.enum([\"theme\",\"recipe\"]),appearance:s.enum([\"auto\",\"light\",\"dark\"]).default(\"auto\"),id:an,name:s.string().trim().min(1).max(80),description:s.string().trim().min(1).max(240).optional(),version:sn,author:s.string().trim().min(1).max(80),metadata:bn.optional(),assets:wn,colors:kn,typography:st,background:s.object({positionX:s.number().min(0).max(1),positionY:s.number().min(0).max(1),scale:s.number().min(.5).max(3),blur:s.number().min(0).max(30),brightness:s.number().min(.3).max(1.5),overlay:s.number().min(0).max(.9),safeArea:s.enum([\"auto\",\"left\",\"center\",\"right\",\"none\"]).default(\"auto\"),taskMode:s.enum([\"auto\",\"full\",\"ambient\",\"banner\",\"off\"]).default(\"full\"),taskOpacity:s.number().min(0).max(1).default(.82)}).strict(),surfaces:s.object({baseOpacity:s.number().min(0).max(1),elevatedOpacity:s.number().min(0).max(1),terminalOpacity:s.number().min(0).max(1),blur:s.number().min(0).max(30)}).strict().default(zt),decorations:s.array(s.object({type:s.enum([\"particles\",\"ribbon\",\"butterflies\",\"polaroid\",\"badge\",\"sparkles\",\"image\"]),enabled:s.boolean(),intensity:s.number().min(0).max(1),assetKey:fe.optional(),placement:s.enum([\"background\",\"corners\",\"hero\",\"cards\"]).optional(),opacity:s.number().min(0).max(1).optional(),scale:s.number().min(.25).max(3).optional()}).strict()).max(16),layout:Sn,rights:s.object({licenseId:s.string().trim().min(1).max(100),attribution:s.string().trim().min(1).max(240).optional(),source:s.string().url().max(500).optional(),localOnly:s.boolean()}).strict()}).strict(),Oa=it.extend({schemaVersion:s.literal(4),typography:_n,interfaceImages:qt.optional(),home:Bt.optional(),composition:nt.optional()}).strict(),Ht=it.extend({schemaVersion:s.literal(4),typography:Rn,interfaceImages:qt,home:Bt.optional(),composition:nt}).strict();function Wt(t,e,r){r&&t.kind===\"theme\"&&!t.assets.background&&e.addIssue({code:s.ZodIssueCode.custom,path:[\"assets\",\"background\"],message:\"theme requires background\"}),t.kind===\"recipe\"&&(!t.rights.localOnly||Object.keys(t.assets).length>0)&&e.addIssue({code:s.ZodIssueCode.custom,path:[\"rights\",\"localOnly\"],message:\"recipes must be local-only and contain no bundled assets\"}),t.decorations.forEach((o,n)=>{o.type===\"image\"&&!o.assetKey&&e.addIssue({code:s.ZodIssueCode.custom,path:[\"decorations\",n,\"assetKey\"],message:\"image decoration requires an asset key\"}),o.assetKey&&!t.assets.decorations?.[o.assetKey]&&e.addIssue({code:s.ZodIssueCode.custom,path:[\"decorations\",n,\"assetKey\"],message:\"decoration asset key is not declared\"})}),t.composition.layers.forEach((o,n)=>{(o.asset.kind===\"portrait\"?t.assets.portrait:t.assets.decorations?.[o.asset.assetKey])||e.addIssue({code:s.ZodIssueCode.custom,path:[\"composition\",\"layers\",n,\"asset\"],message:\"composition layer asset is not declared\"})});for(let o of[\"uiFontAssetKey\",\"codeFontAssetKey\",\"displayFontAssetKey\"]){let n=t.typography[o];n&&!t.assets.fonts?.[n]&&e.addIssue({code:s.ZodIssueCode.custom,path:[\"typography\",o],message:\"font asset key is not declared\"})}}var Na=Ht.superRefine((t,e)=>Wt(t,e,!1)),$a=Ht.superRefine((t,e)=>Wt(t,e,!0)),Cn=it.extend({schemaVersion:s.literal(2),assets:Dt}).strict(),Pa=Cn.extend({schemaVersion:s.literal(1),colors:Vt}).strict();var An=\"{projectName}\";function Zt(t){return t.map(e=>e.split(An).flatMap((r,o,n)=>[...r?[{kind:\"text\",value:r}]:[],...o=.58?\"dark\":\"light\"}function Nn(t){return t.background.safeArea!==\"auto\"?t.background.safeArea:t.background.positionX>=.6?\"left\":t.background.positionX<=.4?\"right\":\"center\"}function $n(t){return t.background.taskMode===\"auto\"?\"ambient\":t.background.taskMode}function Fe(t){return Math.round(t*1e4)/100}function je(t,e,r,o){return t?t===e?{path:t,source:\"background\",...Tn[r],sizePx:o}:{path:t,source:\"custom\",positionXPercent:50,positionYPercent:50,sizePx:o}:{source:\"default\",positionXPercent:50,positionYPercent:50,sizePx:o}}function Ut(t,e,r){if(t===\"none\")return e;let o=t===\"left\"?\"90deg\":t===\"right\"?\"270deg\":\"circle at center\";return`${t===\"center\"?`radial-gradient(${o},color-mix(in srgb,${r} 62%,transparent) 0 24%,transparent 72%)`:`linear-gradient(${o},color-mix(in srgb,${r} 68%,transparent) 0 28%,transparent 72%)`},${e}`}function Kt(t,e,r,o){return t===\"off\"?e:t===\"banner\"?`linear-gradient(to bottom,color-mix(in srgb,${e} ${o}%,transparent) 0 42%,color-mix(in srgb,${e} 96%,transparent) 72%)`:`color-mix(in srgb,${e} ${t===\"ambient\"?o:r}%,transparent)`}function Yt(t){let e=Object.fromEntries(t.layout.modules.map(n=>[n.id,{...n}])),r={};for(let n of[\"zh-CN\",\"en\"]){let a=t.home?.welcome.localized[n];a&&(r[n]=Zt(a.lines))}let o=Object.keys(r).length>0;return{appearance:{mode:t.appearance,resolved:On(t)},colors:{...t.colors},typography:{uiFamily:t.typography.uiFamily,codeFamily:t.typography.codeFamily,uiSize:t.typography.uiSize*t.typography.scale,codeSize:t.typography.codeSize,uiWeight:t.typography.uiWeight,codeWeight:t.typography.codeWeight,lineHeight:t.typography.lineHeight},displayTypography:{family:t.typography.displayFamily,...t.typography.displayFontAssetKey?{fontAssetKey:t.typography.displayFontAssetKey}:{},size:t.typography.displaySize,weight:t.typography.displayWeight,lineHeight:t.typography.displayLineHeight,letterSpacingEm:t.typography.displayLetterSpacing},...o?{welcome:{localized:r,...t.home?.welcome.layout?{layout:{anchor:t.home.welcome.layout.anchor,positionXPercent:t.home.welcome.layout.positionX*100,positionYPercent:t.home.welcome.layout.positionY*100,widthPercent:t.home.welcome.layout.width*100,textAlign:t.home.welcome.layout.textAlign,hideNativeIcon:t.home.welcome.layout.hideNativeIcon}}:{}}}:{},composition:{layers:t.composition.layers.map(n=>{let a=Nt(n);return{...a,asset:{...a.asset}}})},background:{positionXPercent:t.background.positionX*100,positionYPercent:t.background.positionY*100,scale:t.background.scale,blurPx:t.background.blur,brightness:t.background.brightness,overlayColor:`rgba(0,0,0,${t.background.overlay})`,safeArea:Nn(t),taskModeMode:t.background.taskMode,taskMode:$n(t),taskOpacityPercent:Fe(t.background.taskOpacity)},surfaces:{baseOpacityPercent:Fe(t.surfaces.baseOpacity),elevatedOpacityPercent:Fe(t.surfaces.elevatedOpacity),terminalOpacityPercent:Fe(t.surfaces.terminalOpacity),blurPx:t.surfaces.blur},interfaceImagery:{profileAvatar:je(t.assets.profileAvatar,t.assets.background,\"profileAvatar\",t.interfaceImages.profileAvatarSize),suggestionIcons:{card1:je(t.assets.suggestionIcons?.card1,t.assets.background,\"card1\",t.interfaceImages.suggestionIconSize),card2:je(t.assets.suggestionIcons?.card2,t.assets.background,\"card2\",t.interfaceImages.suggestionIconSize),card3:je(t.assets.suggestionIcons?.card3,t.assets.background,\"card3\",t.interfaceImages.suggestionIconSize),card4:je(t.assets.suggestionIcons?.card4,t.assets.background,\"card4\",t.interfaceImages.suggestionIconSize)},projectIcons:(t.assets.projectIcons??[]).map(n=>({path:n,source:n===t.assets.background?\"background\":\"custom\",positionXPercent:50,positionYPercent:50,sizePx:t.interfaceImages.projectIconSize}))},layout:{...t.layout,modules:t.layout.modules.map(n=>({...n}))},modules:e}}var V=class extends Error{constructor(r,o){super(o);this.code=r;this.name=\"RuntimeThemeError\"}code};var Ce={shellMain:\"main.main-surface,main,[role=main]\",sidebar:\"aside.app-shell-left-panel,aside,nav,[role=navigation]\",applicationMenu:'[class~=\"group/application-menu-top-bar\"]',modeSwitcher:'[role=\"group\"][aria-label=\"Composer mode\"]',homeMarker:'[data-testid=\"home-icon\"]',routeMain:'[role=\"main\"]',terminal:'[role=\"terminal\"],.xterm,[class*=\"terminal\"]',scrollFade:[\".app-shell-main-content-top-fade\",'[class*=\"scroll-fade\"]','[class*=\"scrollFade\"]','[class*=\"top-fade\"]','[class*=\"topFade\"]','[class*=\"bottom-fade\"]','[class*=\"bottomFade\"]'].join(\",\"),overlay:['[role=\"menu\"]','[role=\"dialog\"]','[role=\"alertdialog\"]','[role=\"listbox\"]',\"[data-radix-popper-content-wrapper]\"].join(\",\")};function S(...t){return t.map(e=>`[data-open-chatgpt-skin-surface=\"${e}\"]`).join(\",\")}var Pn=8*1024*1024;function jn(t){if(t>Pn)throw new V(\"THEME_RUNTIME_TOO_LARGE\",\"compiled theme exceeds 8 MiB\")}function Mn(t){let e=0;for(;;){let r={...t,totalBytes:e},o=Buffer.byteLength(JSON.stringify(r));if(o===e)return r;e=o}}function He(t){return`\"${t.replaceAll(\"\\\\\",\"\\\\\\\\\").replaceAll('\"','\\\\\"').replaceAll(\"\\r\",\"\\\\d \").replaceAll(`\n`,\"\\\\a \")}\"`}function Ln(t){let e=t.toLowerCase();if(e.endsWith(\".png\"))return\"image/png\";if(e.endsWith(\".jpg\")||e.endsWith(\".jpeg\"))return\"image/jpeg\";if(e.endsWith(\".webp\"))return\"image/webp\";if(e.endsWith(\".woff2\"))return\"font/woff2\";throw new V(\"ASSET_UNSUPPORTED\",`unsupported runtime asset: ${t}`)}function Xt(t,e){return`data:${Ln(t)};base64,${Buffer.from(e).toString(\"base64\")}`}function Bn(t,e){let r=t.theme,o=r.decorations.filter(a=>a.enabled).map(a=>{let i;if(a.assetKey){let c=r.assets.decorations?.[a.assetKey];if(!c)throw new V(\"ASSET_MISSING\",a.assetKey);i=e(c)}return{kind:a.type,count:Math.round(a.intensity*24),opacity:a.opacity??Math.min(.5,.12+a.intensity*.3),scale:a.scale??1,placement:a.placement??\"background\",...i!==void 0?{asset:i}:{}}}).filter(a=>a.count>0),n=r.assets.portrait;return n&&o.unshift({kind:\"image\",count:1,opacity:.92,scale:1.4,placement:\"hero\",asset:e(n)}),o}function zn(t){return t.align===\"center\"?\"margin-inline:auto!important;\":t.align===\"end\"?\"margin-left:auto!important;\":t.align===\"stretch\"?\"\":\"margin-right:auto!important;\"}function Vn(t){return t.size===\"compact\"?\"max-width:78%!important;\":t.size===\"expanded\"?\"max-width:100%!important;\":\"\"}function Dn(t){return t.map(e=>{if(at.some(n=>n===e.id)||e.id===\"sidebar\"||e.id===\"topbar\"||e.id===\"task-background\"||e.id===\"content-layer\")return\"\";let r=`[data-open-chatgpt-skin-surface=\"${e.id}\"]`;if(!e.visible)return`${r}{display:none!important;}`;let o=`margin-bottom:${e.spacing}px!important;${zn(e)}${Vn(e)}`;return o?`${r}{${o}}`:\"\"}).join(\"\")}function Ae(t,e,r=\"where\"){return t.map(o=>`${o} :${r}(${e})`).join(\",\")}function Jt(t){let e=t.theme.assets.background;if(t.theme.kind!==\"theme\"||!e)throw new V(\"THEME_RUNTIME_UNSUPPORTED\",\"runtime requires a complete theme\");let r=t.theme,o=Yt(r),n=[],a=new Map,i=b=>{let E=t.files.get(b);if(!E)throw new V(\"ASSET_MISSING\",b);return Xt(b,E)},c=b=>{let E=a.get(b);if(E!==void 0)return E;let Ie=n.length;return a.set(b,Ie),n.push(i(b)),Ie},m=Ut(o.background.safeArea,o.background.overlayColor,\"var(--ocs-panel)\"),p=Kt(o.background.taskMode,\"var(--ocs-panel)\",o.surfaces.baseOpacityPercent,o.background.taskOpacityPercent),y=S(\"task\"),w=S(\"workbench\"),M=S(\"workspace-panel\"),ge=S(\"resource-card\"),ut=S(\"terminal\"),Ue=S(\"top-fade\"),Ke=S(\"scroll-fade\"),Ye=S(\"mode-switcher\"),br=S(\"mode-switcher-track\"),vr=S(\"mode-switcher-selection\"),ye=S(\"feature-page\"),Xe=S(\"feature-toolbar\"),Je=S(\"feature-search\"),G=S(\"composer\"),Ge=S(\"composer-chrome\"),pt=S(\"project-picker-stack\"),Z=S(\"status-banner\"),T=S(\"settings\"),C=S(\"settings-panel\"),U=S(\"overlay\"),mt=S(\"application-menu\"),ht=[y,w,M],Ee=[y,w],kr=Ae(ht,\".text-token-foreground,.text-token-text-primary\",\"is\"),xr=Ae(ht,\".text-token-description-foreground,.text-token-text-secondary,.text-token-text-tertiary,.text-token-muted-foreground\",\"is\"),Sr=Ae(Ee,\".text-token-conversation-body\",\"is\"),wr=Ae(Ee,'[class*=\"_markdownContent_\"],[class*=\"_markdownText_\"]',\"is\"),_r=Ee.map(b=>`${b} :is([class*=\"_markdownContent_\"],[class*=\"_markdownText_\"]) :where(p,li,ul,ol,blockquote)`).join(\",\"),Rr=Ee.map(b=>`${b} :is(.text-token-text-secondary,.text-token-text-tertiary) :where(.text-token-conversation-body,svg,path)`).join(\",\"),Cr=Ae(Ee,'[class*=\"bg-token-text-code-block-background\"],[class*=\"_codeBlock_\"]',\"is\"),Ar=['[class*=\"bg-token-main-surface\"]','[class*=\"bg-token-dropdown-background\"]','[class*=\"bg-token-bg-fog\"]','[class*=\"bg-token-list-\"]','[class*=\"bg-token-input-background\"]',\".file-tree-container\",\"file-tree-container\",\".diffs-container\",\"diffs-container\",\".codex-review-diff-card\",'[class*=\"app-shell-tab-background\"]','[class*=\"sticky\"][class*=\"backdrop-blur\"]'].join(\",\"),Tr=[w,M].map(b=>`${b} :is(${Ar}):not(${M})`).join(\",\"),Er=Ae([T,C],\"div,section,article,span,p,label,small,li,dd,dt,th,td\"),Ir=[\"@layer base{\",'[data-open-chatgpt-skin-surface=\"sidebar\"] :is(','[class*=\"text-token-input-placeholder-foreground\"],','[class*=\"text-token-muted-foreground\"],[class*=\"text-token-text-tertiary\"]){',\"color:var(--ocs-text-secondary)!important;}\",`${y} :is([class*=\"text-token-foreground\"],[class*=\"text-token-text-primary\"]),`,`${w} :is([class*=\"text-token-foreground\"],[class*=\"text-token-text-primary\"]),`,`${M} :is([class*=\"text-token-foreground\"],[class*=\"text-token-text-primary\"]){`,\"color:var(--ocs-text)!important;}\",`${y} :is([class*=\"text-token-description-foreground\"],`,'[class*=\"text-token-text-secondary\"],[class*=\"text-token-text-tertiary\"],','[class*=\"text-token-muted-foreground\"]),',`${w} :is([class*=\"text-token-description-foreground\"],`,'[class*=\"text-token-text-secondary\"],[class*=\"text-token-text-tertiary\"],','[class*=\"text-token-muted-foreground\"]),',`${M} :is([class*=\"text-token-description-foreground\"],`,'[class*=\"text-token-text-secondary\"],[class*=\"text-token-text-tertiary\"],','[class*=\"text-token-muted-foreground\"]){color:var(--ocs-text-secondary)!important;}',`${y} :is([class*=\"text-token-conversation-body\"]),`,`${w} :is([class*=\"text-token-conversation-body\"]){`,\"color:inherit!important;}\",`${y} :is([class*=\"_markdownContent_\"],[class*=\"_markdownText_\"]),`,`${w} :is([class*=\"_markdownContent_\"],[class*=\"_markdownText_\"]){`,\"color:var(--ocs-text)!important;}\",`${T} :is([class*=\"text-token-foreground\"],[class*=\"text-token-text-primary\"]),`,`${C} :is([class*=\"text-token-foreground\"],[class*=\"text-token-text-primary\"]){`,\"color:var(--ocs-text)!important;}\",`${T} :is([class*=\"text-token-description-foreground\"],`,'[class*=\"text-token-text-secondary\"],[class*=\"text-token-text-tertiary\"],','[class*=\"text-token-muted-foreground\"],[class*=\"description\"],[class*=\"Description\"]),',`${C} :is([class*=\"text-token-description-foreground\"],`,'[class*=\"text-token-text-secondary\"],[class*=\"text-token-text-tertiary\"],','[class*=\"text-token-muted-foreground\"],[class*=\"description\"],[class*=\"Description\"]){',\"color:var(--ocs-text-secondary)!important;}\",`${T} :is([class*=\"bg-token-main-surface\"],[class*=\"bg-token-input-background\"],`,'[class*=\"bg-token-bg-fog\"],[class*=\"bg-token-dropdown-background\"],','[class*=\"bg-token-sidebar-surface\"],[class*=\"bg-token-elevated-surface\"],','[class*=\"bg-token-list-background\"]),',`${C} :is([class*=\"bg-token-main-surface\"],[class*=\"bg-token-input-background\"],`,'[class*=\"bg-token-bg-fog\"],[class*=\"bg-token-dropdown-background\"],','[class*=\"bg-token-sidebar-surface\"],[class*=\"bg-token-elevated-surface\"],','[class*=\"bg-token-list-background\"]){',\"background-color:color-mix(in srgb,var(--ocs-panel) var(--ocs-elevated-panel-mix),transparent)!important;}\",`${U} :is([class*=\"text-token-foreground\"],[class*=\"text-token-text-primary\"]){`,\"color:var(--ocs-text)!important;}\",`${U} :is([class*=\"text-token-description-foreground\"],`,'[class*=\"text-token-text-secondary\"],[class*=\"text-token-text-tertiary\"],','[class*=\"text-token-muted-foreground\"]){color:var(--ocs-text-secondary)!important;}',`${U} :is(.composer-surface-chrome,[class*=\"bg-token-input-background\"],`,'[class*=\"bg-token-dropdown-background\"]){',\"color:var(--ocs-input-text)!important;\",\"background-color:color-mix(in srgb,var(--ocs-panel) var(--ocs-elevated-panel-mix),transparent)!important;}\",\"}\"].join(\"\"),Or=i(e),Qe=b=>{if(b.path)return b.path===e?{asset:\"background\",positionXPercent:b.positionXPercent,positionYPercent:b.positionYPercent,sizePx:b.sizePx}:{asset:c(b.path),positionXPercent:b.positionXPercent,positionYPercent:b.positionYPercent,sizePx:b.sizePx}},ft=Qe(o.interfaceImagery.profileAvatar),Nr=Object.fromEntries(Object.entries(o.interfaceImagery.suggestionIcons).map(([b,E])=>[b,Qe(E)]).filter(b=>b[1]!==void 0)),gt=o.interfaceImagery.projectIcons.map(Qe).filter(b=>b!==void 0),$r={...ft?{profileAvatar:ft}:{},suggestionIcons:Nr,...gt.length>0?{projectIcons:gt}:{}},Pr=He(r.typography.uiFamily),yt=He(r.typography.codeFamily),jr=[`:root{--ocs-accent:${r.colors.accent};--ocs-secondary:${r.colors.secondary};`,`--ocs-text:${r.colors.text};--ocs-text-secondary:${r.colors.textSecondary};`,`--ocs-muted:${r.colors.muted};--ocs-link:${r.colors.link};`,`--ocs-input-text:${r.colors.inputText};--ocs-placeholder:${r.colors.placeholder};`,`--ocs-code-text:${r.colors.codeText};--ocs-panel:${r.colors.panel};`,`--ocs-border:${r.colors.border};--ocs-success:${r.colors.success};`,`--ocs-warning:${r.colors.warning};--ocs-danger:${r.colors.danger};`,`--ocs-info:${r.colors.info};--ocs-color-scheme:${o.appearance.resolved};`,`--ocs-module-gap:${o.layout.moduleGap}px;`,\"--ocs-home-overlap-relief:0px;\",`--ocs-composer-width:${o.layout.composerWidth*100}%;`,`--ocs-card-columns:${o.layout.cardColumns};--ocs-hero-height:${o.layout.heroHeight}px;`,`--ocs-background-x:${o.background.positionXPercent}%;`,`--ocs-background-y:${o.background.positionYPercent}%;`,`--ocs-background-scale:${o.background.scale};`,`--ocs-surface-panel-mix:${o.surfaces.baseOpacityPercent}%;`,`--ocs-task-panel-mix:${o.background.taskOpacityPercent}%;`,`--ocs-elevated-panel-mix:${o.surfaces.elevatedOpacityPercent}%;`,`--ocs-terminal-panel-mix:${o.surfaces.terminalOpacityPercent}%;`,`--ocs-surface-blur:${o.surfaces.blurPx}px;`,`color-scheme:${o.appearance.resolved}!important;}`,\"html{background:#17191a!important;}body{color:var(--ocs-text)!important;\",`font-family:${Pr}!important;position:relative!important;isolation:isolate!important;`,`font-size:${o.typography.uiSize}px!important;`,`font-weight:${o.typography.uiWeight}!important;`,`line-height:${o.typography.lineHeight}!important;background:transparent!important;}`,'body::before{content:\"\";position:fixed;inset:-32px;z-index:-3;pointer-events:none;',\"background-image:var(--ocs-background-image);\",\"background-position:var(--ocs-background-x) var(--ocs-background-y);\",\"background-size:cover;background-repeat:no-repeat;\",\"transform:scale(var(--ocs-background-scale));\",\"transform-origin:var(--ocs-background-x) var(--ocs-background-y);\",`filter:blur(${o.background.blurPx}px) brightness(${o.background.brightness});}`,'body::after{content:\"\";position:fixed;inset:0;z-index:-2;pointer-events:none;',`background:${m};}`,\"p,li,dd,dt{color:var(--ocs-text-secondary)!important;}\",\"small,[aria-description]{color:var(--ocs-muted)!important;}\",\"a,[role=link]{color:var(--ocs-link)!important;}\",\"input,textarea,[contenteditable=true],[role=textbox]{color:var(--ocs-input-text)!important;\",\"caret-color:var(--ocs-accent)!important;}\",\"input::placeholder,textarea::placeholder{color:var(--ocs-placeholder)!important;opacity:1!important;}\",`pre,code{color:var(--ocs-code-text)!important;font-family:${yt}!important;`,`font-size:${r.typography.codeSize}px!important;`,`font-weight:${r.typography.codeWeight}!important;}`,\"[role=alert]{color:var(--ocs-danger)!important;}\",\"[role=status]{color:var(--ocs-info)!important;}\",'[data-open-chatgpt-skin-surface=\"main\"]{backdrop-filter:none!important;}','[data-open-chatgpt-skin-surface=\"main\"],[data-open-chatgpt-skin-surface=\"sidebar\"]{',\"background-color:color-mix(in srgb,var(--ocs-panel) var(--ocs-surface-panel-mix),transparent)!important;\",\"border-color:var(--ocs-border)!important;box-shadow:none!important;}\",'[data-open-chatgpt-skin-surface=\"sidebar\"]{backdrop-filter:blur(var(--ocs-surface-blur))!important;}',`${y}{position:relative!important;isolation:isolate!important;`,`color:var(--ocs-text)!important;background:${p}!important;`,\"border-color:var(--ocs-border)!important;}\",`${y} :where(article,[data-message-author-role]){`,\"color:var(--ocs-text)!important;}\",`${w}{color:var(--ocs-text)!important;`,\"background:color-mix(in srgb,var(--ocs-panel) var(--ocs-surface-panel-mix),transparent)!important;\",\"border-color:var(--ocs-border)!important;box-shadow:none!important;\",\"backdrop-filter:blur(var(--ocs-surface-blur))!important;}\",`${M}{color:var(--ocs-text)!important;`,\"background:transparent!important;\",\"border-color:var(--ocs-border)!important;box-shadow:none!important;\",\"backdrop-filter:none!important;}\",`${Tr}{color:var(--ocs-text)!important;`,\"background-color:color-mix(in srgb,var(--ocs-panel) var(--ocs-elevated-panel-mix),transparent)!important;\",\"background-image:none!important;border-color:var(--ocs-border)!important;\",\"box-shadow:none!important;}\",`${kr}{color:var(--ocs-text)!important;}`,`${xr}{color:var(--ocs-text-secondary)!important;}`,`${Sr}{color:inherit!important;}`,`${wr}{color:var(--ocs-text)!important;}`,`${_r}{color:inherit!important;}`,`${Rr}{color:inherit!important;}`,`${Cr}{color:var(--ocs-code-text)!important;`,\"background-color:color-mix(in srgb,var(--ocs-panel) var(--ocs-elevated-panel-mix),transparent)!important;\",\"border-color:var(--ocs-border)!important;box-shadow:none!important;}\",`${ge}{color:var(--ocs-text)!important;`,\"background-color:color-mix(in srgb,var(--ocs-panel) var(--ocs-elevated-panel-mix),transparent)!important;\",\"background-image:none!important;border-color:var(--ocs-border)!important;\",\"box-shadow:none!important;backdrop-filter:blur(var(--ocs-surface-blur))!important;}\",`${ge} :where(button,[role=button],svg){color:inherit!important;}`,`${y} :where(th,td){color:var(--ocs-text)!important;}`,`${M} :where(button,[role=button],svg){`,\"color:inherit!important;}\",`${ut}{color:var(--ocs-code-text)!important;`,\"background:color-mix(in srgb,var(--ocs-panel) var(--ocs-terminal-panel-mix),transparent)!important;\",`border-color:var(--ocs-border)!important;font-family:${yt}!important;}`,`${ut} :where(.xterm,.xterm-viewport,.xterm-screen,pre,code){`,\"color:inherit!important;background:transparent!important;}\",`${T}{position:relative!important;isolation:isolate!important;`,\"color:var(--ocs-text)!important;\",\"background:color-mix(in srgb,var(--ocs-panel) var(--ocs-surface-panel-mix),transparent)!important;\",\"border-color:var(--ocs-border)!important;box-shadow:none!important;\",\"backdrop-filter:blur(var(--ocs-surface-blur))!important;}\",`${C}{color:var(--ocs-text)!important;`,\"background:color-mix(in srgb,var(--ocs-panel) var(--ocs-elevated-panel-mix),transparent)!important;\",\"border-color:var(--ocs-border)!important;box-shadow:none!important;\",\"backdrop-filter:blur(var(--ocs-surface-blur))!important;}\",`${Er}{color:inherit!important;}`,`${T} :where(h1,h2,h3,h4,h5,h6,[role=heading]),`,`${C} :where(h1,h2,h3,h4,h5,h6,[role=heading]){`,\"color:var(--ocs-text)!important;}\",`${T} :is(.text-token-text-primary,.text-token-foreground),`,`${C} :is(.text-token-text-primary,.text-token-foreground){`,\"color:var(--ocs-text)!important;}\",`${T} :is(.text-token-input-foreground),`,`${C} :is(.text-token-input-foreground){`,\"color:var(--ocs-input-text)!important;}\",`${T} :where(p,span,label,small),`,`${C} :where(p,span,label,small){color:var(--ocs-text-secondary)!important;}`,`${T} :is(.text-token-text-secondary,.text-token-description-foreground,.text-token-text-tertiary,.text-token-muted-foreground,[class*=\"description\"],[class*=\"Description\"]),`,`${C} :is(.text-token-text-secondary,.text-token-description-foreground,.text-token-text-tertiary,.text-token-muted-foreground,[class*=\"description\"],[class*=\"Description\"]){`,\"color:var(--ocs-text-secondary)!important;}\",`${T} :is(.text-token-input-placeholder-foreground),`,`${C} :is(.text-token-input-placeholder-foreground){`,\"color:var(--ocs-muted)!important;}\",`${T} :is(a,[role=link],.text-token-text-link-foreground),`,`${C} :is(a,[role=link],.text-token-text-link-foreground){`,\"color:var(--ocs-link)!important;}\",`${C} :where(button,[role=button],input,textarea,select,svg){`,\"color:inherit!important;border-color:var(--ocs-border)!important;}\",`${T} :is([class*=\"bg-token-main-surface\"],[class*=\"bg-token-input-background\"],`,'[class*=\"bg-token-bg-fog\"],[class*=\"bg-token-dropdown-background\"],','[class*=\"bg-token-sidebar-surface\"],[class*=\"bg-token-elevated-surface\"],','[class*=\"bg-token-list-background\"]),',`${C} :is([class*=\"bg-token-main-surface\"],[class*=\"bg-token-input-background\"],`,'[class*=\"bg-token-bg-fog\"],[class*=\"bg-token-dropdown-background\"],','[class*=\"bg-token-sidebar-surface\"],[class*=\"bg-token-elevated-surface\"],','[class*=\"bg-token-list-background\"]){',\"background-color:color-mix(in srgb,var(--ocs-panel) var(--ocs-elevated-panel-mix),transparent)!important;}\",`${T} :is([class*=\"bg-token-text-link-foreground\"]),`,`${C} :is([class*=\"bg-token-text-link-foreground\"]){`,\"background-color:var(--ocs-accent)!important;color:white!important;\",\"border-color:transparent!important;}\",`${U}{color:var(--ocs-text)!important;`,\"background:color-mix(in srgb,var(--ocs-panel) var(--ocs-elevated-panel-mix),transparent)!important;\",\"border-color:var(--ocs-border)!important;box-shadow:0 14px 44px rgba(0,0,0,.22)!important;\",\"backdrop-filter:blur(var(--ocs-surface-blur))!important;}\",`${U} :where(div,section,article,span,p,label,small,li,dd,dt,button,[role=menuitem],svg){`,\"color:inherit!important;border-color:var(--ocs-border)!important;}\",`${U} :is(.text-token-description-foreground,.text-token-text-secondary,`,\".text-token-text-tertiary,.text-token-muted-foreground){\",\"color:var(--ocs-text-secondary)!important;}\",`${U} :is(.composer-surface-chrome,[class*=\"bg-token-input-background\"],`,'[class*=\"bg-token-dropdown-background\"]){',\"color:var(--ocs-input-text)!important;\",\"background-color:color-mix(in srgb,var(--ocs-panel) var(--ocs-elevated-panel-mix),transparent)!important;\",\"border-color:var(--ocs-border)!important;}\",`${U} :where(input,textarea,[contenteditable=true],[role=textbox]){`,\"color:var(--ocs-input-text)!important;background:transparent!important;}\",`${mt}{color:var(--ocs-text-secondary)!important;`,\"background:color-mix(in srgb,var(--ocs-panel) var(--ocs-elevated-panel-mix),transparent)!important;\",\"border-color:var(--ocs-border)!important;box-shadow:none!important;\",\"backdrop-filter:blur(var(--ocs-surface-blur))!important;}\",`${mt} :where(button,svg){color:inherit!important;}`,`${Ye}{color:var(--ocs-text-secondary)!important;`,\"background:transparent!important;border-color:transparent!important;\",\"box-shadow:none!important;backdrop-filter:none!important;}\",`${Ye} :where(button){color:var(--ocs-text-secondary)!important;}`,`${Ye} :is(.text-token-text-primary,[class*=\"text-token-text-primary\"]){`,\"color:var(--ocs-text)!important;}\",`${br}{`,\"background:color-mix(in srgb,var(--ocs-panel) var(--ocs-surface-panel-mix),transparent)!important;\",\"border:1px solid var(--ocs-border)!important;box-shadow:none!important;\",\"filter:none!important;backdrop-filter:none!important;}\",`${vr}{`,\"background:color-mix(in srgb,var(--ocs-panel) var(--ocs-elevated-panel-mix),transparent)!important;\",\"border:1px solid var(--ocs-border)!important;box-shadow:none!important;\",\"filter:none!important;backdrop-filter:blur(var(--ocs-surface-blur))!important;}\",`${ye}{color:var(--ocs-text)!important;`,\"background:transparent!important;border-color:var(--ocs-border)!important;\",\"box-shadow:none!important;backdrop-filter:none!important;}\",`${ye} :where(h1,h2,h3,h4,h5,h6,[role=heading]){`,\"color:var(--ocs-text)!important;}\",`${ye} :is(.text-token-foreground,.text-token-text-primary,`,'[class*=\"text-token-foreground\"],[class*=\"text-token-text-primary\"]){',\"color:var(--ocs-text)!important;}\",`${ye} :is(.text-token-description-foreground,.text-token-text-secondary,`,\".text-token-text-tertiary,.text-token-muted-foreground,\",'[class*=\"text-token-description-foreground\"],[class*=\"text-token-text-secondary\"],','[class*=\"text-token-text-tertiary\"],[class*=\"text-token-muted-foreground\"]){',\"color:var(--ocs-text-secondary)!important;}\",`${ye} :is([class*=\"bg-token-main-surface-primary\"]):not(`,\"[data-open-chatgpt-skin-surface]){\",\"background-color:transparent!important;background-image:none!important;}\",`${ye} :is([class*=\"bg-token-dropdown-background\"],`,'[class*=\"bg-token-bg-fog\"],[class*=\"bg-token-input-background\"]):not(',\"[data-open-chatgpt-skin-surface]){\",\"background-color:color-mix(in srgb,var(--ocs-panel) var(--ocs-elevated-panel-mix),transparent)!important;\",\"border-color:var(--ocs-border)!important;}\",`${Xe}{color:var(--ocs-text)!important;`,\"background:color-mix(in srgb,var(--ocs-panel) var(--ocs-surface-panel-mix),transparent)!important;\",\"border-color:var(--ocs-border)!important;box-shadow:none!important;\",\"backdrop-filter:blur(var(--ocs-surface-blur))!important;}\",`${Xe}::before,${Xe}::after{`,\"background:linear-gradient(to bottom,\",\"color-mix(in srgb,var(--ocs-panel) var(--ocs-surface-panel-mix),transparent),\",\"transparent)!important;box-shadow:none!important;}\",`${Je}{color:var(--ocs-input-text)!important;`,\"background:color-mix(in srgb,var(--ocs-panel) var(--ocs-elevated-panel-mix),transparent)!important;\",\"border-color:var(--ocs-border)!important;box-shadow:none!important;\",\"backdrop-filter:blur(var(--ocs-surface-blur))!important;}\",`${Je} :where(input,textarea,[contenteditable=true],[role=textbox]){`,\"color:var(--ocs-input-text)!important;background:transparent!important;}\",`${Je} :where(input::placeholder,textarea::placeholder){`,\"color:var(--ocs-placeholder)!important;opacity:1!important;}\",'[data-open-chatgpt-skin-surface=\"main\"] :where(h1,h2,h3,h4,h5,h6,[role=heading]),','[data-open-chatgpt-skin-surface=\"hero\"],','[data-open-chatgpt-skin-surface=\"hero\"] :where(h1,h2,h3,[role=heading]){',\"color:var(--ocs-text)!important;}\",`[data-open-chatgpt-skin-surface=\"sidebar\"]{padding:${o.layout.sidebarDensity===\"compact\"?8:14}px!important;`,\"color:var(--ocs-text-secondary)!important;}\",'[data-open-chatgpt-skin-surface=\"sidebar\"] *{',\"color:inherit!important;}\",'[data-open-chatgpt-skin-surface=\"sidebar\"] :is(.text-token-foreground,.text-token-text-primary,',\".text-token-text-secondary,.text-token-text-tertiary,.text-token-muted-foreground){\",\"color:var(--ocs-text-secondary)!important;}\",'[data-open-chatgpt-skin-surface=\"window-titlebar\"],','[data-open-chatgpt-skin-surface=\"topbar\"]{color:var(--ocs-text-secondary)!important;',\"background:transparent!important;\",\"border-color:var(--ocs-border)!important;box-shadow:none!important;}\",'[data-open-chatgpt-skin-surface=\"window-titlebar\"] *,','[data-open-chatgpt-skin-surface=\"topbar\"] *{color:inherit!important;',\"box-shadow:none!important;filter:none!important;}\",'[data-open-chatgpt-skin-surface=\"topbar\"]::before,','[data-open-chatgpt-skin-surface=\"topbar\"]::after{background:transparent!important;',\"box-shadow:none!important;filter:none!important;border-color:var(--ocs-border)!important;}\",`${Ue}{background:transparent!important;`,\"background-color:transparent!important;background-image:none!important;\",\"box-shadow:none!important;filter:none!important;backdrop-filter:none!important;\",\"pointer-events:none!important;}\",`${Ke}{background:transparent!important;`,\"background-color:transparent!important;background-image:none!important;\",\"box-shadow:none!important;filter:none!important;backdrop-filter:none!important;\",\"pointer-events:none!important;}\",`${Ue}::before,${Ue}::after,`,`${Ke}::before,${Ke}::after{`,\"background:transparent!important;background-image:none!important;\",\"box-shadow:none!important;filter:none!important;backdrop-filter:none!important;}\",`${G}{width:var(--ocs-composer-width)!important;`,\"max-width:var(--ocs-composer-width)!important;margin-inline:auto!important;\",\"color:var(--ocs-text)!important;\",\"background:transparent!important;border-color:transparent!important;box-shadow:none!important;}\",`${G} :where(button,[role=button],span,svg){color:var(--ocs-text)!important;}`,`${Ge}{width:100%!important;max-width:100%!important;`,\"margin-inline:0!important;background:transparent!important;\",\"border-color:transparent!important;box-shadow:none!important;backdrop-filter:none!important;}\",`${G}::before,${G}::after,`,`${Ge}::before,${Ge}::after{`,\"background:transparent!important;background-image:none!important;\",\"border-color:transparent!important;box-shadow:none!important;filter:none!important;}\",'[data-open-chatgpt-skin-surface=\"composer-input\"]{width:100%!important;max-width:100%!important;',\"color:var(--ocs-input-text)!important;\",\"background-color:color-mix(in srgb,var(--ocs-panel) var(--ocs-elevated-panel-mix),transparent)!important;\",\"border-color:var(--ocs-border)!important;\",\"box-shadow:none!important;\",\"backdrop-filter:blur(var(--ocs-surface-blur))!important;}\",'[data-open-chatgpt-skin-surface=\"composer-input\"] *{color:inherit!important;}','[data-open-chatgpt-skin-surface=\"composer-input\"] :where(input,textarea,[contenteditable=true],[role=textbox]){',\"color:var(--ocs-input-text)!important;background:transparent!important;}\",'[data-open-chatgpt-skin-surface=\"composer-input\"] [data-placeholder]::before,','[data-open-chatgpt-skin-surface=\"composer-input\"] [data-placeholder]::after{',\"color:var(--ocs-placeholder)!important;opacity:1!important;}\",'[data-open-chatgpt-skin-surface=\"composer-input\"] :where(button,[role=button]){',\"color:var(--ocs-text)!important;background-color:transparent!important;}\",`${G} :where([role=alert]){color:var(--ocs-danger)!important;}`,`${G} :where([role=status]){color:var(--ocs-info)!important;}`,`${G} :where(button:disabled,[role=button][aria-disabled=true]){`,\"color:var(--ocs-muted)!important;}\",`${pt}{overflow:hidden!important;pointer-events:none!important;}`,`${pt} :is(`,\"button,[role=button],[role=group],a,input,select){pointer-events:auto!important;}\",'[data-open-chatgpt-skin-surface=\"project-picker\"]{color:var(--ocs-text)!important;',\"background-color:color-mix(in srgb,var(--ocs-panel) var(--ocs-elevated-panel-mix),transparent)!important;\",\"border-color:var(--ocs-border)!important;box-shadow:none!important;}\",'[data-open-chatgpt-skin-surface=\"project-picker\"] *{color:inherit!important;}',`${Z}{color:var(--ocs-text)!important;background-color:color-mix(in srgb,var(--ocs-panel) var(--ocs-elevated-panel-mix),transparent)!important;border-color:var(--ocs-border)!important;box-shadow:none!important;backdrop-filter:blur(var(--ocs-surface-blur))!important;}`,`${Z} :where(h1,h2,h3,h4,h5,h6,[role=heading]){color:var(--ocs-text)!important;}`,`${Z} :where(div,section,article,span,p,label,small,svg){color:inherit!important;border-color:var(--ocs-border)!important;}`,`${Z} :is(.text-token-description-foreground,.text-token-text-secondary,.text-token-text-tertiary){color:var(--ocs-text-secondary)!important;}`,`${Z} :is([class*=\"bg-token-input-background\"],[class*=\"bg-token-main-surface\"],[class*=\"bg-token-elevated-surface\"]){background:transparent!important;background-image:none!important;box-shadow:none!important;}`,`${Z} :where(button,[role=button]){color:var(--ocs-text)!important;background-color:color-mix(in srgb,var(--ocs-panel) var(--ocs-surface-panel-mix),transparent)!important;border-color:var(--ocs-border)!important;box-shadow:none!important;}`,`${Z} :where(button,[role=button])[class~=\"bg-token-foreground\"]{color:var(--ocs-panel)!important;background-color:var(--ocs-accent)!important;border-color:var(--ocs-accent)!important;}`,`${Z} :where(button,[role=button]) svg{color:inherit!important;}`,'[data-open-chatgpt-skin-surface=\"suggestions\"]{display:grid!important;',\"grid-template-columns:repeat(var(--ocs-card-columns),minmax(0,1fr))!important;\",\"grid-auto-rows:1fr!important;align-items:stretch!important;\",\"gap:var(--ocs-module-gap)!important;}\",'[data-open-chatgpt-skin-surface=\"suggestions\"] > *{',\"width:100%!important;min-width:0!important;max-width:none!important;}\",'[data-open-chatgpt-skin-surface=\"card\"]{width:100%!important;max-width:none!important;',\"min-width:0!important;height:100%!important;position:relative!important;\",\"color:var(--ocs-text)!important;\",\"background-color:color-mix(in srgb,var(--ocs-panel) var(--ocs-elevated-panel-mix),transparent)!important;\",\"border-color:var(--ocs-border)!important;border-radius:12px!important;}\",'[data-open-chatgpt-skin-surface=\"card\"] :where(span,strong,p){color:inherit!important;}',\"[data-open-chatgpt-skin-interface-host]{position:relative!important;}\",\"[data-open-chatgpt-skin-native-icon]{opacity:0!important;}\",'[data-open-chatgpt-skin-surface=\"hero\"]{',\"min-height:max(180px,calc(min(var(--ocs-hero-height),45vh) - var(--ocs-home-overlap-relief)))!important;}\",'[data-open-chatgpt-skin-surface=\"hero\"] *{color:inherit!important;}','[data-open-chatgpt-skin-surface=\"home-native-icon\"]{visibility:hidden!important;}',Ir,Dn(o.layout.modules)].join(\"\"),Mr=Object.entries(r.assets.fonts??{}).map(([b,E])=>{let Ie=t.files.get(E);if(!Ie)throw new V(\"ASSET_MISSING\",E);return`@font-face{font-family:${He(`ocs-${b}`)};src:url(${He(Xt(E,Ie))}) format(\"woff2\");}`}).join(\"\"),Lr=Bn(t,c),Br=o.composition.layers.map(b=>{let E=b.asset.kind===\"portrait\"?r.assets.portrait:r.assets.decorations?.[b.asset.assetKey];if(!E)throw new V(\"ASSET_MISSING\",b.id);return{id:b.id,asset:c(E),surface:b.surface,anchor:b.anchor,positionXPercent:b.positionXPercent,positionYPercent:b.positionYPercent,widthPercent:b.widthPercent,opacity:b.opacity,rotationDeg:b.rotationDeg,required:b.required}}),bt=o.welcome?{localized:o.welcome.localized,displayFamily:o.displayTypography.fontAssetKey?`ocs-${o.displayTypography.fontAssetKey}`:o.displayTypography.family,displaySizePx:o.displayTypography.size,displayWeight:o.displayTypography.weight,displayLineHeight:o.displayTypography.lineHeight,displayLetterSpacingEm:o.displayTypography.letterSpacingEm,...o.welcome.layout?{layout:o.welcome.layout}:{}}:void 0,vt=Mn({themeId:r.id,themeVersion:r.version,backgroundDataUrl:Or,themeCss:jr,fontCss:Mr,layout:o.layout,decorations:Lr,interfaceImagery:$r,assetDataUrls:n,...bt?{welcome:bt}:{},compositionLayers:Br});return jn(vt.totalBytes),vt}var Qt=['style[data-open-chatgpt-skin=\"theme\"]','style[data-open-chatgpt-skin=\"fonts\"]','div[data-open-chatgpt-skin=\"decorations\"]','div[data-open-chatgpt-skin=\"welcome\"]','div[data-open-chatgpt-skin=\"composition-layer\"]',\"span[data-open-chatgpt-skin-interface-image]\"].join(\",\"),er=\"[data-open-chatgpt-skin-surface]\",qn=\"span[data-open-chatgpt-skin-interface-image]\",tr=\"[data-open-chatgpt-skin-interface-host]\",rr=\"[data-open-chatgpt-skin-native-icon]\",ct=\"__openChatgptSkinMarkSurfaces\",or=\"__openChatgptSkinSurfaceObserver\",nr=\"__openChatgptSkinSurfaceRefreshListener\",Te=\"__openChatgptSkinShadowSheets\",ar=\"--ocs-home-overlap-relief\",Fn=-1,dt=\"textarea,[contenteditable=true],[role=textbox]\",sr=[Ce.terminal,Ce.overlay].join(\",\"),Gt=\".thread-scroll-container,[data-thread-scroll-container]\",Hn='[class*=\"thread-resource-card\"],[class*=\"turn-diff-row-padding\"]',Wn={diffs:[\"@layer base{\",':host,pre,code,[data-gutter],[data-content],[data-line-type=\"context\"],','[data-column-number][data-line-type=\"context\"]{',\"background:color-mix(in srgb,var(--ocs-panel) var(--ocs-elevated-panel-mix),transparent)!important;\",\"color:var(--ocs-code-text)!important;border-color:var(--ocs-border)!important;}\",\"[data-separator-wrapper],[data-separator],[data-separator-content]{\",\"background:color-mix(in srgb,var(--ocs-panel) 88%,transparent)!important;\",\"color:var(--ocs-text-secondary)!important;border-color:var(--ocs-border)!important;}\",'[data-line-type=\"context\"] *{color:var(--ocs-code-text)!important;}','[data-line-type=\"change-addition\"]{',\"background:color-mix(in srgb,var(--ocs-panel) 82%,var(--ocs-success))!important;}\",'[data-line][data-line-type=\"change-addition\"],','[data-line][data-line-type=\"change-addition\"] *{color:var(--ocs-code-text)!important;}','[data-line-type=\"change-deletion\"]{',\"background:color-mix(in srgb,var(--ocs-panel) 82%,var(--ocs-danger))!important;}\",'[data-line][data-line-type=\"change-deletion\"],','[data-line][data-line-type=\"change-deletion\"] *{color:var(--ocs-code-text)!important;}',\"}\"].join(\"\"),tree:[\"@layer base{\",\":host,[data-file-tree-sticky-overlay-content],[data-file-tree-virtualized-list],\",'[data-type=\"item\"]{',\"background:color-mix(in srgb,var(--ocs-panel) var(--ocs-elevated-panel-mix),transparent)!important;\",\"color:var(--ocs-text)!important;border-color:var(--ocs-border)!important;}\",'[data-type=\"item\"]:hover{background:color-mix(in srgb,var(--ocs-accent) 8%,transparent)!important;}','[data-type=\"item\"][aria-selected=\"true\"]{',\"background:color-mix(in srgb,var(--ocs-accent) 14%,transparent)!important;\",\"color:var(--ocs-text)!important;}\",\"[data-truncate-marker]{background:var(--ocs-panel)!important;\",\"color:var(--ocs-text)!important;box-shadow:none!important;}\",\"}\"].join(\"\")},lt=`Array.from(document.querySelectorAll(\n ${JSON.stringify(dt)}\n)).some((node) => visible(node) &&\n !node.closest(${JSON.stringify(sr)}))`,ir=\"(?:settings|preferences|account|appearance|theme|general|permissions|configuration|personalization|\\u8BBE\\u7F6E|\\u504F\\u597D|\\u8D26\\u6237|\\u5916\\u89C2|\\u4E3B\\u9898|\\u5E38\\u89C4|\\u6743\\u9650|\\u914D\\u7F6E|\\u4E2A\\u6027\\u5316)\",Me=`(() => {\n const visible = (node) => {\n if (!node) return false;\n const rect = node.getBoundingClientRect();\n return rect.width > 0 && rect.height > 0;\n };\n const pathname = (() => {\n try { return new URL(window.location.href).pathname.toLowerCase(); }\n catch { return \"\"; }\n })();\n const pattern = new RegExp(${JSON.stringify(ir)}, \"i\");\n const settingsPath = pathname.split(\"/\")\n .some((segment) => /^(?:settings|preferences)$/.test(segment));\n const settingsNavigation = Array.from(document.querySelectorAll(\n \"nav,aside,[role=navigation]\"\n )).some((node) => visible(node) && pattern.test([\n node.id,\n typeof node.className === \"string\" ? node.className : \"\",\n node.getAttribute(\"aria-label\") || \"\",\n node.getAttribute(\"data-testid\") || \"\",\n ].join(\" \")));\n const main = document.querySelector(\"main,[role=main]\");\n const mainSemantic = Boolean(main) && Array.from(main.querySelectorAll(\n \"h1,h2,h3,[role=heading],[aria-label],[data-testid]\"\n )).some((node) => visible(node) &&\n !node.closest('[role=\"dialog\"],[role=\"menu\"],[role=\"alertdialog\"]') &&\n pattern.test([\n node.id,\n typeof node.className === \"string\" ? node.className : \"\",\n node.getAttribute(\"role\") || \"\",\n node.getAttribute(\"aria-label\") || \"\",\n node.getAttribute(\"data-testid\") || \"\",\n String(node.textContent || \"\").slice(0, 120),\n ].join(\" \")));\n return settingsPath || settingsNavigation || mainSemantic;\n})()`,cr=`((main, composer, suggestions, visible) => {\n const marked = Array.from(main.querySelectorAll(\n '[data-open-chatgpt-skin-surface=\"home-heading\"]'\n )).filter((node) => visible(node));\n if (marked.length > 0) return marked.length === 1 ? marked[0] : null;\n\n const normalizedText = (node) => String(node.textContent || \"\")\n .trim().replace(/\\\\s+/g, \" \");\n const candidates = Array.from(main.querySelectorAll(\"h1,h2,[role=heading],*\"))\n .filter((node) => {\n if (!visible(node) || (composer && composer.contains(node)) ||\n (suggestions && suggestions.contains(node)) ||\n node.closest(${JSON.stringify(Ce.overlay)}) ||\n node.closest('[data-open-chatgpt-skin=\"composition-layer\"]')) return false;\n const rect = node.getBoundingClientRect();\n const fontSize = Number.parseFloat(window.getComputedStyle(node).fontSize);\n const semantic = node.matches(\"h1,h2,[role=heading]\");\n return normalizedText(node).length > 0 && (semantic ||\n (rect.width >= 100 && rect.height >= 24 && fontSize >= 24));\n });\n const roots = candidates.filter((candidate) =>\n !candidates.some((ancestor) => ancestor !== candidate &&\n ancestor.contains(candidate))\n );\n return roots.length === 1 ? roots[0] : null;\n})`,dr=`(() => {\n const observerKey = ${JSON.stringify(or)};\n const markerKey = ${JSON.stringify(ct)};\n const shadowSheetsKey = ${JSON.stringify(Te)};\n const refreshListenerKey = ${JSON.stringify(nr)};\n const observer = window[observerKey];\n if (observer && typeof observer.disconnect === \"function\") observer.disconnect();\n delete window[observerKey];\n delete window[markerKey];\n const refreshListener = window[refreshListenerKey];\n if (typeof refreshListener === \"function\") {\n window.removeEventListener(\"resize\", refreshListener);\n window.removeEventListener(\"scroll\", refreshListener, true);\n }\n delete window[refreshListenerKey];\n const shadowRecords = Array.isArray(window[shadowSheetsKey])\n ? window[shadowSheetsKey]\n : [];\n for (const record of shadowRecords) {\n if (record.sheet && record.root?.adoptedStyleSheets) {\n record.root.adoptedStyleSheets = Array.from(record.root.adoptedStyleSheets)\n .filter((sheet) => sheet !== record.sheet);\n }\n if (record.node?.parentNode) record.node.parentNode.removeChild(record.node);\n }\n delete window[shadowSheetsKey];\n if (Array.isArray(window[shadowSheetsKey])) window[shadowSheetsKey] = undefined;\n document.documentElement.style.removeProperty(${JSON.stringify(ar)});\n for (const node of document.querySelectorAll('[data-open-chatgpt-skin=\"welcome\"]')) {\n const record = node.__openChatgptSkinWelcomeRecord;\n if (record?.heading?.style) record.heading.style.visibility = record.visibility;\n node.remove();\n }\n const selector = ${JSON.stringify(Qt)};\n for (const node of document.querySelectorAll(selector)) node.remove();\n const surfaceSelector = ${JSON.stringify(er)};\n for (const node of document.querySelectorAll(surfaceSelector)) {\n node.removeAttribute(\"data-open-chatgpt-skin-surface\");\n }\n for (const node of document.querySelectorAll(${JSON.stringify(tr)})) {\n node.removeAttribute(\"data-open-chatgpt-skin-interface-host\");\n }\n for (const node of document.querySelectorAll(${JSON.stringify(rr)})) {\n node.removeAttribute(\"data-open-chatgpt-skin-native-icon\");\n }\n const countShadowStyles = (root) => {\n let count = 0;\n for (const node of root.querySelectorAll(\"*\")) {\n const shadow = node.shadowRoot;\n if (!shadow) continue;\n count += shadow.querySelectorAll('style[data-open-chatgpt-skin-shadow]').length;\n count += countShadowStyles(shadow);\n }\n return count;\n };\n return document.querySelectorAll(selector).length +\n document.querySelectorAll(surfaceSelector).length +\n countShadowStyles(document) +\n (Array.isArray(window[shadowSheetsKey]) ? window[shadowSheetsKey].length : 0);\n})()`,rs=`(() => {\n const visible = (node) => {\n if (!node) return false;\n const rect = node.getBoundingClientRect();\n return rect.width > 0 && rect.height > 0;\n };\n const settingsPagePresent = ${Me};\n return {\n main: visible(document.querySelector(\"main,[role=main]\")),\n navigation: visible(document.querySelector(\"nav,aside,[role=navigation]\")),\n composer: ${lt} || settingsPagePresent\n };\n})()`;function lr(t,e){let r=e??JSON.stringify(t),o=JSON.stringify(Ce);return`(() => {\n const theme = ${r};\n const nativeSelectors = ${o};\n const resolveHomeHeading = ${cr};\n const visible = (node) => {\n if (!node) return false;\n const rect = node.getBoundingClientRect();\n return rect.width > 0 && rect.height > 0;\n };\n const main = Array.from(document.querySelectorAll(nativeSelectors.shellMain))\n .find((node) => visible(node) &&\n !node.closest('[data-open-chatgpt-skin=\"composition-layer\"]')) || null;\n if (!main) {\n return { valid: false, welcomeSupported: false, requiredLayersResolved: false };\n }\n const pathname = (() => {\n try { return new URL(window.location.href).pathname.toLowerCase(); }\n catch { return \"\"; }\n })();\n const routeSegments = pathname.split(\"/\");\n const taskPath = routeSegments.some((segment) =>\n /^(?:tasks?|threads?|workspaces?)$/.test(segment)\n );\n const settingsPath = routeSegments.some((segment) =>\n /^(?:settings|preferences)$/.test(segment)\n );\n const settingsPagePresent = ${Me};\n const composer = Array.from(main.querySelectorAll(\n ${JSON.stringify(dt)}\n )).find((node) => visible(node) &&\n !node.closest(${JSON.stringify(sr)})) || null;\n const managedHomeRoutes = Array.from(document.querySelectorAll(\n '[data-open-chatgpt-skin-surface=\"home-route\"]'\n )).filter((node) => visible(node) && main.contains(node));\n const routeMains = Array.from(main.querySelectorAll(nativeSelectors.routeMain))\n .filter((node) => node !== main && visible(node));\n const modeSwitcher = Array.from(document.querySelectorAll(nativeSelectors.modeSwitcher))\n .find((node) => visible(node));\n const chatGptLandingRoutes = modeSwitcher && composer ? routeMains.filter((node) => {\n if (!node.contains(composer)) return false;\n const routeRect = node.getBoundingClientRect();\n const composerRect = composer.getBoundingClientRect();\n return composerRect.bottom <= routeRect.bottom - Math.max(96, routeRect.height * 0.15);\n }) : [];\n const activeHome = (managedHomeRoutes.length === 1 ||\n Boolean(main.querySelector(nativeSelectors.homeMarker)) ||\n chatGptLandingRoutes.length === 1) &&\n !taskPath && !settingsPath && !settingsPagePresent;\n const suggestionMarkers = Array.from(document.querySelectorAll(\n '[data-open-chatgpt-skin-surface=\"suggestions\"]'\n )).filter((node) => visible(node) && main.contains(node));\n const inferredSuggestions = Array.from(main.querySelectorAll(\"section,div\"))\n .filter((node) => visible(node) && (!composer || !node.contains(composer)))\n .filter((node) => {\n const buttons = Array.from(node.children).filter((child) =>\n child.matches(\"button,[role=button]\") && visible(child)\n );\n return buttons.length >= 3 && buttons.length <= 8;\n });\n const suggestionCandidates = suggestionMarkers.length > 0\n ? suggestionMarkers\n : inferredSuggestions;\n const suggestions = suggestionCandidates.length === 1 ? suggestionCandidates[0] : null;\n const heading = resolveHomeHeading(main, composer, suggestions, visible);\n const heroMarkers = Array.from(document.querySelectorAll(\n '[data-open-chatgpt-skin-surface=\"hero\"]'\n )).filter((node) => visible(node) && main.contains(node));\n const heroCandidates = heroMarkers.length > 0\n ? heroMarkers\n : (heading ? [heading] : []);\n const hero = heroCandidates.length === 1 ? heroCandidates[0] : null;\n const locale = String(document.documentElement.lang || \"en\").toLowerCase()\n .startsWith(\"zh\") ? \"zh-CN\" : \"en\";\n const welcomeLines = theme.welcome?.localized?.[locale];\n const projectButton = Array.from(main.querySelectorAll(\"button,[role=button]\"))\n .filter((node) => visible(node) && /(?:switch project|project|\\u9879\\u76EE)/i.test(\n [node.getAttribute(\"aria-label\") || \"\", node.getAttribute(\"data-testid\") || \"\"]\n .join(\" \")\n ))[0];\n const projectName = String(projectButton?.textContent || \"\").trim();\n const needsProjectName = Array.isArray(welcomeLines) && welcomeLines.some((line) =>\n Array.isArray(line) && line.some((token) => token?.kind === \"projectName\")\n );\n const nativeWelcomeFallback = !Array.isArray(welcomeLines) || welcomeLines.length === 0 ||\n (needsProjectName && !projectName);\n const welcomeSupported = !theme.welcome || !activeHome || nativeWelcomeFallback ||\n Boolean(heading && hero);\n const applicable = (surface) => surface === \"viewport\" || surface === \"main\" ||\n activeHome;\n const resolved = (surface) => {\n if (surface === \"viewport\") return Boolean(document.documentElement);\n if (surface === \"main\") return Boolean(main);\n if (surface === \"home-hero\") return Boolean(hero);\n if (surface === \"suggestions\") return Boolean(suggestions);\n return false;\n };\n const requiredLayersResolved = theme.compositionLayers\n .filter((layer) => layer.required && applicable(layer.surface))\n .every((layer) => resolved(layer.surface));\n return {\n valid: welcomeSupported && requiredLayersResolved,\n welcomeSupported,\n requiredLayersResolved,\n };\n })()`}function ur(t,e){let r=e??JSON.stringify(t),o=JSON.stringify(Ce);return`(async () => {\n const theme = ${r};\n const nativeSelectors = ${o};\n const resolveHomeHeading = ${cr};\n const observerKey = ${JSON.stringify(or)};\n const markerKey = ${JSON.stringify(ct)};\n const shadowSheetsKey = ${JSON.stringify(Te)};\n const refreshListenerKey = ${JSON.stringify(nr)};\n const shadowThemeCss = ${JSON.stringify(Wn)};\n const compositionUnderlayZIndex = ${Fn};\n const detachShadowThemeRecord = (record) => {\n if (record.sheet && record.root?.adoptedStyleSheets) {\n record.root.adoptedStyleSheets = Array.from(record.root.adoptedStyleSheets)\n .filter((sheet) => sheet !== record.sheet);\n }\n if (record.node?.parentNode) record.node.parentNode.removeChild(record.node);\n };\n const clearShadowThemes = () => {\n const records = Array.isArray(window[shadowSheetsKey])\n ? window[shadowSheetsKey]\n : [];\n for (const record of records) detachShadowThemeRecord(record);\n delete window[shadowSheetsKey];\n if (Array.isArray(window[shadowSheetsKey])) window[shadowSheetsKey] = undefined;\n };\n const previousObserver = window[observerKey];\n if (previousObserver && typeof previousObserver.disconnect === \"function\") {\n previousObserver.disconnect();\n }\n delete window[observerKey];\n delete window[markerKey];\n const previousRefreshListener = window[refreshListenerKey];\n if (typeof previousRefreshListener === \"function\") {\n window.removeEventListener(\"resize\", previousRefreshListener);\n window.removeEventListener(\"scroll\", previousRefreshListener, true);\n }\n delete window[refreshListenerKey];\n clearShadowThemes();\n const overlapReliefProperty = ${JSON.stringify(ar)};\n document.documentElement.style.removeProperty(overlapReliefProperty);\n const managedSelector = ${JSON.stringify(Qt)};\n const restoreWelcome = (node) => {\n const record = node?.__openChatgptSkinWelcomeRecord;\n if (record?.heading?.style) record.heading.style.visibility = record.visibility;\n if (node?.parentNode) node.parentNode.removeChild(node);\n };\n for (const node of document.querySelectorAll('[data-open-chatgpt-skin=\"welcome\"]')) {\n restoreWelcome(node);\n }\n for (const node of document.querySelectorAll(managedSelector)) node.remove();\n const surfaceSelector = ${JSON.stringify(er)};\n for (const node of document.querySelectorAll(surfaceSelector)) {\n node.removeAttribute(\"data-open-chatgpt-skin-surface\");\n }\n const interfaceImageSelector = ${JSON.stringify(qn)};\n const interfaceHostSelector = ${JSON.stringify(tr)};\n const nativeIconSelector = ${JSON.stringify(rr)};\n for (const node of document.querySelectorAll(interfaceHostSelector)) {\n node.removeAttribute(\"data-open-chatgpt-skin-interface-host\");\n }\n for (const node of document.querySelectorAll(nativeIconSelector)) {\n node.removeAttribute(\"data-open-chatgpt-skin-native-icon\");\n }\n\n const visible = (node) => {\n if (!node) return false;\n const rect = node.getBoundingClientRect();\n return rect.width > 0 && rect.height > 0;\n };\n const hasSurfaceBackground = (node) => {\n if (!node) return false;\n const background = window.getComputedStyle(node).backgroundColor;\n return typeof background === \"string\" && background !== \"transparent\" &&\n background !== \"rgba(0, 0, 0, 0)\";\n };\n const insideOverlay = (node) => Boolean(node && node.closest(nativeSelectors.overlay));\n const insideTerminal = (node) => Boolean(node && node.closest(nativeSelectors.terminal));\n const insideThreadConversation = (node) => Boolean(node && node.closest(\n ${JSON.stringify(Gt)}\n ));\n const verificationRecord = {\n themeId: theme.themeId,\n welcomeExpected: false,\n welcomeSupported: true,\n requiredLayerIds: [],\n requiredLayersResolved: true,\n };\n const resolveThemeLocale = () => {\n const declared = String(document.documentElement.lang || \"\").toLowerCase();\n if (declared.startsWith(\"zh\")) return \"zh-CN\";\n const uiSample = Array.from(document.querySelectorAll(\n \"button,[role=button],[role=navigation],[aria-label]\"\n )).filter((node) => visible(node)).slice(0, 40).map((node) => [\n node.getAttribute(\"aria-label\") || \"\",\n String(node.textContent || \"\").slice(0, 80)\n ].join(\" \")).join(\" \");\n return /[\\u3400-\\u9FFF]/u.test(uiSample) ? \"zh-CN\" : \"en\";\n };\n const syncWelcome = (main, hero, heading, activeHome) => {\n const existing = document.querySelector('[data-open-chatgpt-skin=\"welcome\"]');\n verificationRecord.welcomeExpected = false;\n verificationRecord.welcomeSupported = true;\n if (!theme.welcome || !activeHome) {\n if (existing) restoreWelcome(existing);\n return;\n }\n const locale = resolveThemeLocale();\n const lines = theme.welcome.localized[locale];\n if (!Array.isArray(lines) || lines.length === 0) {\n if (existing) restoreWelcome(existing);\n return;\n }\n const projectButton = Array.from(main.querySelectorAll(\"button,[role=button]\"))\n .filter((node) => visible(node) && /(?:switch project|project|\\u9879\\u76EE)/i.test(\n [node.getAttribute(\"aria-label\") || \"\", node.getAttribute(\"data-testid\") || \"\"]\n .join(\" \")\n ))[0];\n const projectName = String(projectButton?.textContent || \"\").trim();\n if (projectButton && !projectButton.dataset.openChatgptSkinSurface) {\n projectButton.dataset.openChatgptSkinSurface = \"project-name\";\n }\n const needsProjectName = lines.some((line) => Array.isArray(line) &&\n line.some((token) => token?.kind === \"projectName\"));\n if (needsProjectName && !projectName) {\n if (existing) restoreWelcome(existing);\n return;\n }\n if (!hero || !heading) {\n verificationRecord.welcomeSupported = false;\n if (existing) restoreWelcome(existing);\n return;\n }\n const resolvedLines = lines.map((line) => line.map((token) => {\n if (token.kind === \"text\") return token.value;\n if (token.kind === \"projectName\") return projectName;\n throw new Error(\"THEME_WELCOME_INVALID\");\n }).join(\"\"));\n verificationRecord.welcomeExpected = true;\n let overlay = existing;\n if (overlay?.__openChatgptSkinWelcomeRecord?.heading !== heading) {\n restoreWelcome(overlay);\n overlay = null;\n }\n if (!overlay) {\n overlay = document.createElement(\"div\");\n overlay.dataset.openChatgptSkin = \"welcome\";\n overlay.dataset.openChatgptSkinOwner = theme.themeId;\n overlay.setAttribute(\"aria-hidden\", \"true\");\n overlay.__openChatgptSkinWelcomeRecord = {\n heading,\n visibility: heading.style.visibility,\n };\n document.body.append(overlay);\n }\n const currentLines = Array.from(overlay.children)\n .map((node) => String(node.textContent || \"\"));\n const linesMatch = overlay.childNodes.length === resolvedLines.length &&\n currentLines.length === resolvedLines.length &&\n currentLines.every((line, index) => line === resolvedLines[index]);\n if (!linesMatch) {\n while (overlay.firstChild) overlay.removeChild(overlay.firstChild);\n for (const line of resolvedLines) {\n const lineNode = document.createElement(\"div\");\n lineNode.append(document.createTextNode(line));\n overlay.append(lineNode);\n }\n }\n const nativeRect = heading.getBoundingClientRect();\n const heroRect = hero.getBoundingClientRect();\n const layout = theme.welcome.layout;\n const anchorTransforms = {\n \"top-left\": \"translate(0%, 0%)\",\n \"top-center\": \"translate(-50%, 0%)\",\n \"top-right\": \"translate(-100%, 0%)\",\n \"center-left\": \"translate(0%, -50%)\",\n \"center\": \"translate(-50%, -50%)\",\n \"center-right\": \"translate(-100%, -50%)\",\n \"bottom-left\": \"translate(0%, -100%)\",\n \"bottom-center\": \"translate(-50%, -100%)\",\n \"bottom-right\": \"translate(-100%, -100%)\"\n };\n const left = layout\n ? heroRect.left + heroRect.width * layout.positionXPercent / 100\n : nativeRect.left;\n const top = layout\n ? heroRect.top + heroRect.height * layout.positionYPercent / 100\n : nativeRect.top;\n const width = layout ? heroRect.width * layout.widthPercent / 100 : nativeRect.width;\n Object.assign(overlay.style, {\n position: \"fixed\",\n left: String(left) + \"px\",\n top: String(top) + \"px\",\n width: String(width) + \"px\",\n transform: layout ? anchorTransforms[layout.anchor] : \"none\",\n textAlign: layout?.textAlign || \"\",\n pointerEvents: \"none\",\n userSelect: \"none\",\n zIndex: \"0\",\n color: \"var(--ocs-text)\",\n fontFamily: theme.welcome.displayFamily,\n fontSize: String(theme.welcome.displaySizePx) + \"px\",\n fontWeight: String(theme.welcome.displayWeight),\n lineHeight: String(theme.welcome.displayLineHeight),\n letterSpacing: String(theme.welcome.displayLetterSpacingEm) + \"em\",\n });\n if (layout?.hideNativeIcon) {\n const nativeHeroIcon = Array.from(hero.querySelectorAll(\"svg,img\"))\n .filter((node) => {\n const rect = node.getBoundingClientRect();\n return visible(node) && rect.width >= 12 && rect.width <= 96 &&\n rect.height >= 12 && rect.height <= 96 &&\n !node.closest('[data-open-chatgpt-skin=\"composition-layer\"]');\n })[0];\n const iconHost = nativeHeroIcon?.parentElement || nativeHeroIcon;\n if (iconHost) iconHost.dataset.openChatgptSkinSurface = \"home-native-icon\";\n }\n heading.style.visibility = \"hidden\";\n };\n const syncCompositionLayers = (main, hero, suggestions, activeHome) => {\n const descriptors = Array.isArray(theme.compositionLayers)\n ? theme.compositionLayers\n : [];\n const anchorTransforms = {\n \"top-left\": \"translate(0%, 0%)\",\n \"top-center\": \"translate(-50%, 0%)\",\n \"top-right\": \"translate(-100%, 0%)\",\n \"center-left\": \"translate(0%, -50%)\",\n \"center\": \"translate(-50%, -50%)\",\n \"center-right\": \"translate(-100%, -50%)\",\n \"bottom-left\": \"translate(0%, -100%)\",\n \"bottom-center\": \"translate(-50%, -100%)\",\n \"bottom-right\": \"translate(-100%, -100%)\",\n };\n const targetFor = (surface) => {\n if (surface === \"viewport\") return document.documentElement;\n if (surface === \"main\") return main;\n if (!activeHome) return null;\n if (surface === \"home-hero\") return hero;\n if (surface === \"suggestions\") return suggestions;\n return null;\n };\n const existingHosts = Array.from(document.querySelectorAll(\n '[data-open-chatgpt-skin=\"composition-layer\"]'\n ));\n const applicable = (descriptor) => descriptor.surface === \"viewport\" ||\n descriptor.surface === \"main\" || activeHome;\n verificationRecord.requiredLayerIds = descriptors\n .filter((descriptor) => descriptor.required && applicable(descriptor))\n .map((descriptor) => descriptor.id);\n verificationRecord.requiredLayersResolved = true;\n const desiredHosts = new Set();\n for (const surface of [\"viewport\", \"main\", \"home-hero\", \"suggestions\"]) {\n const surfaceLayers = descriptors.filter((descriptor) => descriptor.surface === surface);\n if (surfaceLayers.length === 0) continue;\n const target = targetFor(surface);\n if (!target) continue;\n let host = existingHosts.find((candidate) =>\n candidate.dataset.openChatgptSkinCompositionSurface === surface &&\n candidate.dataset.openChatgptSkinOwner === theme.themeId\n );\n if (!host) {\n host = document.createElement(\"div\");\n host.dataset.openChatgptSkin = \"composition-layer\";\n host.dataset.openChatgptSkinCompositionSurface = surface;\n host.dataset.openChatgptSkinOwner = theme.themeId;\n host.setAttribute(\"aria-hidden\", \"true\");\n document.body.append(host);\n }\n desiredHosts.add(host);\n host.dataset.openChatgptSkinSurface = \"composition-host\";\n const rect = surface === \"viewport\" ? null : target.getBoundingClientRect();\n Object.assign(host.style, {\n position: \"fixed\",\n left: rect ? String(rect.left) + \"px\" : \"0px\",\n top: rect ? String(rect.top) + \"px\" : \"0px\",\n width: rect ? String(rect.width) + \"px\" : \"100vw\",\n height: rect ? String(rect.height) + \"px\" : \"100vh\",\n pointerEvents: \"none\",\n overflow: \"visible\",\n zIndex: String(compositionUnderlayZIndex),\n });\n const desiredLayers = new Set();\n for (const descriptor of surfaceLayers) {\n const dataUrl = theme.assetDataUrls[descriptor.asset];\n const anchorTransform = anchorTransforms[descriptor.anchor];\n if (typeof dataUrl !== \"string\" || typeof anchorTransform !== \"string\") continue;\n let layer = Array.from(host.querySelectorAll('[data-open-chatgpt-skin-layer-id]'))\n .find((candidate) => candidate.dataset.openChatgptSkinLayerId === descriptor.id);\n if (!layer) {\n layer = document.createElement(\"img\");\n layer.dataset.openChatgptSkinLayerId = descriptor.id;\n layer.setAttribute(\"aria-hidden\", \"true\");\n layer.tabIndex = -1;\n host.append(layer);\n }\n desiredLayers.add(layer);\n layer.src = dataUrl;\n Object.assign(layer.style, {\n position: \"absolute\",\n left: String(descriptor.positionXPercent) + \"%\",\n top: String(descriptor.positionYPercent) + \"%\",\n width: String(descriptor.widthPercent) + \"%\",\n height: \"auto\",\n opacity: String(descriptor.opacity),\n transform: anchorTransform + \" rotate(\" + String(descriptor.rotationDeg) + \"deg)\",\n pointerEvents: \"none\",\n userSelect: \"none\",\n });\n }\n for (const layer of host.querySelectorAll('[data-open-chatgpt-skin-layer-id]')) {\n if (!desiredLayers.has(layer)) layer.remove();\n }\n }\n for (const host of existingHosts) {\n if (!desiredHosts.has(host)) host.remove();\n }\n verificationRecord.requiredLayersResolved = verificationRecord.requiredLayerIds\n .every((layerId) => document.querySelectorAll(\n '[data-open-chatgpt-skin=\"composition-layer\"]' +\n '[data-open-chatgpt-skin-owner=\"' + theme.themeId + '\"] ' +\n '[data-open-chatgpt-skin-layer-id=\"' + layerId + '\"]'\n ).length === 1);\n };\n const overlapsThreadConversation = (node) => Boolean(node && (\n insideThreadConversation(node) || node.querySelector(\n ${JSON.stringify(Gt)}\n )\n ));\n const syncReviewShadowThemes = () => {\n const currentRecords = Array.isArray(window[shadowSheetsKey])\n ? window[shadowSheetsKey]\n : [];\n const records = [];\n for (const record of currentRecords) {\n const adopted = record.sheet && record.root?.adoptedStyleSheets\n ? Array.from(record.root.adoptedStyleSheets).includes(record.sheet)\n : false;\n const connected = Boolean(record.host?.isConnected &&\n record.host.shadowRoot === record.root &&\n (adopted || record.node?.isConnected));\n if (connected) records.push(record);\n else detachShadowThemeRecord(record);\n }\n const roots = new Set(records.map((record) => record.root));\n const attach = (host, kind) => {\n const root = host?.shadowRoot;\n if (!root || roots.has(root)) return;\n const css = shadowThemeCss[kind];\n const canAdopt = typeof window.CSSStyleSheet === \"function\" &&\n typeof window.CSSStyleSheet.prototype?.replaceSync === \"function\" &&\n root.adoptedStyleSheets !== undefined;\n if (canAdopt) {\n const sheet = new window.CSSStyleSheet();\n sheet.replaceSync(css);\n root.adoptedStyleSheets = [...root.adoptedStyleSheets, sheet];\n records.push({ host, root, sheet, kind });\n } else {\n const node = document.createElement(\"style\");\n node.dataset.openChatgptSkinShadow = kind;\n node.textContent = css;\n root.append(node);\n records.push({ host, root, node, kind });\n }\n roots.add(root);\n };\n for (const host of document.querySelectorAll(\"diffs-container\")) {\n attach(host, \"diffs\");\n }\n for (const host of document.querySelectorAll(\"file-tree-container\")) {\n attach(host, \"tree\");\n }\n window[shadowSheetsKey] = records;\n };\n const markSurfaces = () => {\n for (const node of document.querySelectorAll(surfaceSelector)) {\n node.removeAttribute(\"data-open-chatgpt-skin-surface\");\n }\n for (const node of document.querySelectorAll(interfaceHostSelector)) {\n node.removeAttribute(\"data-open-chatgpt-skin-interface-host\");\n }\n for (const node of document.querySelectorAll(nativeIconSelector)) {\n node.removeAttribute(\"data-open-chatgpt-skin-native-icon\");\n }\n const usedInterfaceImages = new Set();\n const mark = (node, surface) => {\n if (node) node.dataset.openChatgptSkinSurface = surface;\n return node;\n };\n const syncInterfaceImage = (host, key, descriptor, nativeIcon, fallbackSize) => {\n if (!host || !descriptor) return;\n const dataUrl = descriptor.asset === \"background\"\n ? theme.backgroundDataUrl\n : theme.assetDataUrls[descriptor.asset];\n if (typeof dataUrl !== \"string\") return;\n host.dataset.openChatgptSkinInterfaceHost = key;\n if (nativeIcon) nativeIcon.dataset.openChatgptSkinNativeIcon = key;\n let overlay = Array.from(host.children).find((child) =>\n child.dataset?.openChatgptSkinInterfaceImage === key\n );\n if (!overlay) {\n overlay = document.createElement(\"span\");\n overlay.dataset.openChatgptSkinInterfaceImage = key;\n overlay.setAttribute(\"aria-hidden\", \"true\");\n host.append(overlay);\n }\n const hostRect = host.getBoundingClientRect();\n const nativeRect = nativeIcon?.getBoundingClientRect();\n const nativeSizeValid = nativeRect && nativeRect.width >= 6 && nativeRect.width <= 64 &&\n nativeRect.height >= 6 && nativeRect.height <= 64;\n const configuredSize = Number(descriptor.sizePx);\n const size = Number.isFinite(configuredSize) && configuredSize >= 6 &&\n configuredSize <= 64 ? configuredSize : null;\n const width = size || (nativeSizeValid ? nativeRect.width : fallbackSize);\n const height = size || (nativeSizeValid ? nativeRect.height : fallbackSize);\n const left = nativeSizeValid\n ? nativeRect.left - hostRect.left + (nativeRect.width - width) / 2\n : 12;\n const top = nativeSizeValid\n ? nativeRect.top - hostRect.top + (nativeRect.height - height) / 2\n : Math.max(2, (hostRect.height - height) / 2);\n Object.assign(overlay.style, {\n position: \"absolute\",\n left: String(left) + \"px\",\n top: String(top) + \"px\",\n width: String(width) + \"px\",\n height: String(height) + \"px\",\n zIndex: \"2\",\n pointerEvents: \"none\",\n borderRadius: key === \"profile-avatar\" ? \"999px\" : \"6px\",\n backgroundImage: \"url(\" + dataUrl + \")\",\n backgroundPosition: String(descriptor.positionXPercent) + \"% \" +\n String(descriptor.positionYPercent) + \"%\",\n backgroundSize: \"cover\",\n backgroundRepeat: \"no-repeat\"\n });\n overlay.dataset.openChatgptSkinSurface = key === \"profile-avatar\"\n ? \"profile-avatar\"\n : key.replace(\"suggestion-card\", \"suggestion-icon-\");\n usedInterfaceImages.add(overlay);\n };\n const main = mark(document.querySelector(nativeSelectors.shellMain), \"main\");\n const sidebar = Array.from(document.querySelectorAll(nativeSelectors.sidebar))\n .filter((node) => visible(node) && (!main || !main.contains(node)) &&\n !insideOverlay(node))\n .map((node) => ({\n node,\n rect: node.getBoundingClientRect(),\n nativeShell: node.matches(\"aside.app-shell-left-panel,.app-shell-left-panel\"),\n semanticAside: node.tagName === \"ASIDE\",\n }))\n .sort((left, right) => Number(right.nativeShell) - Number(left.nativeShell) ||\n Number(right.semanticAside) - Number(left.semanticAside) ||\n right.rect.width * right.rect.height - left.rect.width * left.rect.height)[0]?.node;\n mark(sidebar, \"sidebar\");\n const accountEntry = sidebar ? Array.from(sidebar.querySelectorAll(\"button,[role=button]\"))\n .filter((node) => visible(node) && !insideOverlay(node))\n .map((node) => ({\n node,\n rect: node.getBoundingClientRect(),\n semantic: /(?:account|profile|user|avatar|settings|\\u8D26\\u6237|\\u4E2A\\u4EBA|\\u5934\\u50CF|\\u7528\\u6237)/i.test([\n node.getAttribute(\"aria-label\") || \"\",\n node.getAttribute(\"data-testid\") || \"\",\n String(node.textContent || \"\").slice(0, 80)\n ].join(\" \"))\n }))\n .filter((entry) => {\n const sidebarRect = sidebar.getBoundingClientRect();\n return entry.rect.bottom > sidebarRect.top && entry.rect.top < sidebarRect.bottom;\n })\n .sort((left, right) => Number(right.semantic) - Number(left.semantic) ||\n right.rect.bottom - left.rect.bottom)[0] : null;\n const accountButton = accountEntry?.semantic ? accountEntry.node : null;\n const accountAvatar = accountButton ? Array.from(accountButton.querySelectorAll(\"img,svg,span\"))\n .filter((node) => {\n const rect = node.getBoundingClientRect();\n return visible(node) && rect.width >= 6 && rect.width <= 64 &&\n rect.height >= 6 && rect.height <= 64;\n }).map((node) => ({\n node,\n rect: node.getBoundingClientRect(),\n image: node.tagName === \"IMG\",\n round: /(?:rounded-full|avatar)/i.test(String(node.className || \"\")),\n })).sort((left, right) => Number(right.image) - Number(left.image) ||\n Number(right.round) - Number(left.round) || left.rect.left - right.rect.left)[0]?.node : null;\n syncInterfaceImage(\n accountButton,\n \"profile-avatar\",\n theme.interfaceImagery.profileAvatar,\n accountAvatar,\n 24\n );\n const projectIcons = Array.isArray(theme.interfaceImagery.projectIcons)\n ? theme.interfaceImagery.projectIcons\n : [];\n if (sidebar && projectIcons.length > 0) {\n const sidebarRect = sidebar.getBoundingClientRect();\n const projectRows = Array.from(sidebar.querySelectorAll(\"[role=button][aria-label]\"))\n .filter((node) => {\n const rect = node.getBoundingClientRect();\n return visible(node) && String(node.className || \"\").includes(\"folder-row\") &&\n rect.bottom > sidebarRect.top && rect.top < sidebarRect.bottom;\n });\n for (const [index, row] of projectRows.slice(0, 12).entries()) {\n const nativeIcon = Array.from(row.querySelectorAll(\"svg,img\"))\n .filter((node) => {\n const rect = node.getBoundingClientRect();\n return visible(node) && rect.width >= 6 && rect.width <= 40 &&\n rect.height >= 6 && rect.height <= 40;\n })[0];\n const host = nativeIcon?.parentElement;\n if (!host || !nativeIcon) continue;\n syncInterfaceImage(\n host,\n \"project-icon-\" + String(index + 1),\n projectIcons[index % projectIcons.length],\n nativeIcon,\n 16\n );\n }\n }\n const windowTitlebar = Array.from(document.body.querySelectorAll(\"header,div\"))\n .map((node) => ({ node, rect: node.getBoundingClientRect() }))\n .filter((entry) => entry.rect.top >= -1 && entry.rect.top <= 2 &&\n entry.rect.height >= 24 && entry.rect.height <= 48 &&\n entry.rect.width >= window.innerWidth * 0.7)\n .sort((left, right) => right.rect.width - left.rect.width)[0]?.node;\n mark(windowTitlebar, \"window-titlebar\");\n const applicationMenu = Array.from(document.querySelectorAll(nativeSelectors.applicationMenu))\n .find((node) => visible(node));\n mark(applicationMenu, \"application-menu\");\n const modeSwitcher = Array.from(document.querySelectorAll(nativeSelectors.modeSwitcher))\n .find((node) => visible(node));\n mark(modeSwitcher, \"mode-switcher\");\n if (modeSwitcher) {\n const layers = Array.from(modeSwitcher.children)\n .filter((node) => node.tagName === \"SPAN\" && visible(node))\n .map((node) => ({ node, rect: node.getBoundingClientRect() }))\n .sort((left, right) => right.rect.width * right.rect.height -\n left.rect.width * left.rect.height);\n mark(layers[0]?.node, \"mode-switcher-track\");\n mark(layers[1]?.node, \"mode-switcher-selection\");\n }\n\n const currentPath = (() => {\n try { return new URL(window.location.href).pathname.toLowerCase(); }\n catch { return \"\"; }\n })();\n const routeSegments = currentPath.split(\"/\");\n const taskPath = routeSegments\n .some((segment) => /^(?:tasks?|threads?|workspaces?)$/.test(segment));\n const settingsPath = routeSegments\n .some((segment) => /^(?:settings|preferences)$/.test(segment));\n const settingsPagePattern = new RegExp(\n ${JSON.stringify(ir)}, \"i\"\n );\n const settingsPagePresent = ${Me};\n\n const textbox = settingsPagePresent\n ? null\n : Array.from(document.querySelectorAll(\n ${JSON.stringify(dt)}\n )).filter((node) => visible(node) && !insideOverlay(node) &&\n !insideTerminal(node) && (!main || main.contains(node)))\n .map((node) => ({\n node,\n rect: node.getBoundingClientRect(),\n nativeComposer: Boolean(node.closest(\n \".composer-surface-chrome,[class*='composer'],\" +\n \"[data-testid*='composer'],[data-testid*='prompt']\"\n )),\n }))\n .sort((left, right) => Number(right.nativeComposer) - Number(left.nativeComposer) ||\n right.rect.bottom - left.rect.bottom || right.rect.width - left.rect.width)[0]?.node || null;\n let composerInput = textbox;\n let composer = textbox;\n let composerChromeCandidates = [];\n if (textbox) {\n const textboxRect = textbox.getBoundingClientRect();\n const inputCandidates = [];\n const stackCandidates = [];\n const composerAncestors = [];\n let ancestor = textbox.parentElement;\n while (ancestor && ancestor !== main && ancestor !== document.body) {\n composerAncestors.push(ancestor);\n const rect = ancestor.getBoundingClientRect();\n const enclosesTextbox = rect.width >= textboxRect.width &&\n rect.height >= textboxRect.height;\n if (enclosesTextbox && rect.height <= 360 &&\n rect.width <= textboxRect.width * 1.35) stackCandidates.push(ancestor);\n if (enclosesTextbox && rect.height >= 40 && rect.height <= 220) {\n inputCandidates.push({\n node: ancestor,\n nativeComposer: typeof ancestor.className === \"string\" &&\n ancestor.className.includes(\"composer-surface-chrome\"),\n hasControls: Boolean(ancestor.querySelector(\"button,[role=button]\")),\n hasSurfaceBackground: hasSurfaceBackground(ancestor) ||\n (typeof ancestor.className === \"string\" &&\n ancestor.className.includes(\"composer-surface-chrome\"))\n });\n }\n ancestor = ancestor.parentElement;\n }\n const nativeWidthContainer = composerAncestors.find((node) =>\n typeof node.className === \"string\" &&\n (node.className.includes(\"thread-content-max-width\") ||\n node.className.includes(\"max-w-(--thread-content\"))\n );\n composerInput = inputCandidates.find((entry) => entry.nativeComposer)?.node ||\n inputCandidates.find((entry) => entry.hasSurfaceBackground)?.node ||\n inputCandidates.find((entry) => entry.hasControls)?.node ||\n inputCandidates[inputCandidates.length - 1]?.node || textbox;\n composer = nativeWidthContainer ||\n stackCandidates[stackCandidates.length - 1] || composerInput;\n composerChromeCandidates = stackCandidates.filter((node) =>\n node !== composer && node !== composerInput && hasSurfaceBackground(node)\n );\n }\n mark(composer, \"composer\");\n for (const node of composerChromeCandidates) mark(node, \"composer-chrome\");\n if (composerInput && composerInput !== composer) mark(composerInput, \"composer-input\");\n\n if (!main) return;\n\n const featureSearchInput = Array.from(main.querySelectorAll(\n 'input[id$=\"-page-search\"],input#appgen-site-search'\n ))\n .find((node) => visible(node));\n if (featureSearchInput) {\n const searchAncestors = [];\n let searchAncestor = featureSearchInput.parentElement;\n while (searchAncestor && searchAncestor !== main) {\n if (visible(searchAncestor)) searchAncestors.push(searchAncestor);\n searchAncestor = searchAncestor.parentElement;\n }\n const searchInputRect = featureSearchInput.getBoundingClientRect();\n const featureSearch = searchAncestors\n .filter((node) => {\n const rect = node.getBoundingClientRect();\n return rect.width >= searchInputRect.width && rect.height >= 24 &&\n rect.height <= 72 && hasSurfaceBackground(node);\n })\n .sort((left, right) => {\n const leftRect = left.getBoundingClientRect();\n const rightRect = right.getBoundingClientRect();\n return leftRect.width * leftRect.height - rightRect.width * rightRect.height;\n })[0] || null;\n const featureToolbar = searchAncestors.find((node) => {\n const className = typeof node.className === \"string\" ? node.className : \"\";\n return /(?:^|\\\\s)sticky(?:\\\\s|$)/.test(className) ||\n window.getComputedStyle(node).position === \"sticky\";\n }) || null;\n const featureMainRect = main.getBoundingClientRect();\n const featureMainArea = featureMainRect.width * featureMainRect.height;\n const featurePage = searchAncestors\n .filter((node) => node !== featureSearch && node !== featureToolbar &&\n hasSurfaceBackground(node))\n .map((node) => ({ node, rect: node.getBoundingClientRect() }))\n .filter((entry) => entry.rect.width >= featureMainRect.width * 0.65 &&\n entry.rect.height >= featureMainRect.height * 0.4 &&\n entry.rect.width * entry.rect.height <= featureMainArea * 0.98)\n .sort((left, right) => right.rect.width * right.rect.height -\n left.rect.width * left.rect.height)[0]?.node || null;\n mark(featurePage, \"feature-page\");\n mark(featureToolbar, \"feature-toolbar\");\n mark(featureSearch, \"feature-search\");\n }\n\n const signature = (node) => [\n node.id,\n typeof node.className === \"string\" ? node.className : \"\",\n node.getAttribute(\"role\") || \"\",\n node.getAttribute(\"aria-label\") || \"\",\n node.getAttribute(\"data-testid\") || \"\",\n ].join(\" \").toLowerCase();\n const workspacePattern = /(?:workbench|workspace|review|terminal|browser|files|\\u5DE5\\u4F5C\\u533A|\\u5BA1\\u9605|\\u7EC8\\u7AEF|\\u6D4F\\u89C8\\u5668|\\u6587\\u4EF6)/i;\n const isTerminalSurface = (node) => {\n if (!visible(node)) return false;\n const rect = node.getBoundingClientRect();\n return rect.width >= 160 && rect.height >= 60;\n };\n const routeMains = Array.from(main.querySelectorAll(nativeSelectors.routeMain))\n .filter((node) => node !== main && visible(node));\n const chatGptLandingRoute = modeSwitcher && composer ? routeMains.find((node) => {\n if (!node.contains(composer)) return false;\n const routeRect = node.getBoundingClientRect();\n const composerRect = composer.getBoundingClientRect();\n return composerRect.bottom <= routeRect.bottom - Math.max(96, routeRect.height * 0.15);\n }) : null;\n const homeRoute = routeMains.find((node) =>\n Boolean(node.querySelector(nativeSelectors.homeMarker))\n ) || chatGptLandingRoute;\n let taskRoute = settingsPagePresent ? null : routeMains\n .filter((node) => node !== homeRoute)\n .sort((left, right) => {\n const leftRect = left.getBoundingClientRect();\n const rightRect = right.getBoundingClientRect();\n return rightRect.width * rightRect.height - leftRect.width * leftRect.height;\n })[0] || null;\n if (!settingsPagePresent && !homeRoute && !taskRoute &&\n !main.querySelector(nativeSelectors.homeMarker)) {\n const hasTaskTool = taskPath || Array.from(main.querySelectorAll(nativeSelectors.terminal))\n .some((node) => isTerminalSurface(node)) ||\n Array.from(main.querySelectorAll(\"[aria-label],[data-testid]\"))\n .some((node) => workspacePattern.test(signature(node)));\n if (hasTaskTool) {\n const mainRect = main.getBoundingClientRect();\n taskRoute = Array.from(main.children)\n .filter((node) => {\n if (!visible(node) || node === composer || (composer && composer.contains(node))) {\n return false;\n }\n const rect = node.getBoundingClientRect();\n return rect.width >= mainRect.width * 0.7 && rect.height >= mainRect.height * 0.55;\n })\n .sort((left, right) => {\n const leftRect = left.getBoundingClientRect();\n const rightRect = right.getBoundingClientRect();\n return rightRect.width * rightRect.height - leftRect.width * leftRect.height;\n })[0] || null;\n }\n }\n mark(taskRoute, \"task\");\n\n let settingsRoute = null;\n if (!taskRoute && !homeRoute && settingsPagePresent) {\n const mainRect = main.getBoundingClientRect();\n const mainArea = mainRect.width * mainRect.height;\n settingsRoute = Array.from(main.querySelectorAll(\n \"main,section,article,aside,div,[role=region],[role=tabpanel],[aria-label]\"\n )).filter((node) => {\n if (!visible(node) || node === main || node === composer ||\n (composer && node.contains(composer)) ||\n node.getAttribute(\"data-open-chatgpt-skin-surface\")) return false;\n const rect = node.getBoundingClientRect();\n const area = rect.width * rect.height;\n const semantic = settingsPagePattern.test(signature(node));\n return rect.width >= mainRect.width * 0.55 &&\n rect.height >= mainRect.height * 0.45 &&\n area <= mainArea * 0.96 &&\n (semantic || (settingsPagePresent && hasSurfaceBackground(node)));\n }).map((node) => ({\n node,\n rect: node.getBoundingClientRect(),\n semantic: settingsPagePattern.test(signature(node)),\n background: hasSurfaceBackground(node),\n })).sort((left, right) =>\n Number(right.background) - Number(left.background) ||\n Number(right.semantic) - Number(left.semantic) ||\n right.rect.width * right.rect.height - left.rect.width * left.rect.height\n )[0]?.node || null;\n }\n mark(settingsRoute, \"settings\");\n\n const settingsPanels = settingsRoute ? Array.from(settingsRoute.querySelectorAll(\n \"section,article,aside,div,[role=region],[role=tabpanel],[aria-label]\"\n )).filter((node) => {\n if (!visible(node) || node === settingsRoute || node === composer ||\n (composer && node.contains(composer)) ||\n node.getAttribute(\"data-open-chatgpt-skin-surface\")) return false;\n const rootRect = settingsRoute.getBoundingClientRect();\n const rect = node.getBoundingClientRect();\n return rect.width >= Math.max(180, rootRect.width * 0.45) &&\n rect.width <= rootRect.width * 0.98 && rect.height >= 56 &&\n (hasSurfaceBackground(node) || settingsPagePattern.test(signature(node)));\n }).sort((left, right) => {\n const leftRect = left.getBoundingClientRect();\n const rightRect = right.getBoundingClientRect();\n return rightRect.width * rightRect.height - leftRect.width * leftRect.height;\n }).slice(0, 128) : [];\n for (const panel of settingsPanels) mark(panel, \"settings-panel\");\n\n const taskRoot = taskRoute || main;\n const terminalNodes = taskRoute\n ? Array.from(taskRoot.querySelectorAll(nativeSelectors.terminal))\n .filter((node) => isTerminalSurface(node))\n : [];\n for (const terminal of terminalNodes) mark(terminal, \"terminal\");\n\n const resourceCards = taskRoute ? Array.from(taskRoot.querySelectorAll(\n ${JSON.stringify(Hn)}\n )).filter((node) => visible(node) && insideThreadConversation(node) &&\n node !== composer && (!composer || !composer.contains(node))) : [];\n for (const resourceCard of resourceCards) mark(resourceCard, \"resource-card\");\n\n const workspacePanels = taskRoute ? Array.from(taskRoot.querySelectorAll(\n \"section,[role=region],[role=tabpanel],[aria-label]\"\n )).filter((node) => {\n if (!visible(node) || node === taskRoute || node === composer ||\n (composer && node.contains(composer)) || insideThreadConversation(node)) return false;\n const rect = node.getBoundingClientRect();\n return rect.width >= 180 && rect.height >= 80 && workspacePattern.test(signature(node));\n }) : [];\n for (const panel of workspacePanels) mark(panel, \"workspace-panel\");\n\n if (taskRoute) {\n const taskRect = taskRoute.getBoundingClientRect();\n const taskArea = taskRect.width * taskRect.height;\n const opaquePanels = Array.from(taskRoute.querySelectorAll(\"section,aside,div\"))\n .filter((node) => {\n if (!visible(node) || node === taskRoute || node === composer ||\n (composer && node.contains(composer)) ||\n insideThreadConversation(node) ||\n node.getAttribute(\"data-open-chatgpt-skin-surface\")) return false;\n const rect = node.getBoundingClientRect();\n const area = rect.width * rect.height;\n return rect.width >= Math.max(180, taskRect.width * 0.25) &&\n rect.height >= Math.max(80, taskRect.height * 0.16) &&\n area <= taskArea * 0.82 && hasSurfaceBackground(node);\n })\n .sort((left, right) => {\n const leftRect = left.getBoundingClientRect();\n const rightRect = right.getBoundingClientRect();\n return rightRect.width * rightRect.height - leftRect.width * leftRect.height;\n })\n .slice(0, 8);\n for (const panel of opaquePanels) mark(panel, \"workspace-panel\");\n }\n\n if (!taskRoute && !settingsRoute && !main.querySelector(nativeSelectors.homeMarker)) {\n const mainRect = main.getBoundingClientRect();\n const mainArea = mainRect.width * mainRect.height;\n const secondaryPanels = Array.from(main.querySelectorAll(\n \"section,aside,div[class*='bg-token-main-surface-primary'],\" +\n \"[role=region],[role=tabpanel],[aria-label]\"\n )).filter((node) => {\n if (!visible(node) || node === composer || (composer && node.contains(composer)) ||\n node.getAttribute(\"data-open-chatgpt-skin-surface\")) return false;\n const rect = node.getBoundingClientRect();\n const area = rect.width * rect.height;\n return rect.width >= 180 && rect.height >= 64 && area <= mainArea * 0.98 &&\n (hasSurfaceBackground(node) || settingsPagePattern.test(signature(node)));\n }).sort((left, right) => {\n const leftRect = left.getBoundingClientRect();\n const rightRect = right.getBoundingClientRect();\n return rightRect.width * rightRect.height - leftRect.width * leftRect.height;\n }).slice(0, 12);\n for (const panel of secondaryPanels) mark(panel, \"workspace-panel\");\n }\n\n for (const terminal of terminalNodes) {\n let ancestor = terminal.parentElement;\n while (ancestor && ancestor !== taskRoot && ancestor !== main) {\n if (workspacePattern.test(signature(ancestor)) && visible(ancestor)) {\n mark(ancestor, \"workspace-panel\");\n break;\n }\n ancestor = ancestor.parentElement;\n }\n }\n\n const markedPanels = Array.from(taskRoot.querySelectorAll(\n '[data-open-chatgpt-skin-surface=\"workspace-panel\"]'\n ));\n const taskWorkbench = taskRoute ? Array.from(taskRoot.querySelectorAll(\"section,aside,div\"))\n .filter((node) => node !== taskRoute && node !== composer && visible(node) &&\n !overlapsThreadConversation(node))\n .map((node) => ({\n node,\n rect: node.getBoundingClientRect(),\n panels: markedPanels.filter((panel) => node.contains(panel)).length,\n semantic: /(?:workbench|workspace|\\u5DE5\\u4F5C\\u533A)/i.test(signature(node)),\n }))\n .filter((entry) => entry.semantic || entry.panels >= 2)\n .sort((left, right) => Number(right.semantic) - Number(left.semantic) ||\n right.panels - left.panels ||\n right.rect.width * right.rect.height - left.rect.width * left.rect.height)[0]?.node : null;\n const auxiliaryMainRect = main.getBoundingClientRect();\n const auxiliaryMainArea = auxiliaryMainRect.width * auxiliaryMainRect.height;\n const auxiliaryWorkbench = !settingsPagePresent\n ? Array.from(main.querySelectorAll(\"aside\"))\n .filter((node) => visible(node) && node !== composer &&\n (!composer || !node.contains(composer)) && !insideOverlay(node) &&\n Boolean(node.querySelector(\"[class*='bg-token-main-surface-primary']\")))\n .map((node) => ({ node, rect: node.getBoundingClientRect() }))\n .filter(({ rect }) => {\n const area = rect.width * rect.height;\n return rect.width >= Math.max(180, auxiliaryMainRect.width * 0.25) &&\n rect.width <= auxiliaryMainRect.width * 0.85 &&\n rect.height >= auxiliaryMainRect.height * 0.55 &&\n area <= auxiliaryMainArea * 0.88 &&\n rect.left >= auxiliaryMainRect.left + auxiliaryMainRect.width * 0.15 &&\n rect.right >= auxiliaryMainRect.right - 8;\n })\n .sort((left, right) => right.rect.width * right.rect.height -\n left.rect.width * left.rect.height)[0]?.node || null\n : null;\n const workbench = taskWorkbench || auxiliaryWorkbench;\n mark(workbench, \"workbench\");\n\n for (const overlay of document.querySelectorAll(nativeSelectors.overlay)) {\n if (!visible(overlay)) continue;\n mark(overlay, \"overlay\");\n const overlayRect = overlay.getBoundingClientRect();\n for (const child of overlay.children) {\n if (!visible(child) || !hasSurfaceBackground(child)) continue;\n const childRect = child.getBoundingClientRect();\n if (childRect.width >= overlayRect.width * 0.8 &&\n childRect.height >= overlayRect.height * 0.5) {\n mark(child, \"overlay\");\n }\n }\n }\n\n if (composer && composerInput) {\n const composerRect = composer.getBoundingClientRect();\n const inputRect = composerInput.getBoundingClientRect();\n const statusBanners = Array.from(composer.querySelectorAll(\n \"aside,[role=alert],[role=status]\"\n )).filter((node) => node !== composerInput && !node.contains(composerInput) &&\n visible(node) && Boolean(node.querySelector(\"h1,h2,h3,h4,[role=heading]\")));\n for (const banner of statusBanners) mark(banner, \"status-banner\");\n const projectPickerCandidates = Array.from(composer.querySelectorAll(\"*\"))\n .filter((node) => node !== composerInput && !composerInput.contains(node) &&\n !node.contains(composerInput) && visible(node) &&\n !node.closest('[data-open-chatgpt-skin-surface=\"status-banner\"]'))\n .map((node) => ({\n node,\n rect: node.getBoundingClientRect(),\n hasSurfaceBackground: hasSurfaceBackground(node)\n }))\n .filter((entry) => entry.rect.top < inputRect.bottom &&\n entry.rect.bottom <= inputRect.bottom + 1 && entry.rect.height >= 24 &&\n entry.rect.height <= 80 && entry.rect.width >= composerRect.width * 0.55)\n .sort((left, right) => Number(right.hasSurfaceBackground) -\n Number(left.hasSurfaceBackground) ||\n left.rect.width * left.rect.height - right.rect.width * right.rect.height);\n const compactProjectControl = Array.from(composer.querySelectorAll(\n \"button,[role=button]\"\n )).filter((node) => visible(node) && /(?:switch project|project|\\u9879\\u76EE)/i.test([\n node.getAttribute(\"aria-label\") || \"\",\n node.getAttribute(\"data-testid\") || \"\",\n ].join(\" \")))[0];\n const projectPicker = projectPickerCandidates[0]?.node || compactProjectControl;\n let projectPickerStack = projectPicker;\n let projectPickerAncestor = projectPicker?.parentElement;\n while (projectPickerCandidates.length > 0 && projectPickerAncestor &&\n projectPickerAncestor !== composer &&\n !projectPickerAncestor.contains(composerInput)) {\n projectPickerStack = projectPickerAncestor;\n projectPickerAncestor = projectPickerAncestor.parentElement;\n }\n mark(projectPickerStack, \"project-picker-stack\");\n mark(projectPicker, \"project-picker\");\n }\n\n const buttons = Array.from(main.querySelectorAll(\"button,[role=button]\"))\n .filter((node) => visible(node) && (!composer || !composer.contains(node)));\n const interactiveCards = buttons.filter((node) => {\n const rect = node.getBoundingClientRect();\n return rect.width >= 120 && rect.height >= 64;\n });\n const groups = new Map();\n for (const card of interactiveCards) {\n let ancestor = card.parentElement;\n while (ancestor && ancestor !== main) {\n const entry = groups.get(ancestor) || { count: 0, cards: [] };\n entry.count += 1;\n entry.cards.push(card);\n groups.set(ancestor, entry);\n ancestor = ancestor.parentElement;\n }\n }\n for (const parent of main.querySelectorAll(\"*\")) {\n const cards = Array.from(parent.children).filter((child) => {\n const rect = child.getBoundingClientRect();\n return visible(child) && rect.width >= 100 && rect.height >= 50;\n });\n if (cards.length < 3 || cards.length > 8) continue;\n const existing = groups.get(parent);\n if (!existing || existing.count < cards.length) {\n groups.set(parent, { count: cards.length, cards });\n }\n }\n const suggestionsEntry = taskRoute || settingsRoute ? null : Array.from(groups.entries())\n .filter(([, entry]) => entry.count >= 3 && entry.count <= 8)\n .map(([node, entry]) => ({\n node,\n cards: entry.cards,\n rect: node.getBoundingClientRect()\n }))\n .filter((entry) => entry.rect.width >= 360 && entry.rect.height >= 64 &&\n entry.rect.height <= 480 && (!composer || !entry.node.contains(composer)))\n .sort((left, right) =>\n left.rect.width * left.rect.height - right.rect.width * right.rect.height\n )[0];\n const suggestions = suggestionsEntry?.node;\n if (suggestionsEntry) {\n mark(suggestionsEntry.node, \"suggestions\");\n const suggestionCards = suggestionsEntry.cards.slice(0, 8).sort((left, right) => {\n const leftRect = left.getBoundingClientRect();\n const rightRect = right.getBoundingClientRect();\n return leftRect.top - rightRect.top || leftRect.left - rightRect.left;\n });\n for (const card of suggestionCards) {\n mark(card, \"card\");\n }\n for (const [index, card] of suggestionCards.slice(0, 4).entries()) {\n const nativeIcon = Array.from(card.querySelectorAll(\"svg,img\"))\n .filter((node) => {\n const rect = node.getBoundingClientRect();\n return visible(node) && rect.width >= 6 && rect.width <= 64 &&\n rect.height >= 6 && rect.height <= 64;\n })[0];\n const key = \"suggestion-card\" + String(index + 1);\n syncInterfaceImage(\n card,\n key,\n theme.interfaceImagery.suggestionIcons[\"card\" + String(index + 1)],\n nativeIcon,\n 20\n );\n }\n }\n\n const heading = taskRoute || settingsRoute\n ? null\n : resolveHomeHeading(main, composer, suggestions, visible);\n let hero = heading;\n if (heading) {\n const heroCandidates = [];\n let ancestor = heading.parentElement;\n while (ancestor && ancestor !== main) {\n const rect = ancestor.getBoundingClientRect();\n if (visible(ancestor) && rect.height <= 480 &&\n (!suggestions || !ancestor.contains(suggestions)) &&\n (!composer || !ancestor.contains(composer))) {\n heroCandidates.push(ancestor);\n }\n ancestor = ancestor.parentElement;\n }\n hero = heroCandidates[heroCandidates.length - 1] || heading;\n }\n mark(hero, \"hero\");\n const activeHome = Boolean(homeRoute || main.querySelector(nativeSelectors.homeMarker)) &&\n !taskPath && !settingsPath && !taskRoute && !settingsRoute;\n mark(activeHome ? homeRoute : null, \"home-route\");\n mark(activeHome ? heading : null, \"home-heading\");\n syncWelcome(\n main,\n hero,\n heading,\n activeHome,\n );\n syncCompositionLayers(main, hero, suggestions, activeHome);\n\n const mainRect = main.getBoundingClientRect();\n const explicitTopbar = Array.from(main.querySelectorAll(\n \"header.app-header-tint,header,[class~='app-header-tint']\"\n )).filter((node) => visible(node) && !insideOverlay(node) &&\n node !== composer && (!composer || !node.contains(composer)))\n .map((node) => ({ node, rect: node.getBoundingClientRect() }))\n .filter((entry) => entry.rect.width >= mainRect.width * 0.5 &&\n entry.rect.height >= 24 && entry.rect.height <= 96 &&\n entry.rect.top >= mainRect.top - 2 &&\n entry.rect.top <= mainRect.top + 120)\n .sort((left, right) => left.rect.top - right.rect.top ||\n right.rect.width * right.rect.height - left.rect.width * left.rect.height)[0]?.node;\n const topbar = explicitTopbar || Array.from(main.children)\n .map((node) => ({ node, rect: node.getBoundingClientRect() }))\n .filter((entry) => entry.rect.width > 0 && entry.rect.height > 0 &&\n entry.rect.height <= 96 && entry.rect.top >= mainRect.top - 2 &&\n entry.rect.top <= mainRect.top + 96)\n .sort((left, right) => left.rect.top - right.rect.top)[0]?.node;\n if (topbar && topbar !== hero && topbar !== suggestions && topbar !== composer) {\n mark(topbar, \"topbar\");\n }\n const explicitScrollFades = Array.from(main.querySelectorAll(nativeSelectors.scrollFade));\n const topFade = main.querySelector(\".app-shell-main-content-top-fade\") ||\n Array.from(main.querySelectorAll(\"div\"))\n .map((node) => ({\n node,\n rect: node.getBoundingClientRect(),\n backgroundImage: window.getComputedStyle(node).backgroundImage\n }))\n .find((entry) => entry.rect.width >= mainRect.width * 0.75 &&\n entry.rect.height > 0 && entry.rect.height <= 32 &&\n entry.rect.top >= mainRect.top && entry.rect.top <= mainRect.top + 120 &&\n typeof entry.backgroundImage === \"string\" &&\n entry.backgroundImage.includes(\"gradient\"))?.node;\n mark(topFade, \"top-fade\");\n for (const fade of explicitScrollFades) {\n const interactive = fade.matches(\n \"button,[role=button],input,textarea,[contenteditable=true]\"\n ) || Boolean(fade.querySelector(\n \"button,[role=button],input,textarea,[contenteditable=true]\"\n ));\n if (fade !== topFade && !interactive) mark(fade, \"scroll-fade\");\n }\n const scrollFadePattern = /(?:fade|gradient|mask|scroll-shadow)/i;\n const composerFadeMinimumWidth = composer\n ? composer.getBoundingClientRect().width * 0.75\n : Number.POSITIVE_INFINITY;\n const gradientScrollFades = Array.from(main.querySelectorAll(\"div\"))\n .map((node) => ({\n node,\n rect: node.getBoundingClientRect(),\n backgroundImage: window.getComputedStyle(node).backgroundImage,\n insideComposer: Boolean(composer && composer.contains(node)),\n signature: [\n node.id,\n typeof node.className === \"string\" ? node.className : \"\",\n node.getAttribute(\"data-testid\") || \"\",\n ].join(\" \"),\n }))\n .filter((entry) => entry.node !== topFade &&\n !entry.node.getAttribute(\"data-open-chatgpt-skin-surface\") &&\n !entry.node.matches(\"button,[role=button],input,textarea,[contenteditable=true]\") &&\n !entry.node.querySelector(\"button,[role=button],input,textarea,[contenteditable=true]\") &&\n (entry.rect.width >= mainRect.width * 0.5 ||\n (entry.insideComposer && entry.rect.width >= composerFadeMinimumWidth)) &&\n entry.rect.height > 0 && entry.rect.height <= 120 &&\n typeof entry.backgroundImage === \"string\" &&\n entry.backgroundImage.includes(\"gradient\") &&\n scrollFadePattern.test(entry.signature))\n .slice(0, 8);\n for (const entry of gradientScrollFades) mark(entry.node, \"scroll-fade\");\n\n syncReviewShadowThemes();\n\n for (const overlay of document.querySelectorAll(interfaceImageSelector)) {\n if (!usedInterfaceImages.has(overlay)) overlay.remove();\n }\n\n if (hero && suggestions && composer) {\n const currentRelief = Number.parseFloat(\n document.documentElement.style.getPropertyValue(overlapReliefProperty)\n ) || 0;\n const heroRect = hero.getBoundingClientRect();\n const suggestionsRect = suggestions.getBoundingClientRect();\n const composerRect = composer.getBoundingClientRect();\n const baseOverlap = suggestionsRect.bottom + 16 - composerRect.top + currentRelief;\n const baseHeroHeight = heroRect.height + currentRelief;\n const maximumRelief = Math.max(0, baseHeroHeight - 180);\n const relief = Math.ceil(Math.min(Math.max(0, baseOverlap), maximumRelief));\n if (Math.abs(relief - currentRelief) >= 1) {\n document.documentElement.style.setProperty(\n overlapReliefProperty,\n String(relief) + \"px\"\n );\n }\n } else if (document.documentElement.style.getPropertyValue(overlapReliefProperty)) {\n document.documentElement.style.removeProperty(overlapReliefProperty);\n }\n };\n\n const image = new window.Image();\n image.src = theme.backgroundDataUrl;\n if (typeof image.decode === \"function\") {\n await image.decode();\n } else {\n await new Promise((resolve, reject) => {\n image.onload = () => resolve(undefined);\n image.onerror = () => reject(new Error(\"background image failed to load\"));\n });\n }\n\n const style = document.createElement(\"style\");\n style.dataset.openChatgptSkin = \"theme\";\n style.dataset.openChatgptSkinOwner = theme.themeId;\n style.dataset.openChatgptSkinBackgroundReady = \"true\";\n style.__openChatgptSkinVerificationRecord = verificationRecord;\n style.textContent = \":root{--ocs-background-image:url(\" + theme.backgroundDataUrl + \")}\" +\n theme.themeCss;\n document.head.append(style);\n\n if (theme.fontCss) {\n const fonts = document.createElement(\"style\");\n fonts.dataset.openChatgptSkin = \"fonts\";\n fonts.textContent = theme.fontCss;\n document.head.append(fonts);\n }\n\n const decorationPosition = (placement, index) => {\n if (placement === \"corners\") {\n const corners = [[4, 4], [92, 4], [4, 88], [92, 88]];\n const corner = corners[index % corners.length];\n return { left: corner[0] + \"%\", top: corner[1] + \"%\" };\n }\n if (placement === \"hero\") {\n return { left: String(12 + ((index * 19) % 76)) + \"%\", top: String(8 + ((index * 7) % 18)) + \"%\" };\n }\n if (placement === \"cards\") {\n return { left: String(8 + ((index * 29) % 80)) + \"%\", top: String(28 + ((index * 17) % 56)) + \"%\" };\n }\n return { left: String((index * 37) % 100) + \"%\", top: String((index * 61) % 100) + \"%\" };\n };\n\n const decorations = document.createElement(\"div\");\n decorations.dataset.openChatgptSkin = \"decorations\";\n Object.assign(decorations.style, {\n position: \"fixed\",\n inset: \"0\",\n overflow: \"hidden\",\n pointerEvents: \"none\",\n zIndex: \"-1\",\n contain: \"strict\"\n });\n\n for (const descriptor of theme.decorations) {\n for (let index = 0; index < descriptor.count; index += 1) {\n const item = document.createElement(\"span\");\n item.dataset.openChatgptSkinDecoration = descriptor.kind;\n Object.assign(item.style, {\n position: \"absolute\",\n pointerEvents: \"none\",\n zIndex: \"-1\",\n opacity: String(descriptor.opacity),\n width: String(8 * descriptor.scale) + \"px\",\n height: String(8 * descriptor.scale) + \"px\",\n borderRadius: \"999px\",\n background: \"var(--ocs-secondary)\",\n ...decorationPosition(descriptor.placement, index)\n });\n if (descriptor.asset !== undefined) {\n const dataUrl = theme.assetDataUrls[descriptor.asset];\n if (typeof dataUrl !== \"string\") continue;\n Object.assign(item.style, {\n backgroundImage: \"url(\" + dataUrl + \")\",\n backgroundSize: \"contain\",\n backgroundRepeat: \"no-repeat\",\n backgroundPosition: \"center\",\n backgroundColor: \"transparent\"\n });\n }\n decorations.append(item);\n }\n }\n\n document.body.append(decorations);\n markSurfaces();\n window[markerKey] = markSurfaces;\n window[refreshListenerKey] = markSurfaces;\n window.addEventListener(\"resize\", markSurfaces);\n window.addEventListener(\"scroll\", markSurfaces, true);\n const observer = new window.MutationObserver(() => markSurfaces());\n observer.observe(document.body, {\n childList: true,\n subtree: true,\n attributes: true,\n attributeFilter: [\"class\", \"hidden\", \"aria-hidden\"]\n });\n window[observerKey] = observer;\n return true;\n })()`}var pr=`(() => {\n const visible = (node) => {\n if (!node) return false;\n const rect = node.getBoundingClientRect();\n return rect.width > 0 && rect.height > 0;\n };\n const marker = window[${JSON.stringify(ct)}];\n if (typeof marker === \"function\") marker();\n const shadowRecords = Array.isArray(window[${JSON.stringify(Te)}])\n ? window[${JSON.stringify(Te)}]\n : [];\n const themedShadowRoots = new Set(shadowRecords.map((record) => record.root));\n const reviewShadowHosts = Array.from(document.querySelectorAll(\n \"diffs-container,file-tree-container\"\n )).filter((host) => visible(host));\n const main = document.querySelector(\"main,[role=main]\");\n const composerVisible = ${lt};\n const decorations = document.querySelector('[data-open-chatgpt-skin=\"decorations\"]');\n const theme = document.querySelector('[data-open-chatgpt-skin=\"theme\"]');\n const verificationRecord = theme?.__openChatgptSkinVerificationRecord;\n const managedLayers = Array.from(document.querySelectorAll(\n '[data-open-chatgpt-skin-layer-id]'\n ));\n const welcomeNodes = Array.from(document.querySelectorAll(\n '[data-open-chatgpt-skin=\"welcome\"]'\n ));\n const welcomeValid = Boolean(verificationRecord?.welcomeSupported) &&\n (verificationRecord.welcomeExpected\n ? welcomeNodes.length === 1 &&\n welcomeNodes[0].dataset.openChatgptSkinOwner === verificationRecord.themeId &&\n welcomeNodes[0].style.pointerEvents === \"none\" &&\n welcomeNodes[0].__openChatgptSkinWelcomeRecord?.heading?.style?.visibility === \"hidden\"\n : welcomeNodes.length === 0);\n const requiredLayersResolved = Boolean(verificationRecord?.requiredLayersResolved) &&\n verificationRecord.requiredLayerIds.every((layerId) => managedLayers.filter((layer) =>\n layer.dataset.openChatgptSkinLayerId === layerId &&\n layer.closest('[data-open-chatgpt-skin=\"composition-layer\"]')\n ?.dataset.openChatgptSkinOwner === verificationRecord.themeId\n ).length === 1);\n const mainSurface = document.querySelector('[data-open-chatgpt-skin-surface=\"main\"]');\n const sidebarSurface = document.querySelector('[data-open-chatgpt-skin-surface=\"sidebar\"]');\n const composerSurface = document.querySelector('[data-open-chatgpt-skin-surface=\"composer\"]');\n const composerInputSurface = document.querySelector(\n '[data-open-chatgpt-skin-surface=\"composer-input\"]'\n );\n const composerRect = (composerInputSurface || composerSurface)\n ? (composerInputSurface || composerSurface).getBoundingClientRect()\n : null;\n const viewportHeight = window.innerHeight || document.documentElement.clientHeight;\n const composerOptional = ${Me};\n return {\n themeMarkers: document.querySelectorAll('[data-open-chatgpt-skin=\"theme\"]').length,\n welcomeValid,\n requiredLayersResolved,\n managedLayerCount: managedLayers.length,\n fontMarkers: document.querySelectorAll('[data-open-chatgpt-skin=\"fonts\"]').length,\n decorationMarkers: document.querySelectorAll('[data-open-chatgpt-skin=\"decorations\"]').length,\n backgroundReady: Boolean(theme && theme.dataset.openChatgptSkinBackgroundReady === \"true\"),\n decorationPointerEvents: decorations ? window.getComputedStyle(decorations).pointerEvents : null,\n surfaceMarkers: document.querySelectorAll('[data-open-chatgpt-skin-surface]').length,\n mainSurfaceReady: Boolean(mainSurface),\n sidebarSurfaceReady: Boolean(sidebarSurface),\n composerSurfaceReady: Boolean(composerSurface) || composerOptional,\n composerWithinViewport: composerOptional || (Boolean(composerRect) &&\n (!Number.isFinite(viewportHeight) || viewportHeight <= 0 ||\n (composerRect.top >= 0 && composerRect.bottom <= viewportHeight))),\n horizontalOverflow: document.documentElement.scrollWidth > document.documentElement.clientWidth,\n mainVisible: Boolean(main),\n composerVisible: composerVisible || composerOptional,\n reviewShadowReady: reviewShadowHosts.every((host) =>\n Boolean(host.shadowRoot && themedShadowRoots.has(host.shadowRoot))\n )\n };\n})()`,mr=`(() => {\n const visible = (node) => {\n if (!node) return false;\n const rect = node.getBoundingClientRect();\n return rect.width > 0 && rect.height > 0;\n };\n const composerOptional = ${Me};\n const countShadowStyles = (root) => {\n let count = 0;\n for (const node of root.querySelectorAll(\"*\")) {\n const shadow = node.shadowRoot;\n if (!shadow) continue;\n count += shadow.querySelectorAll('style[data-open-chatgpt-skin-shadow]').length;\n count += countShadowStyles(shadow);\n }\n return count;\n };\n const shadowRecords = Array.isArray(window[${JSON.stringify(Te)}])\n ? window[${JSON.stringify(Te)}].length\n : 0;\n return {\n managedMarkers: document.querySelectorAll(\"[data-open-chatgpt-skin]\").length +\n document.querySelectorAll('[data-open-chatgpt-skin-surface]').length +\n countShadowStyles(document) + shadowRecords,\n horizontalOverflow: document.documentElement.scrollWidth > document.documentElement.clientWidth,\n mainVisible: visible(document.querySelector(\"main,[role=main]\")),\n navigationVisible: visible(document.querySelector(\"nav,aside,[role=navigation]\")),\n composerVisible: ${lt} || composerOptional\n };\n})()`;var Le,Ze=\"__openChatGPTSkinCompiledTheme\",hr=`globalThis[${JSON.stringify(Ze)}]`;function Zn(){let t=globalThis;t.Buffer||(t.Buffer={byteLength:e=>new TextEncoder().encode(e).byteLength,from:e=>({toString:r=>{if(r!==\"base64\")throw new Error(\"unsupported browser buffer encoding\");let o=\"\";for(let n of e)o+=String.fromCharCode(n);return btoa(o)}})})}function Un(t){if(typeof t!=\"string\"||!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(t))throw new Error(\"staged theme asset is not base64\");let e=atob(t),r=new Uint8Array(e.length);for(let o=0;o32*1024*1024)throw new Error(\"staged theme size is invalid\");return Jt({theme:t.theme,files:new Map([...t.files].map(([e,r])=>[e,Un(r)])),totalBytes:t.totalBytes})}function yr(){return globalThis}function We(){delete yr()[Ze]}function Yn(t){We(),Object.defineProperty(globalThis,Ze,{configurable:!0,enumerable:!1,writable:!1,value:t})}function fr(){let t=yr()[Ze];if(!t||typeof t!=\"object\")throw new Error(\"no compiled theme is prepared\");return t}function Xn(t){return t.themeMarkers===1&&t.welcomeValid===!0&&t.requiredLayersResolved===!0&&t.fontMarkers!==void 0&&typeof t.fontMarkers==\"number\"&&t.fontMarkers<=1&&t.decorationMarkers===1&&t.decorationPointerEvents===\"none\"&&typeof t.surfaceMarkers==\"number\"&&t.surfaceMarkers>=3&&t.mainSurfaceReady===!0&&t.sidebarSurfaceReady===!0&&t.composerSurfaceReady===!0&&t.composerWithinViewport===!0&&t.horizontalOverflow===!1&&t.mainVisible===!0&&t.composerVisible===!0&&t.reviewShadowReady===!0&&t.backgroundReady===!0}function Jn(t){return t.managedMarkers===0&&t.horizontalOverflow===!1&&t.mainVisible===!0&&t.navigationVisible===!0&&t.composerVisible===!0}Zn();Object.prototype.hasOwnProperty.call(globalThis,\"__openChatGPTSkinAdapter\")||Object.defineProperty(globalThis,\"__openChatGPTSkinAdapter\",{configurable:!1,enumerable:!1,writable:!1,value:Object.freeze({begin(t,e){return We(),Le={theme:t,totalBytes:e,files:new Map},!0},append(t,e){if(!/^(?:assets\\/)?[A-Za-z0-9][A-Za-z0-9._/-]{0,240}$/.test(t)||t.includes(\"..\")||typeof e!=\"string\")throw new Error(\"staged theme asset path is invalid\");let r=gr(),o=(r.files.get(t)??\"\")+e;if(o.length>24*1024*1024)throw new Error(\"staged theme asset is too large\");return r.files.set(t,o),!0},prepareApply:()=>(Yn(Kn()),Le=void 0,!0),source:t=>{switch(t){case\"preflight\":return lr(fr(),hr);case\"apply\":return ur(fr(),hr);case\"verify\":return pr;case\"remove\":return dr;case\"verifyOfficial\":return mr;default:throw new Error(\"unknown Adapter expression\")}},validatePreflight:t=>t?.valid===!0&&t.requiredLayersResolved===!0,validateVerification:t=>{let e=Xn(t);return e&&We(),e},validateRestore:(t,e)=>{let r=t===0&&Jn(e);return We(),Le=void 0,r}})});})();\n;return Object.prototype.hasOwnProperty.call(globalThis,\"__openChatGPTSkinAdapter\")&&Object.isFrozen(globalThis.__openChatGPTSkinAdapter)})()", + "sha256": "5a292b665cb3eecd14400e517c122c0bcf9d62721826ac4e4a49bb8a08bfc974" +} diff --git a/package-lock.json b/package-lock.json index 8cab6d7..7681acb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "devDependencies": { "@types/node": "^22.20.1", "@types/ws": "^8.18.1", + "esbuild": "^0.28.1", "fflate": "^0.8.3", "linkedom": "^0.18.12", "sharp": "^0.34.5", diff --git a/package.json b/package.json index 7c52f18..445ec8f 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "contracts:verify": "tsx --tsconfig tsconfig.scripts.json scripts/contracts/verify.ts", "contracts:baseline": "tsx --tsconfig tsconfig.scripts.json scripts/contracts/baseline-runner.ts", "go:spike:test": "go -C host/go test -buildvcs=false -tags nodynamic ./... -count=1 -timeout 60s", + "go:cdp-adapter:build": "tsx --tsconfig tsconfig.scripts.json scripts/build-go-cdp-adapter.ts", "go:spike:package": "tsx --tsconfig tsconfig.scripts.json scripts/release/build-go-spike.ts", "go:spike:acceptance": "tsx --tsconfig tsconfig.scripts.json scripts/release/accept-go-spike.ts", "go:spike:merge": "tsx --tsconfig tsconfig.scripts.json scripts/release/merge-go-spike.ts", @@ -56,6 +57,7 @@ "devDependencies": { "@types/node": "^22.20.1", "@types/ws": "^8.18.1", + "esbuild": "^0.28.1", "fflate": "^0.8.3", "linkedom": "^0.18.12", "sharp": "^0.34.5", diff --git a/packages/cdp-adapter/src/scripts.ts b/packages/cdp-adapter/src/scripts.ts index ea8e164..4c6d91d 100644 --- a/packages/cdp-adapter/src/scripts.ts +++ b/packages/cdp-adapter/src/scripts.ts @@ -209,8 +209,8 @@ export const PROBE_EXPRESSION = `(() => { }; })()`; -export function preflightExpression(theme: CompiledTheme): string { - const payload = JSON.stringify(theme); +export function preflightExpression(theme: CompiledTheme, themeReference?: string): string { + const payload = themeReference ?? JSON.stringify(theme); const nativeSelectors = JSON.stringify(NATIVE_CODEX_SURFACE_SELECTORS); return `(() => { const theme = ${payload}; @@ -319,8 +319,8 @@ export function preflightExpression(theme: CompiledTheme): string { })()`; } -export function applyExpression(theme: CompiledTheme): string { - const payload = JSON.stringify(theme); +export function applyExpression(theme: CompiledTheme, themeReference?: string): string { + const payload = themeReference ?? JSON.stringify(theme); const nativeSelectors = JSON.stringify(NATIVE_CODEX_SURFACE_SELECTORS); return `(async () => { const theme = ${payload}; diff --git a/scripts/build-go-cdp-adapter.ts b/scripts/build-go-cdp-adapter.ts new file mode 100644 index 0000000..5355bd1 --- /dev/null +++ b/scripts/build-go-cdp-adapter.ts @@ -0,0 +1,36 @@ +import { createHash } from "node:crypto"; +import { mkdir, writeFile } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; + +import { build } from "esbuild"; +import { PROBE_EXPRESSION } from "../packages/cdp-adapter/src/scripts.js"; + +const adapterId = "current-2026-07"; +const output = resolve("host/go/internal/cdp/generated/adapter-manifest.json"); +const built = await build({ + absWorkingDir: resolve("."), + bundle: true, + entryPoints: ["scripts/go-cdp-adapter-entry.ts"], + format: "iife", + minify: true, + platform: "browser", + target: "es2022", + tsconfig: "tsconfig.scripts.json", + write: false, +}); +const bundle = built.outputFiles[0]?.text; +if (!bundle) throw new Error("Go CDP Adapter browser bundle was not generated"); +// esbuild's IIFE format intentionally discards the entrypoint's final +// expression. CDP bootstrap is a request/response boundary, so wrap the +// bundle in an expression that confirms the immutable Adapter registration. +const source = `(()=>{${bundle};return Object.prototype.hasOwnProperty.call(globalThis,"__openChatGPTSkinAdapter")&&Object.isFrozen(globalThis.__openChatGPTSkinAdapter)})()`; +const digest = createHash("sha256").update(`${adapterId}\n${PROBE_EXPRESSION}\n${source}`, "utf8").digest("hex"); + +await mkdir(dirname(output), { recursive: true }); +await writeFile(output, `${JSON.stringify({ + schemaVersion: 1, + adapterId, + probeExpression: PROBE_EXPRESSION, + source, + sha256: digest, +}, null, 2)}\n`, "utf8"); diff --git a/scripts/go-cdp-adapter-entry.ts b/scripts/go-cdp-adapter-entry.ts new file mode 100644 index 0000000..29cb6f1 --- /dev/null +++ b/scripts/go-cdp-adapter-entry.ts @@ -0,0 +1,173 @@ +import { compileTheme } from "../packages/cdp-adapter/src/compiler.js"; +import { + applyExpression, + preflightExpression, + REMOVE_EXPRESSION, + VERIFY_EXPRESSION, + VERIFY_OFFICIAL_EXPRESSION, +} from "../packages/cdp-adapter/src/scripts.js"; + +declare const Buffer: { + byteLength(value: string): number; + from(value: Uint8Array): { toString(encoding: "base64"): string }; +}; + +interface StagedTheme { + readonly theme: unknown; + readonly totalBytes: number; + readonly files: Map; +} + +let staged: StagedTheme | undefined; +const compiledThemeKey = "__openChatGPTSkinCompiledTheme"; +const compiledThemeReference = `globalThis[${JSON.stringify(compiledThemeKey)}]`; +type BrowserCompiledTheme = ReturnType; + +function installBrowserBuffer(): void { + const global = globalThis as typeof globalThis & { + Buffer?: { byteLength(value: string): number; from(value: Uint8Array): { toString(encoding: string): string } }; + }; + if (global.Buffer) return; + global.Buffer = { + byteLength: (value) => new TextEncoder().encode(value).byteLength, + from: (value) => ({ + toString: (encoding: string) => { + if (encoding !== "base64") throw new Error("unsupported browser buffer encoding"); + let binary = ""; + for (const byte of value) binary += String.fromCharCode(byte); + return btoa(binary); + }, + }), + }; +} + +function decodeBase64(value: string): Uint8Array { + if (typeof value !== "string" || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)) { + throw new Error("staged theme asset is not base64"); + } + const binary = atob(value); + const output = new Uint8Array(binary.length); + for (let index = 0; index < binary.length; index += 1) output[index] = binary.charCodeAt(index); + return output; +} + +function requiredStage(): StagedTheme { + if (!staged) throw new Error("no theme is staged"); + return staged; +} + +function compileStagedTheme(): BrowserCompiledTheme { + const value = requiredStage(); + if (!Number.isInteger(value.totalBytes) || value.totalBytes < 1 || value.totalBytes > 32 * 1024 * 1024) { + throw new Error("staged theme size is invalid"); + } + return compileTheme({ + theme: value.theme as never, + files: new Map([...value.files].map(([path, contents]) => [path, decodeBase64(contents)])), + totalBytes: value.totalBytes, + }); +} + +function adapterGlobal(): typeof globalThis & Record { + return globalThis as typeof globalThis & Record; +} + +function clearCompiledTheme(): void { + delete adapterGlobal()[compiledThemeKey]; +} + +function storeCompiledTheme(theme: BrowserCompiledTheme): void { + clearCompiledTheme(); + Object.defineProperty(globalThis, compiledThemeKey, { + configurable: true, + enumerable: false, + writable: false, + value: theme, + }); +} + +function requiredCompiledTheme(): BrowserCompiledTheme { + const theme = adapterGlobal()[compiledThemeKey]; + if (!theme || typeof theme !== "object") throw new Error("no compiled theme is prepared"); + return theme as BrowserCompiledTheme; +} + +function validVerification(value: Record): boolean { + return value.themeMarkers === 1 && value.welcomeValid === true && + value.requiredLayersResolved === true && value.fontMarkers !== undefined && + typeof value.fontMarkers === "number" && value.fontMarkers <= 1 && + value.decorationMarkers === 1 && value.decorationPointerEvents === "none" && + typeof value.surfaceMarkers === "number" && value.surfaceMarkers >= 3 && + value.mainSurfaceReady === true && value.sidebarSurfaceReady === true && + value.composerSurfaceReady === true && value.composerWithinViewport === true && + value.horizontalOverflow === false && value.mainVisible === true && + value.composerVisible === true && value.reviewShadowReady === true && + value.backgroundReady === true; +} + +function validOfficialAppearance(value: Record): boolean { + return value.managedMarkers === 0 && value.horizontalOverflow === false && + value.mainVisible === true && value.navigationVisible === true && value.composerVisible === true; +} + +installBrowserBuffer(); + +if (!Object.prototype.hasOwnProperty.call(globalThis, "__openChatGPTSkinAdapter")) { + Object.defineProperty(globalThis, "__openChatGPTSkinAdapter", { + configurable: false, + enumerable: false, + writable: false, + value: Object.freeze({ + begin(theme: unknown, totalBytes: number) { + clearCompiledTheme(); + staged = { theme, totalBytes, files: new Map() }; + return true; + }, + append(path: string, chunk: string) { + if (!/^(?:assets\/)?[A-Za-z0-9][A-Za-z0-9._/-]{0,240}$/.test(path) || path.includes("..") || + typeof chunk !== "string") throw new Error("staged theme asset path is invalid"); + const current = requiredStage(); + const next = (current.files.get(path) ?? "") + chunk; + if (next.length > 24 * 1024 * 1024) throw new Error("staged theme asset is too large"); + current.files.set(path, next); + return true; + }, + prepareApply: () => { + storeCompiledTheme(compileStagedTheme()); + staged = undefined; + return true; + }, + source: (name: string) => { + switch (name) { + case "preflight": + return preflightExpression(requiredCompiledTheme(), compiledThemeReference); + case "apply": + return applyExpression(requiredCompiledTheme(), compiledThemeReference); + case "verify": + return VERIFY_EXPRESSION; + case "remove": + return REMOVE_EXPRESSION; + case "verifyOfficial": + return VERIFY_OFFICIAL_EXPRESSION; + default: + throw new Error("unknown Adapter expression"); + } + }, + validatePreflight: (value: Record) => + value?.valid === true && value.requiredLayersResolved === true, + validateVerification: (value: Record) => { + const valid = validVerification(value); + if (valid) clearCompiledTheme(); + return valid; + }, + validateRestore: (removed: unknown, value: Record) => { + const valid = removed === 0 && validOfficialAppearance(value); + clearCompiledTheme(); + staged = undefined; + return valid; + }, + }), + }); +} + +true; From d36bdfc5fec664462ebbe706dca6f73c36c279cb Mon Sep 17 00:00:00 2001 From: u2bo <285077078@qq.com> Date: Sat, 25 Jul 2026 10:09:19 +0800 Subject: [PATCH 15/23] feat: deliver managed Windows runtime adapter --- .../platform/windows/adapter_windows.go | 165 +++++++++++++++++- .../platform/windows/adapter_windows_test.go | 35 ++++ 2 files changed, 195 insertions(+), 5 deletions(-) diff --git a/host/go/internal/platform/windows/adapter_windows.go b/host/go/internal/platform/windows/adapter_windows.go index 6580ef2..f34fb96 100644 --- a/host/go/internal/platform/windows/adapter_windows.go +++ b/host/go/internal/platform/windows/adapter_windows.go @@ -9,19 +9,24 @@ import ( "context" "encoding/json" "errors" + "fmt" "io" + "net" + "os" "os/exec" "regexp" "strings" + "syscall" "time" ) const ( - expectedIdentity = "OpenAI.Codex" - expectedPublisher = "CN=50BDFD77-8903-4850-9FFE-6E8522F64D5B" - expectedSigner = "50BDFD77-8903-4850-9FFE-6E8522F64D5B" - expectedResource = "OpenAI OpCo, LLC" - expectedEntry = "app/ChatGPT.exe" + expectedIdentity = "OpenAI.Codex" + expectedPublisher = "CN=50BDFD77-8903-4850-9FFE-6E8522F64D5B" + expectedSigner = "50BDFD77-8903-4850-9FFE-6E8522F64D5B" + expectedResource = "OpenAI OpCo, LLC" + expectedEntry = "app/ChatGPT.exe" + managedCDPReadyTimeout = 90 * time.Second ) var versionPattern = regexp.MustCompile(`^\d+\.\d+\.\d+\.\d+$`) @@ -51,6 +56,19 @@ type ProcessIdentity struct { ExecutablePath string `json:"executablePath"` } +type ManagedLaunch struct { + Install Install + Root ProcessIdentity + Port int +} + +type portInspection struct { + Host string `json:"host"` + Port int `json:"port"` + OwningPID int `json:"owningPid"` + Ancestors []int `json:"ancestors"` +} + type Error struct { Code string Message string @@ -147,6 +165,134 @@ func RefuseUnmanagedCodex(ctx context.Context) error { return nil } +// LaunchManaged starts only a freshly verified official Appx entry. A normal +// already-running instance is never attached to, and the selected CDP port is +// accepted only when it belongs to the launched process tree. +func LaunchManaged(ctx context.Context) (ManagedLaunch, error) { + if err := RefuseUnmanagedCodex(ctx); err != nil { + return ManagedLaunch{}, err + } + install, err := InspectOfficialCodex(ctx) + if err != nil { + return ManagedLaunch{}, err + } + listener, err := net.Listen("tcp4", "127.0.0.1:0") + if err != nil { + return ManagedLaunch{}, Error{Code: "CODEX_LAUNCH_FAILED", Message: "Could not reserve an IPv4 loopback CDP port"} + } + port := listener.Addr().(*net.TCPAddr).Port + if err := listener.Close(); err != nil { + return ManagedLaunch{}, err + } + launchedAfter := time.Now().UTC().Add(-time.Second) + command := exec.Command(install.EntryPath, "--remote-debugging-address=127.0.0.1", fmt.Sprintf("--remote-debugging-port=%d", port)) + command.SysProcAttr = &syscall.SysProcAttr{CreationFlags: 0x00000008 | 0x00000200, HideWindow: true} + command.Stdin, command.Stdout, command.Stderr = nil, nil, nil + if err := command.Start(); err != nil { + return ManagedLaunch{}, Error{Code: "CODEX_LAUNCH_FAILED", Message: err.Error()} + } + _ = command.Process.Release() + deadline := time.Now().Add(managedCDPReadyTimeout) + for time.Now().Before(deadline) { + roots, rootsErr := ListCodexRoots(ctx) + if rootsErr != nil { + return ManagedLaunch{}, rootsErr + } + for _, root := range roots { + started, parseErr := time.Parse(time.RFC3339Nano, root.StartedAt) + if parseErr == nil && !started.Before(launchedAfter) && strings.EqualFold(normalizePath(root.ExecutablePath), normalizePath(install.EntryPath)) { + inspection, inspectErr := inspectPort(ctx, port) + if inspectErr != nil { + var platformError Error + if errors.As(inspectErr, &platformError) && platformError.Code != "CDP_NOT_READY" { + return ManagedLaunch{}, inspectErr + } + continue + } + if inspection.Host == "127.0.0.1" && inspection.Port == port && containsPID(inspection.Ancestors, root.PID) { + return ManagedLaunch{Install: install, Root: root, Port: port}, nil + } + return ManagedLaunch{}, Error{Code: "CDP_ENDPOINT_UNSAFE", Message: "CDP endpoint is not owned by the managed Codex process"} + } + } + time.Sleep(100 * time.Millisecond) + } + return ManagedLaunch{}, Error{Code: "CDP_NOT_READY", Message: "Managed Codex did not expose an owned IPv4 loopback CDP endpoint"} +} + +// WaitForManagedExit observes only the exact process identity created by this +// Host. It never attaches to or terminates a later normal Codex instance. +func WaitForManagedExit(ctx context.Context, managed ProcessIdentity) error { + if managed.PID < 1 || !validTimestamp(managed.StartedAt) || managed.ExecutablePath == "" { + return Error{Code: "PROCESS_INSPECTION_DENIED", Message: "Managed Codex identity is invalid"} + } + ticker := time.NewTicker(250 * time.Millisecond) + defer ticker.Stop() + for { + roots, err := ListCodexRoots(ctx) + if err != nil { + return err + } + found := false + for _, root := range roots { + if root.PID == managed.PID && root.StartedAt == managed.StartedAt && strings.EqualFold(normalizePath(root.ExecutablePath), normalizePath(managed.ExecutablePath)) { + found = true + break + } + } + if !found { + return nil + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-ticker.C: + } + } +} + +func inspectPort(ctx context.Context, port int) (portInspection, error) { + if port < 1 || port > 65535 { + return portInspection{}, Error{Code: "CDP_ENDPOINT_UNSAFE", Message: "CDP port is invalid"} + } + inspectionContext, cancel := context.WithTimeout(ctx, 3*time.Second) + defer cancel() + command := exec.CommandContext(inspectionContext, "powershell.exe", "-NoLogo", "-NoProfile", "-NonInteractive", "-Command", "& ([scriptblock]::Create([Console]::In.ReadToEnd()))") + command.Env = append(os.Environ(), fmt.Sprintf("OPEN_CHATGPT_SKIN_PORT=%d", port)) + command.Stdin = strings.NewReader(portScript) + var stderr bytes.Buffer + command.Stderr = &stderr + contents, err := command.Output() + if err != nil { + message := strings.TrimSpace(stderr.String()) + if message == "" { + message = err.Error() + } + return portInspection{}, Error{Code: "PROCESS_INSPECTION_DENIED", Message: "CDP port inspection failed: " + message} + } + return parsePortInspection(contents, port) +} + +func parsePortInspection(contents []byte, port int) (portInspection, error) { + var value portInspection + if err := json.Unmarshal(contents, &value); err != nil || value.Host != "127.0.0.1" || value.Port != port || value.OwningPID < 1 || len(value.Ancestors) == 0 { + return portInspection{}, Error{Code: "CDP_NOT_READY", Message: "CDP endpoint is not ready"} + } + if !containsPID(value.Ancestors, value.OwningPID) { + return portInspection{}, Error{Code: "CDP_ENDPOINT_UNSAFE", Message: "CDP endpoint owner is not in its process ancestry"} + } + return value, nil +} + +func containsPID(values []int, target int) bool { + for _, value := range values { + if value == target { + return true + } + } + return false +} + func verify(install Install) error { valid := install.PackageRoot != "" && install.EntryPath != "" && install.IdentityName == expectedIdentity && versionPattern.MatchString(install.PackageVersion) && @@ -225,3 +371,12 @@ $roots = @($all | Where-Object { -not $ids.ContainsKey([int]$_.parentPid) } | Fo [pscustomobject]@{ pid = [int]$_.pid; parentPid = [int]$_.parentPid; startedAt = $process.StartTime.ToUniversalTime().ToString("o"); executablePath = [string]$_.executablePath } }) ConvertTo-Json -InputObject @($roots) -Compress` + +const portScript = `$ErrorActionPreference = "Stop" +$port = [int][Environment]::GetEnvironmentVariable("OPEN_CHATGPT_SKIN_PORT", [EnvironmentVariableTarget]::Process) +$connection = @(Get-NetTCPConnection -State Listen -LocalPort $port -ErrorAction SilentlyContinue | Where-Object { $_.LocalAddress -eq "127.0.0.1" } | Select-Object -First 1) +if ($connection.Count -ne 1) { [pscustomobject]@{} | ConvertTo-Json -Compress; exit 0 } +$processes = @{}; foreach ($process in @(Get-CimInstance Win32_Process)) { $processes[[int]$process.ProcessId] = [int]$process.ParentProcessId } +$ancestors = @(); $cursor = [int]$connection[0].OwningProcess; $seen = @{} +while ($cursor -gt 0 -and -not $seen.ContainsKey($cursor)) { $seen[$cursor] = $true; $ancestors += $cursor; if (-not $processes.ContainsKey($cursor)) { break }; $cursor = [int]$processes[$cursor] } +[pscustomobject]@{ host = "127.0.0.1"; port = $port; owningPid = [int]$connection[0].OwningProcess; ancestors = @($ancestors) } | ConvertTo-Json -Compress` diff --git a/host/go/internal/platform/windows/adapter_windows_test.go b/host/go/internal/platform/windows/adapter_windows_test.go index 897a0ca..3c1fd5e 100644 --- a/host/go/internal/platform/windows/adapter_windows_test.go +++ b/host/go/internal/platform/windows/adapter_windows_test.go @@ -7,6 +7,7 @@ import ( "encoding/json" "errors" "os" + "strings" "testing" ) @@ -78,6 +79,27 @@ func TestRefuseUnmanagedCodexUsesTheRootProcessBoundary(t *testing.T) { } } +func TestPortInspectionRejectsMissingOwner(t *testing.T) { + _, err := parsePortInspection([]byte(`{"host":"127.0.0.1","port":9222,"owningPid":0,"ancestors":[]}`), 9222) + var runtimeError Error + if !errors.As(err, &runtimeError) || runtimeError.Code != "CDP_NOT_READY" { + t.Fatalf("error=%v", err) + } +} + +func TestPortInspectionAcceptsLoopbackOwnerInItsTree(t *testing.T) { + actual, err := parsePortInspection([]byte(`{"host":"127.0.0.1","port":9222,"owningPid":202,"ancestors":[202,101,1]}`), 9222) + if err != nil || actual.OwningPID != 202 { + t.Fatalf("inspection=%+v error=%v", actual, err) + } +} + +func TestPortInspectionScriptUsesTheDocumentedOwningProcessProperty(t *testing.T) { + if strings.Contains(portScript, "OwningProcessID") || strings.Count(portScript, "OwningProcess") != 2 { + t.Fatalf("port inspection script has an unsafe owner field: %s", portScript) + } +} + func TestLiveOfficialCodexInspection(t *testing.T) { if os.Getenv("OPENCHATGPTSKIN_LIVE_WINDOWS_TEST") != "1" { t.Skip("set OPENCHATGPTSKIN_LIVE_WINDOWS_TEST=1 on a prepared Windows device") @@ -106,3 +128,16 @@ func TestLiveClosedCodexHasNoUnmanagedRoot(t *testing.T) { t.Fatal(err) } } + +func TestLiveManagedLaunchOwnsLoopbackCDP(t *testing.T) { + if os.Getenv("OPENCHATGPTSKIN_LIVE_WINDOWS_TEST") != "1" { + t.Skip("set OPENCHATGPTSKIN_LIVE_WINDOWS_TEST=1 on a prepared Windows device") + } + launch, err := LaunchManaged(context.Background()) + if err != nil { + t.Fatal(err) + } + if launch.Port < 1 || launch.Root.PID < 1 || launch.Install.IdentityName != expectedIdentity { + t.Fatalf("managed launch evidence is incomplete: %+v", launch) + } +} From 03bc50cb65b8f1410cbc41332c2ff8351a335db9 Mon Sep 17 00:00:00 2001 From: u2bo <285077078@qq.com> Date: Sat, 25 Jul 2026 10:09:32 +0800 Subject: [PATCH 16/23] feat: deliver managed macOS runtime adapter --- .../internal/platform/macos/adapter_darwin.go | 414 ++++++++++++++++++ .../platform/macos/adapter_darwin_test.go | 29 ++ 2 files changed, 443 insertions(+) create mode 100644 host/go/internal/platform/macos/adapter_darwin.go create mode 100644 host/go/internal/platform/macos/adapter_darwin_test.go diff --git a/host/go/internal/platform/macos/adapter_darwin.go b/host/go/internal/platform/macos/adapter_darwin.go new file mode 100644 index 0000000..6216efd --- /dev/null +++ b/host/go/internal/platform/macos/adapter_darwin.go @@ -0,0 +1,414 @@ +//go:build darwin + +// Package macos verifies a signed official Codex bundle before Runtime can +// launch it. It owns process/port identity only, never theme DOM logic. +package macos + +import ( + "bytes" + "context" + "errors" + "fmt" + "net" + "os" + "os/exec" + "path/filepath" + "regexp" + "strconv" + "strings" + "time" +) + +const ( + expectedBundleID = "com.openai.codex" + expectedIdentity = "OpenAI.Codex" + expectedTeamID = "2DC432GLL2" + expectedEntryPoint = "macOS.Application" + expectedNotarization = "Notarized Developer ID" + expectedResourceSigner = "OpenAI, L.L.C." + managedCDPReadyTimeout = 90 * time.Second +) + +var ( + versionPattern = regexp.MustCompile(`^\d+(?:\.\d+){0,3}$`) + teamPattern = regexp.MustCompile(`(?m)^TeamIdentifier=(.+)$`) + authorityPattern = regexp.MustCompile(`(?m)^Authority=Developer ID Application:\s*(.+?)\s*\([A-Z0-9]+\)\s*$`) +) + +type Install struct { + PackageRoot string + EntryPath string + PackageVersion string + EntryRelativePath string +} + +type ProcessIdentity struct { + PID int + ParentPID int + StartedAt string + ExecutablePath string +} + +type ManagedLaunch struct { + Install Install + Root ProcessIdentity + Port int +} + +type Error struct { + Code string + Message string +} + +func (err Error) Error() string { return err.Message } + +func InspectOfficialCodex(ctx context.Context) (Install, error) { + roots, err := candidateBundleRoots() + if err != nil { + return Install{}, err + } + if len(roots) == 0 { + return Install{}, Error{Code: "CODEX_NOT_INSTALLED", Message: "Codex.app was not found in system or user Applications"} + } + if len(roots) != 1 { + return Install{}, Error{Code: "CODEX_IDENTITY_INVALID", Message: "More than one Codex.app bundle is installed"} + } + return inspectInstall(ctx, roots[0]) +} + +func candidateBundleRoots() ([]string, error) { + home, err := os.UserHomeDir() + if err != nil { + return nil, Error{Code: "PROCESS_INSPECTION_DENIED", Message: "Current macOS user home is unavailable"} + } + candidates := []string{"/Applications/Codex.app", filepath.Join(home, "Applications", "Codex.app")} + roots := make([]string, 0, len(candidates)) + for _, candidate := range candidates { + info, statErr := os.Lstat(candidate) + if errors.Is(statErr, os.ErrNotExist) { + continue + } + if statErr != nil { + return nil, Error{Code: "PROCESS_INSPECTION_DENIED", Message: statErr.Error()} + } + if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { + return nil, Error{Code: "CODEX_IDENTITY_INVALID", Message: "Codex.app must be a regular application bundle"} + } + absolute, absErr := filepath.Abs(candidate) + if absErr != nil { + return nil, absErr + } + roots = append(roots, absolute) + } + return roots, nil +} + +func inspectInstall(ctx context.Context, root string) (Install, error) { + read := func(key string) (string, error) { + return runValue(ctx, "/usr/bin/plutil", "-extract", key, "raw", "-o", "-", filepath.Join(root, "Contents", "Info.plist")) + } + bundleID, err := read("CFBundleIdentifier") + if err != nil { + return Install{}, err + } + executable, err := read("CFBundleExecutable") + if err != nil { + return Install{}, err + } + version, err := read("CFBundleVersion") + if err != nil || !versionPattern.MatchString(version) { + return Install{}, Error{Code: "CODEX_IDENTITY_INVALID", Message: "Codex bundle version is invalid"} + } + verify, err := run(ctx, "/usr/bin/codesign", "--verify", "--deep", "--strict", root) + if err != nil || verify != "" || bundleID != expectedBundleID { + return Install{}, Error{Code: "CODEX_IDENTITY_INVALID", Message: "Codex bundle identity or signature is invalid"} + } + display, err := run(ctx, "/usr/bin/codesign", "-dv", "--verbose=4", root) + if err != nil { + return Install{}, err + } + assessment, assessErr := run(ctx, "/usr/sbin/spctl", "--assess", "--type", "execute", "--verbose=4", root) + if assessErr != nil { + return Install{}, Error{Code: "CODEX_IDENTITY_INVALID", Message: "Codex notarization assessment failed"} + } + team := teamPattern.FindStringSubmatch(display) + authority := authorityPattern.FindStringSubmatch(display) + if len(team) != 2 || len(authority) != 2 || strings.TrimSpace(team[1]) != expectedTeamID || strings.TrimSpace(authority[1]) != expectedResourceSigner || !strings.Contains(assessment, "source="+expectedNotarization) { + return Install{}, Error{Code: "CODEX_IDENTITY_INVALID", Message: "Codex signer or notarization is invalid"} + } + entryRelative := filepath.Join("Contents", "MacOS", executable) + entry := filepath.Join(root, entryRelative) + info, statErr := os.Stat(entry) + if statErr != nil || !info.Mode().IsRegular() { + return Install{}, Error{Code: "CODEX_IDENTITY_INVALID", Message: "Codex bundle entry is invalid"} + } + return Install{PackageRoot: root, EntryPath: entry, PackageVersion: normalizeVersion(version), EntryRelativePath: filepath.ToSlash(entryRelative)}, nil +} + +func normalizeVersion(value string) string { + parts := strings.Split(value, ".") + for len(parts) < 4 { + parts = append(parts, "0") + } + return strings.Join(parts, ".") +} + +func validTimestamp(value string) bool { + _, err := time.Parse(time.RFC3339Nano, value) + return err == nil +} + +func ListCodexRoots(ctx context.Context) ([]ProcessIdentity, error) { + roots, err := candidateBundleRoots() + if err != nil || len(roots) == 0 { + return nil, err + } + entries := make(map[string]struct{}, len(roots)) + for _, root := range roots { + executable, readErr := runValue(ctx, "/usr/bin/plutil", "-extract", "CFBundleExecutable", "raw", "-o", "-", filepath.Join(root, "Contents", "Info.plist")) + if readErr != nil { + return nil, readErr + } + entries[filepath.Join(root, "Contents", "MacOS", executable)] = struct{}{} + } + processes, err := processRows(ctx) + if err != nil { + return nil, err + } + all := make(map[int]struct{}) + for _, process := range processes { + if _, ok := entries[process.ExecutablePath]; ok { + all[process.PID] = struct{}{} + } + } + result := make([]ProcessIdentity, 0, len(all)) + for _, process := range processes { + if _, ok := all[process.PID]; !ok { + continue + } + if _, child := all[process.ParentPID]; !child { + result = append(result, process) + } + } + return result, nil +} + +func RefuseUnmanagedCodex(ctx context.Context) error { + roots, err := ListCodexRoots(ctx) + if err != nil { + return err + } + if len(roots) > 0 { + return Error{Code: "CODEX_ALREADY_RUNNING_UNMANAGED", Message: "A normal Codex instance is already running"} + } + return nil +} + +func LaunchManaged(ctx context.Context) (ManagedLaunch, error) { + if err := RefuseUnmanagedCodex(ctx); err != nil { + return ManagedLaunch{}, err + } + install, err := InspectOfficialCodex(ctx) + if err != nil { + return ManagedLaunch{}, err + } + listener, err := net.Listen("tcp4", "127.0.0.1:0") + if err != nil { + return ManagedLaunch{}, Error{Code: "CODEX_LAUNCH_FAILED", Message: "Could not reserve an IPv4 loopback CDP port"} + } + port := listener.Addr().(*net.TCPAddr).Port + if err := listener.Close(); err != nil { + return ManagedLaunch{}, err + } + launchedAfter := time.Now().UTC().Add(-time.Second) + if _, err := run(ctx, "/usr/bin/open", "-n", install.PackageRoot, "--args", "--remote-debugging-address=127.0.0.1", fmt.Sprintf("--remote-debugging-port=%d", port)); err != nil { + return ManagedLaunch{}, Error{Code: "CODEX_LAUNCH_FAILED", Message: err.Error()} + } + deadline := time.Now().Add(managedCDPReadyTimeout) + for time.Now().Before(deadline) { + roots, rootsErr := ListCodexRoots(ctx) + if rootsErr != nil { + return ManagedLaunch{}, rootsErr + } + for _, root := range roots { + started, parseErr := time.Parse(time.RFC3339Nano, root.StartedAt) + if parseErr != nil || started.Before(launchedAfter) || root.ExecutablePath != install.EntryPath { + continue + } + portInfo, portErr := inspectPort(ctx, port) + if portErr != nil { + var platformError Error + if errors.As(portErr, &platformError) && platformError.Code != "CDP_NOT_READY" { + return ManagedLaunch{}, portErr + } + continue + } + if portInfo.host == "127.0.0.1" && containsPID(portInfo.ancestors, root.PID) { + return ManagedLaunch{Install: install, Root: root, Port: port}, nil + } + return ManagedLaunch{}, Error{Code: "CDP_ENDPOINT_UNSAFE", Message: "CDP endpoint is not owned by the managed Codex process"} + } + time.Sleep(100 * time.Millisecond) + } + return ManagedLaunch{}, Error{Code: "CDP_NOT_READY", Message: "Managed Codex did not expose an owned IPv4 loopback CDP endpoint"} +} + +// WaitForManagedExit observes only the managed root identity and never sends +// a signal to the official application. +func WaitForManagedExit(ctx context.Context, managed ProcessIdentity) error { + if managed.PID < 1 || !validTimestamp(managed.StartedAt) || managed.ExecutablePath == "" { + return Error{Code: "PROCESS_INSPECTION_DENIED", Message: "Managed Codex identity is invalid"} + } + ticker := time.NewTicker(250 * time.Millisecond) + defer ticker.Stop() + for { + roots, err := ListCodexRoots(ctx) + if err != nil { + return err + } + found := false + for _, root := range roots { + if root.PID == managed.PID && root.StartedAt == managed.StartedAt && root.ExecutablePath == managed.ExecutablePath { + found = true + break + } + } + if !found { + return nil + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-ticker.C: + } + } +} + +type portIdentity struct { + host string + owner int + ancestors []int +} + +func inspectPort(ctx context.Context, port int) (portIdentity, error) { + if port < 1 || port > 65535 { + return portIdentity{}, Error{Code: "CDP_ENDPOINT_UNSAFE", Message: "CDP port is invalid"} + } + output, err := run(ctx, "/usr/sbin/lsof", "-nP", fmt.Sprintf("-iTCP:%d", port), "-sTCP:LISTEN", "-Fpn") + if err != nil || output == "" { + return portIdentity{}, Error{Code: "CDP_NOT_READY", Message: "CDP endpoint is not ready"} + } + owner, host, parseErr := parseLsof(output, port) + if parseErr != nil { + return portIdentity{}, parseErr + } + if host != "127.0.0.1" { + return portIdentity{}, Error{Code: "CDP_ENDPOINT_UNSAFE", Message: "CDP listener is not bound to IPv4 loopback"} + } + processes, err := processRows(ctx) + if err != nil { + return portIdentity{}, err + } + parents := make(map[int]int, len(processes)) + for _, process := range processes { + parents[process.PID] = process.ParentPID + } + ancestors := make([]int, 0, 8) + seen := make(map[int]struct{}) + for cursor := owner; cursor > 0; cursor = parents[cursor] { + if _, exists := seen[cursor]; exists { + break + } + seen[cursor] = struct{}{} + ancestors = append(ancestors, cursor) + } + if len(ancestors) == 0 { + return portIdentity{}, Error{Code: "CDP_NOT_READY", Message: "CDP port owner is unavailable"} + } + return portIdentity{host: host, owner: owner, ancestors: ancestors}, nil +} + +func parseLsof(output string, port int) (int, string, error) { + owner, host := 0, "" + for _, line := range strings.Split(output, "\n") { + if len(line) < 2 { + continue + } + switch line[0] { + case 'p': + value, err := strconv.Atoi(line[1:]) + if err != nil || value < 1 { + return 0, "", Error{Code: "PROCESS_INSPECTION_DENIED", Message: "lsof returned an invalid process ID"} + } + owner = value + case 'n': + endpoint := strings.TrimSpace(line[1:]) + if strings.HasSuffix(endpoint, fmt.Sprintf(":%d", port)) { + if owner == 0 || host != "" { + return 0, "", Error{Code: "PROCESS_INSPECTION_DENIED", Message: "CDP port has multiple listening owners"} + } + host = strings.TrimSuffix(endpoint, fmt.Sprintf(":%d", port)) + } + } + } + if owner < 1 || host == "" { + return 0, "", Error{Code: "CDP_NOT_READY", Message: "CDP endpoint is not ready"} + } + return owner, host, nil +} + +func processRows(ctx context.Context) ([]ProcessIdentity, error) { + output, err := run(ctx, "/bin/ps", "-ww", "-axo", "pid=,ppid=,lstart=,command=") + if err != nil { + return nil, err + } + processes := make([]ProcessIdentity, 0) + for _, line := range strings.Split(output, "\n") { + fields := strings.Fields(line) + if len(fields) < 8 { + continue + } + pid, pidErr := strconv.Atoi(fields[0]) + parent, parentErr := strconv.Atoi(fields[1]) + started, startedErr := time.Parse("Mon Jan _2 15:04:05 2006", strings.Join(fields[2:7], " ")) + if pidErr != nil || parentErr != nil || startedErr != nil || pid < 1 || parent < 0 { + return nil, Error{Code: "PROCESS_INSPECTION_DENIED", Message: "macOS process inspection returned an invalid row"} + } + processes = append(processes, ProcessIdentity{PID: pid, ParentPID: parent, StartedAt: started.UTC().Format(time.RFC3339Nano), ExecutablePath: fields[7]}) + } + return processes, nil +} + +func runValue(ctx context.Context, executable string, args ...string) (string, error) { + value, err := run(ctx, executable, args...) + if err != nil || strings.TrimSpace(value) == "" { + return "", Error{Code: "CODEX_IDENTITY_INVALID", Message: "Codex bundle metadata is unavailable"} + } + return strings.TrimSpace(value), nil +} + +func run(ctx context.Context, executable string, args ...string) (string, error) { + commandContext, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + command := exec.CommandContext(commandContext, executable, args...) + command.Env = append(os.Environ(), "LC_ALL=C", "TZ=UTC") + var stdout, stderr bytes.Buffer + command.Stdout, command.Stderr = &stdout, &stderr + if err := command.Run(); err != nil { + message := strings.TrimSpace(stderr.String()) + if message == "" { + message = err.Error() + } + return "", Error{Code: "PROCESS_INSPECTION_DENIED", Message: message} + } + return strings.TrimSpace(stdout.String() + "\n" + stderr.String()), nil +} + +func containsPID(values []int, target int) bool { + for _, value := range values { + if value == target { + return true + } + } + return false +} diff --git a/host/go/internal/platform/macos/adapter_darwin_test.go b/host/go/internal/platform/macos/adapter_darwin_test.go new file mode 100644 index 0000000..d3ee12e --- /dev/null +++ b/host/go/internal/platform/macos/adapter_darwin_test.go @@ -0,0 +1,29 @@ +//go:build darwin + +package macos + +import ( + "errors" + "testing" +) + +func TestParseLsofAcceptsOneIPv4LoopbackOwner(t *testing.T) { + owner, host, err := parseLsof("p412\nn127.0.0.1:9222\n", 9222) + if err != nil || owner != 412 || host != "127.0.0.1" { + t.Fatalf("owner=%d host=%q error=%v", owner, host, err) + } +} + +func TestParseLsofRejectsMultipleOwners(t *testing.T) { + _, _, err := parseLsof("p412\nn127.0.0.1:9222\np413\nn127.0.0.1:9222\n", 9222) + var platformError Error + if !errors.As(err, &platformError) || platformError.Code != "PROCESS_INSPECTION_DENIED" { + t.Fatalf("error=%v", err) + } +} + +func TestNormalizeVersionPadsToRuntimeContractWidth(t *testing.T) { + if value := normalizeVersion("26.721"); value != "26.721.0.0" { + t.Fatalf("version=%q", value) + } +} From 9576f8d81e57479504b594a8947417ad1ce6cb2f Mon Sep 17 00:00:00 2001 From: u2bo <285077078@qq.com> Date: Sat, 25 Jul 2026 10:09:56 +0800 Subject: [PATCH 17/23] feat: complete Go runtime and Studio apply loop --- host/go/internal/app/contract.go | 19 +- host/go/internal/app/controller.go | 360 ++++++++++++++++-- host/go/internal/app/controller_test.go | 116 ++++++ .../internal/app/controller_windows_test.go | 10 + host/go/internal/app/startup_read_other.go | 5 + host/go/internal/app/startup_read_windows.go | 17 + host/go/internal/app/studio.go | 10 +- host/go/internal/app/studio_runtime.go | 83 ++++ host/go/internal/app/theme_runtime.go | 169 ++++++++ host/go/internal/app/theme_runtime_darwin.go | 56 +++ host/go/internal/app/theme_runtime_windows.go | 56 +++ host/go/internal/studio/server.go | 50 ++- host/go/internal/studio/server_test.go | 51 +++ 13 files changed, 957 insertions(+), 45 deletions(-) create mode 100644 host/go/internal/app/startup_read_other.go create mode 100644 host/go/internal/app/startup_read_windows.go create mode 100644 host/go/internal/app/studio_runtime.go create mode 100644 host/go/internal/app/theme_runtime.go create mode 100644 host/go/internal/app/theme_runtime_darwin.go create mode 100644 host/go/internal/app/theme_runtime_windows.go diff --git a/host/go/internal/app/contract.go b/host/go/internal/app/contract.go index 7872d76..e39ab73 100644 --- a/host/go/internal/app/contract.go +++ b/host/go/internal/app/contract.go @@ -15,6 +15,7 @@ import ( "strings" "time" + "github.com/u2bo/OpenChatGPTSkin/host/go/internal/cdp" "github.com/u2bo/OpenChatGPTSkin/host/go/internal/control" "github.com/u2bo/OpenChatGPTSkin/host/go/internal/themerepo" ) @@ -62,7 +63,11 @@ func runRuntimeBaseline(ctx context.Context) (map[string]any, error) { defer cancel() done := make(chan error, 1) go func() { - done <- runController(controllerContext, controllerOptions{startupID: startupID, startupFile: startupFile, dataRoot: dataRoot}) + done <- runController(controllerContext, controllerOptions{ + startupID: startupID, startupFile: startupFile, dataRoot: dataRoot, + factory: func(context.Context) (managedThemeSession, error) { return baselineThemeSession{}, nil }, + load: baselineThemeLoader, + }) }() handshakeContext, cancelHandshake := context.WithTimeout(ctx, 5*time.Second) defer cancelHandshake() @@ -120,6 +125,18 @@ func runRuntimeBaseline(ctx context.Context) (map[string]any, error) { }, nil } +type baselineThemeSession struct{} + +func (baselineThemeSession) Apply(context.Context, cdp.ThemePayload) error { return nil } +func (baselineThemeSession) Restore(context.Context) error { return nil } +func (baselineThemeSession) WaitForExit(context.Context) error { return nil } +func (baselineThemeSession) Close() error { return nil } + +func baselineThemeLoader(ref themerepo.Ref) (cdp.ThemePayload, error) { + document := []byte(`{"schemaVersion":4,"id":"` + ref.ID + `","version":"` + ref.Version + `"}`) + return cdp.ThemePayload{Document: document, Files: map[string][]byte{}, TotalBytes: len(document)}, nil +} + func runtimeStatusFromResponse(response control.Response) string { var value struct { Status string `json:"status"` diff --git a/host/go/internal/app/controller.go b/host/go/internal/app/controller.go index ecd972e..1f6efc3 100644 --- a/host/go/internal/app/controller.go +++ b/host/go/internal/app/controller.go @@ -13,8 +13,10 @@ import ( "sync" "time" + "github.com/u2bo/OpenChatGPTSkin/host/go/internal/cdp" "github.com/u2bo/OpenChatGPTSkin/host/go/internal/control" "github.com/u2bo/OpenChatGPTSkin/host/go/internal/persistence" + "github.com/u2bo/OpenChatGPTSkin/host/go/internal/themerepo" ) const maxStartupLineBytes = 8 * 1024 @@ -27,12 +29,16 @@ type startupHandshake struct { } type controllerModel struct { - mu sync.RWMutex - status string - themeID string - version string - terminal bool - state string + mu sync.RWMutex + status string + themeID string + version string + terminal bool + state string + session managedThemeSession + factory sessionFactory + load themeLoader + restoreWatch managedThemeSession } type persistedControllerState struct { @@ -44,7 +50,7 @@ type persistedControllerState struct { } func loadControllerModel(path string) (*controllerModel, error) { - model := &controllerModel{status: "stopped", state: path} + model := &controllerModel{status: "stopped", state: path, factory: newManagedThemeSession, load: defaultThemeLoader(filepath.Dir(path))} contents, err := os.ReadFile(path) if errors.Is(err, os.ErrNotExist) { return model, nil @@ -74,61 +80,289 @@ func loadControllerModel(path string) (*controllerModel, error) { func bytesReader(contents []byte) *bytes.Reader { return bytes.NewReader(contents) } func newControllerDispatcher(model *controllerModel) *control.Dispatcher { - dispatcher := control.NewDispatcher(func(_ context.Context, request control.Request) (control.Result, error) { + dispatcher := control.NewDispatcher(func(runtimeContext context.Context, request control.Request) (control.Result, error) { if request.Command == "status" { model.mu.RLock() defer model.mu.RUnlock() return model.result(), nil } - model.mu.Lock() - defer model.mu.Unlock() - next := controllerModel{status: model.status, themeID: model.themeID, version: model.version, terminal: model.terminal, state: model.state} switch request.Command { case "launch": - if model.status != "stopped" { - return nil, control.CommandError{Code: "RUNTIME_INVALID_TRANSITION", Message: "Runtime must be stopped before launch"} - } params, err := control.DecodeThemeParameters(request) if err != nil { return nil, err } - next.status, next.themeID, next.version = "active", params.ThemeID, params.ThemeVersion + return model.launch(runtimeContext, params) case "switch": - if model.status != "active" && model.status != "paused" { - return nil, control.CommandError{Code: "RUNTIME_INVALID_TRANSITION", Message: "Runtime must be active or paused before switching theme"} - } params, err := control.DecodeThemeParameters(request) if err != nil { return nil, err } - next.status, next.themeID, next.version = "active", params.ThemeID, params.ThemeVersion + return model.switchTheme(runtimeContext, params) case "resume": - if model.status != "paused" { - return nil, control.CommandError{Code: "RUNTIME_INVALID_TRANSITION", Message: "Runtime must be paused before resume"} - } - next.status = "active" + return model.resume(runtimeContext) case "pause": - if model.status != "active" { - return nil, control.CommandError{Code: "RUNTIME_INVALID_TRANSITION", Message: "Runtime must be active before pause"} - } - next.status = "paused" + return model.pause(runtimeContext) case "restore": - if model.status == "restored-awaiting-exit" { - return nil, control.CommandError{Code: "RUNTIME_INVALID_TRANSITION", Message: "Runtime restore is already pending exit"} - } - next.status, next.themeID, next.version, next.terminal = "restored-awaiting-exit", "", "", true + return model.restore(runtimeContext) default: return nil, control.CommandError{Code: "RUNTIME_INVALID_TRANSITION", Message: "Runtime command is unavailable in the current state"} } - if err := next.persistLocked(); err != nil { - return nil, err - } - model.status, model.themeID, model.version, model.terminal = next.status, next.themeID, next.version, next.terminal - return model.result(), nil }) return dispatcher } +func (model *controllerModel) launch(ctx context.Context, parameters control.ThemeParameters) (control.Result, error) { + model.mu.RLock() + stopped := model.status == "stopped" + model.mu.RUnlock() + if !stopped { + return nil, control.CommandError{Code: "RUNTIME_INVALID_TRANSITION", Message: "Runtime must be stopped before launch"} + } + if _, err := model.setOperation("launching", parameters.ThemeID, parameters.ThemeVersion, false); err != nil { + return nil, err + } + payload, ref, err := model.theme(ctx, parameters) + if err != nil { + if stopErr := model.stopAfterFailure(); stopErr != nil { + return nil, stopErr + } + return nil, runtimeCommandError(err, "THEME_APPLY_FAILED") + } + if model.factory == nil { + if stopErr := model.stopAfterFailure(); stopErr != nil { + return nil, stopErr + } + return nil, control.CommandError{Code: "RUNTIME_CONTROL_UNAVAILABLE", Message: "Runtime platform adapter is unavailable"} + } + session, err := model.factory(ctx) + if err == nil { + err = session.Apply(ctx, payload) + } + if err != nil { + fallback := "THEME_APPLY_FAILED" + if session != nil { + if cleanupErr := cleanupThemeSession(ctx, session); cleanupErr != nil { + err, fallback = cleanupErr, "THEME_CLEANUP_FAILED" + } + } + if stopErr := model.stopAfterFailure(); stopErr != nil { + return nil, stopErr + } + return nil, runtimeCommandError(err, fallback) + } + model.mu.Lock() + model.status, model.themeID, model.version, model.session = "active", ref.ID, ref.Version, session + err = model.persistLocked() + result := model.result() + model.mu.Unlock() + if err != nil { + cleanupErr := cleanupThemeSession(ctx, session) + if stopErr := model.stopAfterFailure(); stopErr != nil { + return nil, stopErr + } + if cleanupErr != nil { + return nil, runtimeCommandError(cleanupErr, "THEME_CLEANUP_FAILED") + } + return nil, err + } + return result, nil +} + +func (model *controllerModel) switchTheme(ctx context.Context, parameters control.ThemeParameters) (control.Result, error) { + model.mu.Lock() + if model.status != "active" && model.status != "paused" || model.session == nil { + model.mu.Unlock() + return nil, control.CommandError{Code: "RUNTIME_INVALID_TRANSITION", Message: "Runtime must have an active managed session before switching theme"} + } + session := model.session + previousStatus, previousThemeID, previousVersion := model.status, model.themeID, model.version + model.status, model.themeID, model.version = "switching", parameters.ThemeID, parameters.ThemeVersion + if err := model.persistLocked(); err != nil { + model.status, model.themeID, model.version = previousStatus, previousThemeID, previousVersion + model.mu.Unlock() + return nil, err + } + model.mu.Unlock() + payload, ref, err := model.theme(ctx, parameters) + if err == nil { + err = session.Apply(ctx, payload) + } + if err != nil { + fallback := "THEME_APPLY_FAILED" + if cleanupErr := cleanupThemeSession(ctx, session); cleanupErr != nil { + err, fallback = cleanupErr, "THEME_CLEANUP_FAILED" + } + if stopErr := model.stopAfterFailure(); stopErr != nil { + return nil, stopErr + } + return nil, runtimeCommandError(err, fallback) + } + model.mu.Lock() + model.status, model.themeID, model.version = "active", ref.ID, ref.Version + err = model.persistLocked() + result := model.result() + model.mu.Unlock() + if err != nil { + cleanupErr := cleanupThemeSession(ctx, session) + if stopErr := model.stopAfterFailure(); stopErr != nil { + return nil, stopErr + } + if cleanupErr != nil { + return nil, runtimeCommandError(cleanupErr, "THEME_CLEANUP_FAILED") + } + return nil, err + } + return result, nil +} + +func (model *controllerModel) pause(ctx context.Context) (control.Result, error) { + model.mu.RLock() + if model.status != "active" || model.session == nil { + model.mu.RUnlock() + return nil, control.CommandError{Code: "RUNTIME_INVALID_TRANSITION", Message: "Runtime must be active before pause"} + } + session := model.session + model.mu.RUnlock() + if err := session.Restore(ctx); err != nil { + return nil, runtimeCommandError(err, "THEME_CLEANUP_FAILED") + } + result, err := model.setOperation("paused", model.themeID, model.version, false) + if err == nil { + return result, nil + } + cleanupErr := cleanupThemeSession(ctx, session) + if stopErr := model.stopAfterFailure(); stopErr != nil { + return nil, stopErr + } + if cleanupErr != nil { + return nil, runtimeCommandError(cleanupErr, "THEME_CLEANUP_FAILED") + } + return nil, err +} + +func (model *controllerModel) resume(ctx context.Context) (control.Result, error) { + model.mu.RLock() + if model.status != "paused" || model.session == nil { + model.mu.RUnlock() + return nil, control.CommandError{Code: "RUNTIME_INVALID_TRANSITION", Message: "Runtime must be paused before resume"} + } + parameters := control.ThemeParameters{ThemeID: model.themeID, ThemeVersion: model.version} + session := model.session + model.mu.RUnlock() + payload, ref, err := model.theme(ctx, parameters) + if err == nil { + err = session.Apply(ctx, payload) + } + if err != nil { + fallback := "THEME_APPLY_FAILED" + if cleanupErr := cleanupThemeSession(ctx, session); cleanupErr != nil { + err, fallback = cleanupErr, "THEME_CLEANUP_FAILED" + } + if stopErr := model.stopAfterFailure(); stopErr != nil { + return nil, stopErr + } + return nil, runtimeCommandError(err, fallback) + } + result, err := model.setOperation("active", ref.ID, ref.Version, false) + if err == nil { + return result, nil + } + cleanupErr := cleanupThemeSession(ctx, session) + if stopErr := model.stopAfterFailure(); stopErr != nil { + return nil, stopErr + } + if cleanupErr != nil { + return nil, runtimeCommandError(cleanupErr, "THEME_CLEANUP_FAILED") + } + return nil, err +} + +func (model *controllerModel) restore(ctx context.Context) (control.Result, error) { + model.mu.RLock() + if model.status == "restored-awaiting-exit" { + model.mu.RUnlock() + return nil, control.CommandError{Code: "RUNTIME_INVALID_TRANSITION", Message: "Runtime restore is already pending exit"} + } + session := model.session + model.mu.RUnlock() + if session != nil { + if err := session.Restore(ctx); err != nil { + return nil, runtimeCommandError(err, "THEME_CLEANUP_FAILED") + } + if err := session.Close(); err != nil { + return nil, runtimeCommandError(err, "THEME_CLEANUP_FAILED") + } + } + model.mu.Lock() + if session == nil { + model.status, model.themeID, model.version, model.session = "stopped", "", "", nil + model.restoreWatch = nil + model.terminal = true + } else { + model.status, model.themeID, model.version, model.session = "restored-awaiting-exit", "", "", session + model.restoreWatch = session + } + err := model.persistLocked() + result := model.result() + model.mu.Unlock() + if err != nil { + if stopErr := model.stopAfterFailure(); stopErr != nil { + return nil, stopErr + } + return nil, err + } + return result, nil +} + +func (model *controllerModel) theme(_ context.Context, parameters control.ThemeParameters) (cdp.ThemePayload, themerepo.Ref, error) { + if model.load == nil { + return cdp.ThemePayload{}, themerepo.Ref{}, control.CommandError{Code: "THEME_NOT_FOUND", Message: "Theme repository is unavailable"} + } + payload, err := model.load(themerepo.Ref{ID: parameters.ThemeID, Version: parameters.ThemeVersion}) + if err != nil { + return cdp.ThemePayload{}, themerepo.Ref{}, err + } + var identity struct { + ID string `json:"id"` + Version string `json:"version"` + } + if err := json.Unmarshal(payload.Document, &identity); err != nil || identity.ID != parameters.ThemeID || identity.Version == "" { + return cdp.ThemePayload{}, themerepo.Ref{}, control.CommandError{Code: "THEME_APPLY_FAILED", Message: "Theme payload identity is invalid"} + } + return payload, themerepo.Ref{ID: identity.ID, Version: identity.Version}, nil +} + +func (model *controllerModel) setOperation(status, themeID, version string, terminal bool) (control.Result, error) { + model.mu.Lock() + defer model.mu.Unlock() + previousStatus, previousThemeID, previousVersion, previousTerminal := model.status, model.themeID, model.version, model.terminal + model.status, model.themeID, model.version, model.terminal = status, themeID, version, terminal + if err := model.persistLocked(); err != nil { + model.status, model.themeID, model.version, model.terminal = previousStatus, previousThemeID, previousVersion, previousTerminal + return nil, err + } + return model.result(), nil +} + +func (model *controllerModel) stopAfterFailure() error { + model.mu.Lock() + defer model.mu.Unlock() + model.status, model.themeID, model.version, model.session, model.terminal = "stopped", "", "", nil, false + model.restoreWatch = nil + if err := model.persistLocked(); err != nil { + return err + } + model.terminal = true + return nil +} + +func cleanupThemeSession(ctx context.Context, session managedThemeSession) error { + restoreErr := session.Restore(ctx) + closeErr := session.Close() + return errors.Join(restoreErr, closeErr) +} + func (model *controllerModel) persistLocked() error { if model.state == "" { return nil @@ -182,11 +416,15 @@ func (model *controllerModel) result() control.Result { ref := map[string]string{"id": model.themeID, "version": model.version} result["selectedTheme"] = ref } - // A controller transition alone is not a visual application. The platform - // CDP adapter must verify the exact renderer before it can set these fields. - result["appliedTheme"] = nil - result["skinApplied"] = false - result["nextAction"] = "A verified platform adapter must apply and confirm the theme." + if model.status == "active" && model.session != nil { + result["appliedTheme"] = map[string]string{"id": model.themeID, "version": model.version} + result["skinApplied"] = true + result["nextAction"] = "The verified platform adapter confirmed the active theme." + } else { + result["appliedTheme"] = nil + result["skinApplied"] = false + result["nextAction"] = "A verified platform adapter must apply and confirm the theme." + } return result } @@ -196,6 +434,29 @@ func (model *controllerModel) isTerminal() bool { return model.terminal } +func (model *controllerModel) takeRestoreWatch() managedThemeSession { + model.mu.Lock() + defer model.mu.Unlock() + session := model.restoreWatch + model.restoreWatch = nil + return session +} + +func (model *controllerModel) markManagedExit() error { + model.mu.Lock() + defer model.mu.Unlock() + if model.status != "restored-awaiting-exit" { + return nil + } + model.status, model.themeID, model.version, model.session = "stopped", "", "", nil + model.restoreWatch = nil + if err := model.persistLocked(); err != nil { + return err + } + model.terminal = true + return nil +} + func writeHandshake(path string, value startupHandshake) error { contents, err := json.Marshal(value) if err != nil { @@ -242,6 +503,8 @@ type controllerOptions struct { startupFile string dataRoot string once bool + factory sessionFactory + load themeLoader } func runController(ctx context.Context, options controllerOptions) error { @@ -275,11 +538,24 @@ func runController(ctx context.Context, options controllerOptions) error { _ = writeHandshake(options.startupFile, startupHandshake{OK: false, StartupID: options.startupID, ErrorCode: "RUNTIME_SESSION_STALE"}) return err } + if options.factory != nil { + model.factory = options.factory + } + if options.load != nil { + model.load = options.load + } if err := writeHandshake(options.startupFile, startupHandshake{OK: true, StartupID: options.startupID, Endpoint: endpoint}); err != nil { return err } dispatcher := newControllerDispatcher(model) err = control.Serve(ctx, listener, dispatcher, func() { + if session := model.takeRestoreWatch(); session != nil { + go func() { + if session.WaitForExit(ctx) == nil && model.markManagedExit() == nil { + _ = listener.Close() + } + }() + } if options.once || model.isTerminal() { _ = listener.Close() } @@ -331,7 +607,7 @@ func waitForHandshake(ctx context.Context, path, startupID string) (startupHands } return handshake, nil } - if !errors.Is(err, os.ErrNotExist) { + if !errors.Is(err, os.ErrNotExist) && !transientStartupReadError(err) { return startupHandshake{}, err } select { diff --git a/host/go/internal/app/controller_test.go b/host/go/internal/app/controller_test.go index b228dc9..11b5001 100644 --- a/host/go/internal/app/controller_test.go +++ b/host/go/internal/app/controller_test.go @@ -10,7 +10,9 @@ import ( "testing" "time" + "github.com/u2bo/OpenChatGPTSkin/host/go/internal/cdp" "github.com/u2bo/OpenChatGPTSkin/host/go/internal/control" + "github.com/u2bo/OpenChatGPTSkin/host/go/internal/themerepo" ) func TestStudioCloseDoesNotEndControllerAndControllerCleansItsLock(t *testing.T) { @@ -95,6 +97,28 @@ func TestRestoreRespondsBeforeControllerTerminalCleanup(t *testing.T) { if _, err := os.Stat(filepath.Join(dataRoot, "controller.lock")); !errors.Is(err, os.ErrNotExist) { t.Fatalf("controller lock remains after restore: %v", err) } + model, err := loadControllerModel(filepath.Join(dataRoot, "runtime-session.json")) + if err != nil { + t.Fatal(err) + } + if model.status != "stopped" { + t.Fatalf("restored state=%q", model.status) + } +} + +func TestManagedExitPersistsStoppedState(t *testing.T) { + statePath := filepath.Join(t.TempDir(), "runtime-session.json") + model := &controllerModel{status: "restored-awaiting-exit", state: statePath, session: testThemeSession{}} + if err := model.markManagedExit(); err != nil { + t.Fatal(err) + } + reloaded, err := loadControllerModel(statePath) + if err != nil { + t.Fatal(err) + } + if reloaded.status != "stopped" || reloaded.session != nil || !model.terminal { + t.Fatalf("state=%q session=%v terminal=%v", reloaded.status, reloaded.session, model.terminal) + } } func TestControllerStateMachinePersistsAndRequiresRecoveryAfterCrash(t *testing.T) { @@ -103,6 +127,8 @@ func TestControllerStateMachinePersistsAndRequiresRecoveryAfterCrash(t *testing. if err != nil { t.Fatal(err) } + model.factory = func(context.Context) (managedThemeSession, error) { return testThemeSession{}, nil } + model.load = testThemeLoader dispatcher := newControllerDispatcher(model) launch := dispatcher.Dispatch(context.Background(), control.Request{ ProtocolVersion: 1, RequestID: "00000000-0000-4000-8000-000000000506", Command: "launch", @@ -129,3 +155,93 @@ func TestControllerStateMachinePersistsAndRequiresRecoveryAfterCrash(t *testing. t.Fatalf("recovery status = %+v", recoveryStatus) } } + +func TestFailedThemeLaunchRespondsThenReleasesControllerLock(t *testing.T) { + dataRoot := t.TempDir() + startupID := "00000000-0000-4000-8000-000000000509" + startupFile := filepath.Join(dataRoot, "startup.json") + controllerDone := make(chan error, 1) + go func() { + controllerDone <- runController(context.Background(), controllerOptions{ + startupID: startupID, startupFile: startupFile, dataRoot: dataRoot, + factory: func(context.Context) (managedThemeSession, error) { + return nil, errors.New("platform launch failed") + }, + load: testThemeLoader, + }) + }() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + handshake, err := waitForHandshake(ctx, startupFile, startupID) + if err != nil { + t.Fatal(err) + } + response, err := control.RoundTrip(ctx, func() (control.Connection, error) { + return dialControl(handshake.Endpoint) + }, control.Request{ + ProtocolVersion: 1, RequestID: "00000000-0000-4000-8000-000000000510", Command: "launch", + Params: json.RawMessage(`{"themeId":"mountain-mist","themeVersion":"1.3.0"}`), + }) + if err != nil || response.OK || response.Error == nil || response.Error.Code != "THEME_APPLY_FAILED" { + t.Fatalf("launch response=%+v error=%v", response, err) + } + select { + case err := <-controllerDone: + if err != nil { + t.Fatal(err) + } + case <-ctx.Done(): + t.Fatal("failed Controller did not terminate") + } + if _, err := os.Stat(filepath.Join(dataRoot, "controller.lock")); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("failed Controller lock remains: %v", err) + } +} + +func TestRuntimeCommandErrorPreservesControlError(t *testing.T) { + err := runtimeCommandError(control.CommandError{Code: "THEME_APPLY_FAILED", Message: "Theme payload identity is invalid"}, "INTERNAL") + var commandError control.CommandError + if !errors.As(err, &commandError) || commandError.Code != "THEME_APPLY_FAILED" || commandError.Message != "Theme payload identity is invalid" { + t.Fatalf("error=%+v", err) + } +} + +func TestCleanupThemeSessionAttemptsRestoreAndClose(t *testing.T) { + restoreErr := errors.New("restore failed") + closeErr := errors.New("close failed") + session := &cleanupTrackingSession{restoreErr: restoreErr, closeErr: closeErr} + err := cleanupThemeSession(context.Background(), session) + if !session.restored || !session.closed || !errors.Is(err, restoreErr) || !errors.Is(err, closeErr) { + t.Fatalf("restored=%v closed=%v error=%v", session.restored, session.closed, err) + } +} + +type cleanupTrackingSession struct { + restored bool + closed bool + restoreErr error + closeErr error +} + +func (*cleanupTrackingSession) Apply(context.Context, cdp.ThemePayload) error { return nil } +func (session *cleanupTrackingSession) Restore(context.Context) error { + session.restored = true + return session.restoreErr +} +func (*cleanupTrackingSession) WaitForExit(context.Context) error { return nil } +func (session *cleanupTrackingSession) Close() error { + session.closed = true + return session.closeErr +} + +type testThemeSession struct{} + +func (testThemeSession) Apply(context.Context, cdp.ThemePayload) error { return nil } +func (testThemeSession) Restore(context.Context) error { return nil } +func (testThemeSession) WaitForExit(context.Context) error { return nil } +func (testThemeSession) Close() error { return nil } + +func testThemeLoader(ref themerepo.Ref) (cdp.ThemePayload, error) { + document := []byte(`{"schemaVersion":4,"id":"` + ref.ID + `","version":"` + ref.Version + `"}`) + return cdp.ThemePayload{Document: document, Files: map[string][]byte{}, TotalBytes: len(document)}, nil +} diff --git a/host/go/internal/app/controller_windows_test.go b/host/go/internal/app/controller_windows_test.go index a6eecd3..4bf8b33 100644 --- a/host/go/internal/app/controller_windows_test.go +++ b/host/go/internal/app/controller_windows_test.go @@ -43,3 +43,13 @@ func TestWindowsProcessIdentityIncludesCreationTime(t *testing.T) { t.Fatal("PID-only match accepted a changed creation time") } } + +func TestWindowsStartupHandshakeRetriesOnlySharingViolations(t *testing.T) { + sharing := &os.PathError{Op: "open", Path: "startup.json", Err: windowsSharingViolation} + if !transientStartupReadError(sharing) { + t.Fatal("sharing violation was not treated as transient") + } + if transientStartupReadError(os.ErrPermission) { + t.Fatal("permission denial was treated as transient") + } +} diff --git a/host/go/internal/app/startup_read_other.go b/host/go/internal/app/startup_read_other.go new file mode 100644 index 0000000..79cb9fe --- /dev/null +++ b/host/go/internal/app/startup_read_other.go @@ -0,0 +1,5 @@ +//go:build !windows + +package app + +func transientStartupReadError(error) bool { return false } diff --git a/host/go/internal/app/startup_read_windows.go b/host/go/internal/app/startup_read_windows.go new file mode 100644 index 0000000..744bcb8 --- /dev/null +++ b/host/go/internal/app/startup_read_windows.go @@ -0,0 +1,17 @@ +//go:build windows + +package app + +import ( + "errors" + "syscall" +) + +const windowsSharingViolation syscall.Errno = 32 // ERROR_SHARING_VIOLATION + +// transientStartupReadError covers the short interval where Windows prevents +// a second process from reading a just-replaced handshake file. The caller +// remains bounded by its startup deadline and does not ignore other I/O errors. +func transientStartupReadError(err error) bool { + return errors.Is(err, windowsSharingViolation) +} diff --git a/host/go/internal/app/studio.go b/host/go/internal/app/studio.go index acfabaf..5895307 100644 --- a/host/go/internal/app/studio.go +++ b/host/go/internal/app/studio.go @@ -7,6 +7,7 @@ import ( "path/filepath" "github.com/u2bo/OpenChatGPTSkin/host/go/internal/studio" + "github.com/u2bo/OpenChatGPTSkin/host/go/internal/themerepo" ) const goHostVersion = "0.3.0-alpha.1" @@ -54,7 +55,14 @@ func startStudio(ctx context.Context, viteOrigin, configuredDataRoot string) (*R DraftRoot: filepath.Join(dataRoot, "theme-studio", "drafts"), StudioVersion: goHostVersion, RepositoryURL: &repositoryURL, - ViteOrigin: viteOrigin, + RuntimeStatus: func() studio.RuntimeStatus { return readStudioRuntimeStatus(dataRoot) }, + ApplyTheme: func(requestContext context.Context, ref themerepo.Ref) (studio.RuntimeStatus, error) { + return applyStudioTheme(requestContext, dataRoot, ref) + }, + RestoreTheme: func(requestContext context.Context) (studio.RuntimeStatus, error) { + return restoreStudioTheme(requestContext, dataRoot) + }, + ViteOrigin: viteOrigin, }) if err != nil { code := studio.ErrorCode(err) diff --git a/host/go/internal/app/studio_runtime.go b/host/go/internal/app/studio_runtime.go new file mode 100644 index 0000000..34b67e0 --- /dev/null +++ b/host/go/internal/app/studio_runtime.go @@ -0,0 +1,83 @@ +package app + +import ( + "context" + "encoding/json" + "time" + + "github.com/u2bo/OpenChatGPTSkin/host/go/internal/control" + "github.com/u2bo/OpenChatGPTSkin/host/go/internal/studio" + "github.com/u2bo/OpenChatGPTSkin/host/go/internal/themerepo" +) + +func readStudioRuntimeStatus(dataRoot string) studio.RuntimeStatus { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + response, err := sendRuntime(ctx, dataRoot, control.Request{ + ProtocolVersion: control.ProtocolVersion, + RequestID: studioRequestID(), + Command: "status", + Params: json.RawMessage(`{}`), + }) + if err != nil || !response.OK { + return studio.StoppedRuntimeStatus() + } + status, err := studioStatusFromResponse(response) + if err != nil { + return studio.StoppedRuntimeStatus() + } + status.ControllerAvailable = true + return status +} + +func applyStudioTheme(ctx context.Context, dataRoot string, ref themerepo.Ref) (studio.RuntimeStatus, error) { + current := readStudioRuntimeStatus(dataRoot) + command := "launch" + if current.Status == "active" || current.Status == "paused" { + command = "switch" + } + response, err := runRuntime(ctx, []string{command, "--data-root", dataRoot, "--theme", ref.ID, "--theme-version", ref.Version}) + if err != nil { + return studio.RuntimeStatus{}, err + } + return studioStatusFromResponse(response) +} + +func restoreStudioTheme(ctx context.Context, dataRoot string) (studio.RuntimeStatus, error) { + response, err := runRuntime(ctx, []string{"restore", "--data-root", dataRoot}) + if err != nil { + return studio.RuntimeStatus{}, err + } + return studioStatusFromResponse(response) +} + +func studioRequestID() string { + value, err := requestID() + if err != nil { + return "00000000-0000-4000-8000-000000000601" + } + return value +} + +func studioStatusFromResponse(response control.Response) (studio.RuntimeStatus, error) { + if !response.OK { + if response.Error != nil { + return studio.RuntimeStatus{}, commandError{code: response.Error.Code, message: response.Error.Message} + } + return studio.RuntimeStatus{}, commandError{code: "RUNTIME_CONTROL_UNAVAILABLE", message: "Runtime did not return a status"} + } + var result struct { + Status string `json:"status"` + SelectedTheme *themerepo.Ref `json:"selectedTheme"` + AppliedTheme *themerepo.Ref `json:"appliedTheme"` + SkinApplied *bool `json:"skinApplied"` + NextAction string `json:"nextAction"` + } + if err := json.Unmarshal(response.Result, &result); err != nil || result.Status == "" || result.SkinApplied == nil { + return studio.RuntimeStatus{}, commandError{code: "RUNTIME_STATUS_UNAVAILABLE", message: "Runtime status payload is invalid"} + } + return studio.RuntimeStatus{ + Status: result.Status, ControllerAvailable: true, SelectedTheme: result.SelectedTheme, + AppliedTheme: result.AppliedTheme, SkinApplied: result.SkinApplied, NextAction: result.NextAction, + }, nil +} diff --git a/host/go/internal/app/theme_runtime.go b/host/go/internal/app/theme_runtime.go new file mode 100644 index 0000000..743f473 --- /dev/null +++ b/host/go/internal/app/theme_runtime.go @@ -0,0 +1,169 @@ +package app + +import ( + "context" + "encoding/json" + "errors" + "path/filepath" + "sort" + "strconv" + "strings" + "time" + + "github.com/u2bo/OpenChatGPTSkin/host/go/internal/cdp" + "github.com/u2bo/OpenChatGPTSkin/host/go/internal/control" + "github.com/u2bo/OpenChatGPTSkin/host/go/internal/themerepo" +) + +type managedThemeSession interface { + Apply(context.Context, cdp.ThemePayload) error + Restore(context.Context) error + WaitForExit(context.Context) error + Close() error +} + +type sessionFactory func(context.Context) (managedThemeSession, error) +type themeLoader func(themerepo.Ref) (cdp.ThemePayload, error) + +const ( + compatibleTargetReadyTimeout = 20 * time.Second + compatibleTargetRetryInterval = 200 * time.Millisecond +) + +// waitForCompatibleTarget distinguishes a listening CDP port from a renderer +// whose workbench DOM has finished initializing. The platform adapters have +// already verified the port owner before this runs; only transient discovery +// and adapter-readiness failures are retried. +func waitForCompatibleTarget(ctx context.Context, endpoint cdp.Endpoint) (cdp.Target, error) { + readyContext, cancel := context.WithTimeout(ctx, compatibleTargetReadyTimeout) + defer cancel() + + var lastErr error + for { + targets, err := cdp.Discover(readyContext, endpoint) + if err == nil { + var target cdp.Target + target, err = cdp.SelectCompatibleCodexTarget(readyContext, endpoint, targets) + if err == nil { + return target, nil + } + } + if !retryableTargetReadinessError(err) { + return cdp.Target{}, err + } + lastErr = err + + timer := time.NewTimer(compatibleTargetRetryInterval) + select { + case <-readyContext.Done(): + if !timer.Stop() { + select { + case <-timer.C: + default: + } + } + return cdp.Target{}, lastErr + case <-timer.C: + } + } +} + +func retryableTargetReadinessError(err error) bool { + var adapterError cdp.Error + return errors.As(err, &adapterError) && (adapterError.Code == "CDP_NOT_READY" || adapterError.Code == "ADAPTER_INCOMPATIBLE") +} + +func defaultThemeLoader(dataRoot string) themeLoader { + return func(ref themerepo.Ref) (cdp.ThemePayload, error) { + installRoot, err := findInstallRoot(false) + if err != nil { + return cdp.ThemePayload{}, commandError{code: "THEME_NOT_FOUND", message: "Installed themes are unavailable"} + } + repository, err := themerepo.OpenWithPersonal(filepath.Join(installRoot, "themes"), filepath.Join(dataRoot, "theme-store")) + if err != nil { + return cdp.ThemePayload{}, err + } + resolved, err := resolveThemeRef(repository, ref) + if err != nil { + return cdp.ThemePayload{}, err + } + bundle, err := repository.Read("builtin", resolved) + if err != nil { + bundle, err = repository.Read("personal", resolved) + if err != nil { + return cdp.ThemePayload{}, err + } + } + files := make(map[string][]byte, len(bundle.Files)) + total := len(bundle.Document) + for path, contents := range bundle.Files { + files[path] = append([]byte(nil), contents...) + total += len(contents) + } + return cdp.ThemePayload{Document: json.RawMessage(append([]byte(nil), bundle.Document...)), Files: files, TotalBytes: total}, nil + } +} + +func resolveThemeRef(repository *themerepo.Repository, requested themerepo.Ref) (themerepo.Ref, error) { + if requested.Version != "" { + return requested, nil + } + library, err := repository.List() + if err != nil { + return themerepo.Ref{}, err + } + candidates := make([]themerepo.Ref, 0) + for _, theme := range library.Themes { + if theme.Ref.ID == requested.ID && theme.Ready { + candidates = append(candidates, theme.Ref) + } + } + if len(candidates) == 0 { + return themerepo.Ref{}, commandError{code: "THEME_NOT_FOUND", message: "Theme version is unavailable"} + } + sort.Slice(candidates, func(left, right int) bool { return semverGreater(candidates[left].Version, candidates[right].Version) }) + return candidates[0], nil +} + +func semverGreater(left, right string) bool { + parse := func(value string) [3]int { + var result [3]int + for index, part := range strings.Split(value, ".") { + if index >= len(result) { + break + } + result[index], _ = strconv.Atoi(part) + } + return result + } + a, b := parse(left), parse(right) + for index := range a { + if a[index] != b[index] { + return a[index] > b[index] + } + } + return false +} + +func runtimeCommandError(err error, fallback string) error { + if err == nil { + return nil + } + var controlError control.CommandError + if errors.As(err, &controlError) { + return controlError + } + var command commandError + if errors.As(err, &command) { + return control.CommandError{Code: command.code, Message: command.message} + } + var repositoryError themerepo.Error + if errors.As(err, &repositoryError) { + return control.CommandError{Code: repositoryError.Code, Message: repositoryError.Message} + } + var adapterError cdp.Error + if errors.As(err, &adapterError) { + return control.CommandError{Code: adapterError.Code, Message: adapterError.Message} + } + return control.CommandError{Code: fallback, Message: "The Runtime theme operation could not be completed"} +} diff --git a/host/go/internal/app/theme_runtime_darwin.go b/host/go/internal/app/theme_runtime_darwin.go new file mode 100644 index 0000000..32a91a0 --- /dev/null +++ b/host/go/internal/app/theme_runtime_darwin.go @@ -0,0 +1,56 @@ +//go:build darwin + +package app + +import ( + "context" + "errors" + + "github.com/u2bo/OpenChatGPTSkin/host/go/internal/cdp" + "github.com/u2bo/OpenChatGPTSkin/host/go/internal/control" + platform "github.com/u2bo/OpenChatGPTSkin/host/go/internal/platform/macos" +) + +type macOSThemeSession struct { + connection *cdp.Connection + root platform.ProcessIdentity +} + +func newManagedThemeSession(ctx context.Context) (managedThemeSession, error) { + launch, err := platform.LaunchManaged(ctx) + if err != nil { + return nil, platformRuntimeError(err) + } + endpoint := cdp.Endpoint{Host: "127.0.0.1", Port: launch.Port} + target, err := waitForCompatibleTarget(ctx, endpoint) + if err != nil { + return nil, platformRuntimeError(err) + } + connection, err := cdp.Connect(ctx, endpoint, target) + if err != nil { + return nil, platformRuntimeError(err) + } + return &macOSThemeSession{connection: connection, root: launch.Root}, nil +} + +func (session *macOSThemeSession) Apply(ctx context.Context, payload cdp.ThemePayload) error { + return session.connection.ApplyTheme(ctx, payload) +} + +func (session *macOSThemeSession) Restore(ctx context.Context) error { + return session.connection.RestoreTheme(ctx) +} + +func (session *macOSThemeSession) WaitForExit(ctx context.Context) error { + return platform.WaitForManagedExit(ctx, session.root) +} + +func (session *macOSThemeSession) Close() error { return session.connection.Close() } + +func platformRuntimeError(err error) error { + var platformError platform.Error + if errors.As(err, &platformError) { + return control.CommandError{Code: platformError.Code, Message: platformError.Message} + } + return err +} diff --git a/host/go/internal/app/theme_runtime_windows.go b/host/go/internal/app/theme_runtime_windows.go new file mode 100644 index 0000000..d80aec9 --- /dev/null +++ b/host/go/internal/app/theme_runtime_windows.go @@ -0,0 +1,56 @@ +//go:build windows + +package app + +import ( + "context" + "errors" + + "github.com/u2bo/OpenChatGPTSkin/host/go/internal/cdp" + "github.com/u2bo/OpenChatGPTSkin/host/go/internal/control" + platform "github.com/u2bo/OpenChatGPTSkin/host/go/internal/platform/windows" +) + +type windowsThemeSession struct { + connection *cdp.Connection + root platform.ProcessIdentity +} + +func newManagedThemeSession(ctx context.Context) (managedThemeSession, error) { + launch, err := platform.LaunchManaged(ctx) + if err != nil { + return nil, platformRuntimeError(err) + } + endpoint := cdp.Endpoint{Host: "127.0.0.1", Port: launch.Port} + target, err := waitForCompatibleTarget(ctx, endpoint) + if err != nil { + return nil, platformRuntimeError(err) + } + connection, err := cdp.Connect(ctx, endpoint, target) + if err != nil { + return nil, platformRuntimeError(err) + } + return &windowsThemeSession{connection: connection, root: launch.Root}, nil +} + +func (session *windowsThemeSession) Apply(ctx context.Context, payload cdp.ThemePayload) error { + return session.connection.ApplyTheme(ctx, payload) +} + +func (session *windowsThemeSession) Restore(ctx context.Context) error { + return session.connection.RestoreTheme(ctx) +} + +func (session *windowsThemeSession) WaitForExit(ctx context.Context) error { + return platform.WaitForManagedExit(ctx, session.root) +} + +func (session *windowsThemeSession) Close() error { return session.connection.Close() } + +func platformRuntimeError(err error) error { + var platformError platform.Error + if errors.As(err, &platformError) { + return control.CommandError{Code: platformError.Code, Message: platformError.Message} + } + return err +} diff --git a/host/go/internal/studio/server.go b/host/go/internal/studio/server.go index 704b534..ca1452a 100644 --- a/host/go/internal/studio/server.go +++ b/host/go/internal/studio/server.go @@ -57,6 +57,8 @@ type Config struct { StudioVersion string RepositoryURL *string RuntimeStatus func() RuntimeStatus + ApplyTheme func(context.Context, themerepo.Ref) (RuntimeStatus, error) + RestoreTheme func(context.Context) (RuntimeStatus, error) MaxSSEClients int ViteOrigin string } @@ -200,7 +202,8 @@ func Start(ctx context.Context, config Config) (*RunningServer, error) { state := &handlerState{ origin: origin, session: session, repository: repository, workspace: workspace, indexHTML: indexHTML, studioVersion: config.StudioVersion, repositoryURL: config.RepositoryURL, - runtimeStatus: runtimeStatus, maxSSEClients: maxSSEClients, nonce: nonce, viteProxy: viteProxy, + runtimeStatus: runtimeStatus, applyTheme: config.ApplyTheme, restoreTheme: config.RestoreTheme, + maxSSEClients: maxSSEClients, nonce: nonce, viteProxy: viteProxy, } server := &http.Server{ Handler: state, @@ -247,6 +250,8 @@ type handlerState struct { studioVersion string repositoryURL *string runtimeStatus func() RuntimeStatus + applyTheme func(context.Context, themerepo.Ref) (RuntimeStatus, error) + restoreTheme func(context.Context) (RuntimeStatus, error) maxSSEClients int nonce string viteProxy *httputil.ReverseProxy @@ -329,6 +334,41 @@ func (state *handlerState) dispatch(response http.ResponseWriter, request *http. } state.writeJSON(response, http.StatusOK, library) return nil + case request.URL.Path == "/api/themes/apply" && request.Method == http.MethodPost: + if err := state.requireOrigin(request); err != nil { + return err + } + if state.applyTheme == nil { + return studioError{code: "RUNTIME_STATUS_UNAVAILABLE", message: "Runtime apply is unavailable", statusCode: http.StatusServiceUnavailable} + } + var input struct { + Ref themerepo.Ref `json:"ref"` + } + if err := decodeBoundedJSON(request.Body, jsonLimitBytes, &input); err != nil { + return err + } + if _, err := state.readTheme(input.Ref); err != nil { + return err + } + runtime, err := state.applyTheme(request.Context(), input.Ref) + if err != nil { + return err + } + state.writeJSON(response, http.StatusOK, runtime) + return nil + case request.URL.Path == "/api/runtime/restore" && request.Method == http.MethodPost: + if err := state.requireOrigin(request); err != nil { + return err + } + if state.restoreTheme == nil { + return studioError{code: "RUNTIME_STATUS_UNAVAILABLE", message: "Runtime restore is unavailable", statusCode: http.StatusServiceUnavailable} + } + runtime, err := state.restoreTheme(request.Context()) + if err != nil { + return err + } + state.writeJSON(response, http.StatusOK, runtime) + return nil case request.URL.Path == "/api/theme-preview" && request.Method == http.MethodGet: asset, err := state.repository.Preview(request.URL.Query().Get("source"), themerepo.Ref{ ID: request.URL.Query().Get("id"), Version: request.URL.Query().Get("version"), @@ -437,6 +477,14 @@ func (state *handlerState) dispatch(response http.ResponseWriter, request *http. } } +func (state *handlerState) readTheme(ref themerepo.Ref) (themerepo.Bundle, error) { + bundle, err := state.repository.Read("builtin", ref) + if err == nil { + return bundle, nil + } + return state.repository.Read("personal", ref) +} + func (state *handlerState) dispatchDraft(response http.ResponseWriter, request *http.Request) error { relative := strings.TrimPrefix(request.URL.Path, "/api/drafts/") parts := strings.Split(relative, "/") diff --git a/host/go/internal/studio/server_test.go b/host/go/internal/studio/server_test.go index a14d2dc..1228436 100644 --- a/host/go/internal/studio/server_test.go +++ b/host/go/internal/studio/server_test.go @@ -269,3 +269,54 @@ func TestStudioDraftRoutesPersistAndExportPersonalVersion(t *testing.T) { t.Fatalf("export status=%d bytes=%d type=%q", response.StatusCode, len(archive), response.Header.Get("Content-Type")) } } + +func TestStudioAppliesOnlySavedThemeAndReturnsRuntimeStatus(t *testing.T) { + config := studioFixture(t) + applied := themerepo.Ref{} + config.ApplyTheme = func(_ context.Context, ref themerepo.Ref) (RuntimeStatus, error) { + applied = ref + active := true + return RuntimeStatus{Status: "active", ControllerAvailable: true, SelectedTheme: &ref, AppliedTheme: &ref, SkinApplied: &active, NextAction: "Theme is active."}, nil + } + config.RestoreTheme = func(context.Context) (RuntimeStatus, error) { + active := false + return RuntimeStatus{Status: "restored-awaiting-exit", ControllerAvailable: true, SkinApplied: &active, NextAction: "Waiting for normal exit."}, nil + } + server, err := Start(context.Background(), config) + if err != nil { + t.Fatal(err) + } + defer server.Close() + client := sessionClient(t, server) + apply, err := http.NewRequest(http.MethodPost, server.Origin+"/api/themes/apply", strings.NewReader(`{"ref":{"id":"mountain-mist","version":"1.3.0"}}`)) + if err != nil { + t.Fatal(err) + } + apply.Header.Set("Origin", server.Origin) + apply.Header.Set("Content-Type", "application/json") + response, err := client.Do(apply) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK || applied != (themerepo.Ref{ID: "mountain-mist", Version: "1.3.0"}) { + t.Fatalf("apply status=%d ref=%+v", response.StatusCode, applied) + } + var runtime RuntimeStatus + if err := json.NewDecoder(response.Body).Decode(&runtime); err != nil || runtime.Status != "active" || runtime.SkinApplied == nil || !*runtime.SkinApplied { + t.Fatalf("runtime=%+v error=%v", runtime, err) + } + restore, err := http.NewRequest(http.MethodPost, server.Origin+"/api/runtime/restore", nil) + if err != nil { + t.Fatal(err) + } + restore.Header.Set("Origin", server.Origin) + response, err = client.Do(restore) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + t.Fatalf("restore status=%d", response.StatusCode) + } +} From d7bd1fa144b6098123f1291deeb7f66329c6ac8f Mon Sep 17 00:00:00 2001 From: u2bo <285077078@qq.com> Date: Sat, 25 Jul 2026 13:16:34 +0800 Subject: [PATCH 18/23] feat: build native node-free Go packages --- scripts/release/build-go-spike.ts | 1 + scripts/release/go-spike.ts | 182 ++++++++++++++++++++++++++---- tests/go-spike.test.ts | 66 ++++++++--- 3 files changed, 214 insertions(+), 35 deletions(-) diff --git a/scripts/release/build-go-spike.ts b/scripts/release/build-go-spike.ts index bf7ed06..d152977 100644 --- a/scripts/release/build-go-spike.ts +++ b/scripts/release/build-go-spike.ts @@ -10,6 +10,7 @@ try { outputDirectory: output, nativeInstallers: !args.includes("--portable-only"), nativeArtifactsOnly: args.includes("--native-only"), + onProgress: (message) => process.stderr.write(`[go-package] ${message}\n`), }); process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); } catch (error) { diff --git a/scripts/release/go-spike.ts b/scripts/release/go-spike.ts index bcb011b..414c23d 100644 --- a/scripts/release/go-spike.ts +++ b/scripts/release/go-spike.ts @@ -101,6 +101,15 @@ export interface BuildGoSpikeOptions { readonly outputDirectory: string; readonly nativeInstallers: boolean; readonly nativeArtifactsOnly?: boolean; + readonly onProgress?: (message: string) => void; +} + +interface GoSpikeBuildMetadata { + readonly goVersion: string; + readonly commit: string; + readonly dirty: boolean; + readonly contractsSha256: string; + readonly cdpAdapterSha256: string; } function portable(path: string): string { @@ -175,6 +184,36 @@ function sha256(contents: Uint8Array): string { return createHash("sha256").update(contents).digest("hex"); } +async function directorySha256(root: string): Promise { + const hash = createHash("sha256"); + for (const path of await walkFiles(root)) { + hash.update(path, "utf8"); + hash.update("\0", "utf8"); + hash.update(await readFile(join(root, ...path.split("/")))); + } + return hash.digest("hex"); +} + +async function buildMetadata(workspaceRoot: string): Promise { + const [{ stdout: goVersion }, { stdout: commit }, { stdout: status }, cdpManifest] = await Promise.all([ + execFileAsync("go", ["env", "GOVERSION"], { cwd: join(workspaceRoot, "host", "go"), windowsHide: true }), + execFileAsync("git", ["rev-parse", "HEAD"], { cwd: workspaceRoot, windowsHide: true }), + execFileAsync("git", ["status", "--porcelain"], { cwd: workspaceRoot, windowsHide: true }), + readFile(join(workspaceRoot, "host", "go", "internal", "cdp", "generated", "adapter-manifest.json"), "utf8"), + ]); + const parsed = JSON.parse(cdpManifest) as { readonly sha256?: unknown }; + if (typeof parsed.sha256 !== "string" || !/^[a-f0-9]{64}$/.test(parsed.sha256)) { + throw new Error("Embedded CDP Adapter manifest hash is invalid"); + } + return { + goVersion: goVersion.trim(), + commit: commit.trim(), + dirty: status.trim().length > 0, + contractsSha256: await directorySha256(join(workspaceRoot, "contracts")), + cdpAdapterSha256: parsed.sha256, + }; +} + async function artifact(path: string, kind: GoSpikeArtifactReport["kind"], baselineBytes: number): Promise { const contents = await readFile(path); return { name: path.split(sep).at(-1)!, kind, bytes: contents.length, sha256: sha256(contents), baselineBytes }; @@ -210,6 +249,7 @@ async function stageTarget( workspaceRoot: string, outputDirectory: string, target: Target, + metadata: GoSpikeBuildMetadata, ): Promise { const stageParent = join(outputDirectory, "stages", target.target); const stageRoot = join(stageParent, PRODUCT); @@ -240,20 +280,34 @@ async function stageTarget( copyRuntimeThemes(workspaceRoot, join(stageRoot, "themes")), cp(join(workspaceRoot, "LICENSE"), join(stageRoot, "LICENSE")), ]); + const executableContents = await readFile(executablePath); + const executableFormat = inspectExecutable(executableContents); + if (executableFormat !== target.executableFormat) { + throw new Error(`Go spike target format mismatch: ${target.target}`); + } await writeFile(join(stageRoot, "go-spike-manifest.json"), `${JSON.stringify({ schemaVersion: 1, version: SPIKE_VERSION, target: target.target, roles: ["studio", "controller", "runtime"], + host: { + language: "go", + goVersion: metadata.goVersion, + commit: metadata.commit, + dirty: metadata.dirty, + entry: { + path: target.executable, + bytes: executableContents.length, + sha256: sha256(executableContents), + }, + }, + contractsSha256: metadata.contractsSha256, + cdpAdapterSha256: metadata.cdpAdapterSha256, imageImplementation: IMAGE_IMPLEMENTATION, cgo: false, sidecars: [], }, null, 2)}\n`, "utf8"); - const executableContents = await readFile(executablePath); - const executableFormat = inspectExecutable(executableContents); - if (executableFormat !== target.executableFormat) { - throw new Error(`Go spike target format mismatch: ${target.target}`); - } + await assertNodeFreeStage(stageRoot); return { target: target.target, executableFormat, @@ -263,6 +317,16 @@ async function stageTarget( }; } +async function assertNodeFreeStage(stageRoot: string): Promise { + for (const path of await walkFiles(stageRoot)) { + const parts = path.toLowerCase().split("/"); + const name = parts.at(-1); + if (parts.includes("node_modules") || name === "node" || name === "node.exe") { + throw new Error(`Go spike stage contains a Node Runtime entry: ${path}`); + } + } +} + async function buildPortableArtifacts( outputDirectory: string, targets: readonly Target[], @@ -296,11 +360,28 @@ function currentNativeTarget(): Target { } async function findInnoSetup(): Promise { - for (const path of [ + const candidates = [ process.env.INNO_SETUP_COMPILER, "C:/Program Files (x86)/Inno Setup 6/ISCC.exe", "C:/Program Files/Inno Setup 6/ISCC.exe", - ]) { + ]; + if (process.platform === "win32") { + for (const key of [ + "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\Inno Setup 6_is1", + "HKLM\\Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\Inno Setup 6_is1", + "HKLM\\Software\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\Inno Setup 6_is1", + ]) { + try { + const { stdout } = await execFileAsync("reg.exe", ["query", key, "/v", "InstallLocation"], { windowsHide: true }); + const match = /^\s*InstallLocation\s+REG_\w+\s+(.+)$/im.exec(stdout); + if (match?.[1]) candidates.push(join(match[1].trim(), "ISCC.exe")); + } catch (error) { + const code = (error as NodeJS.ErrnoException & { readonly code?: unknown }).code; + if (code !== 1) throw error; + } + } + } + for (const path of candidates) { if (path && await pathExists(path)) return path; } return null; @@ -424,22 +505,30 @@ export async function buildGoSpikePackages(options: BuildGoSpikeOptions): Promis await mkdir(outputDirectory, { recursive: true }); const npmExecPath = process.env.npm_execpath; if (!npmExecPath) throw new Error("npm_execpath is required to build the Go spike UI"); + options.onProgress?.("Building Theme Studio UI"); await execFileAsync(process.execPath, [npmExecPath, "run", "studio:build"], { cwd: workspaceRoot, windowsHide: true, }); + const metadata = await buildMetadata(workspaceRoot); + const selectedTargets = options.nativeArtifactsOnly ? [currentNativeTarget()] : TARGETS; const targets: GoSpikeTargetReport[] = []; - for (const target of TARGETS) targets.push(await stageTarget(workspaceRoot, outputDirectory, target)); + for (const target of selectedTargets) { + options.onProgress?.(`Building and staging ${target.target}`); + targets.push(await stageTarget(workspaceRoot, outputDirectory, target, metadata)); + } const nativeTarget = options.nativeInstallers || options.nativeArtifactsOnly ? currentNativeTarget() : undefined; - const artifacts = await buildPortableArtifacts( - outputDirectory, - options.nativeArtifactsOnly ? [nativeTarget!] : TARGETS, - ); + options.onProgress?.("Building portable artifacts"); + const artifacts = await buildPortableArtifacts(outputDirectory, selectedTargets); if (options.nativeInstallers) { - if (process.platform === "win32") artifacts.push(await buildWindowsSetup(outputDirectory)); + if (process.platform === "win32") { + options.onProgress?.("Building and accepting Windows Setup"); + artifacts.push(await buildWindowsSetup(outputDirectory)); + } if (process.platform === "darwin") { + options.onProgress?.(`Building macOS ${nativeTarget!.goarch === "arm64" ? "ARM64" : "x64"} DMG`); artifacts.push(await buildMacDmg( outputDirectory, nativeTarget as Extract, @@ -456,6 +545,7 @@ export async function buildGoSpikePackages(options: BuildGoSpikeOptions): Promis artifacts, }; await writeFile(join(outputDirectory, "go-spike-report.json"), `${JSON.stringify(report, null, 2)}\n`, "utf8"); + options.onProgress?.("Go package build complete"); return report; } @@ -469,7 +559,8 @@ export async function acceptGoSpikePackages( }> { const outputDirectory = resolve(outputDirectoryInput); const report = JSON.parse(await readFile(join(outputDirectory, "go-spike-report.json"), "utf8")) as GoSpikeReport; - if (report.schemaVersion !== 1 || report.targets.length !== 3) { + if (report.schemaVersion !== 1 || report.targets.length < 1 || report.targets.length > TARGETS.length || + new Set(report.targets.map(({ target }) => target)).size !== report.targets.length) { throw new Error("Go spike report identity is invalid"); } for (const target of report.targets) { @@ -478,12 +569,35 @@ export async function acceptGoSpikePackages( } const definition = TARGETS.find((value) => value.target === target.target); if (!definition) throw new Error(`Unknown Go spike target: ${target.target}`); - const executable = await readFile(join( - outputDirectory, "stages", target.target, PRODUCT, definition.executable, - )); + const stageRoot = join(outputDirectory, "stages", target.target, PRODUCT); + await assertNodeFreeStage(stageRoot); + const executable = await readFile(join(stageRoot, definition.executable)); if (inspectExecutable(executable) !== target.executableFormat) { throw new Error(`Go spike executable format changed: ${target.target}`); } + const manifest = JSON.parse(await readFile(join(stageRoot, "go-spike-manifest.json"), "utf8")) as { + readonly schemaVersion?: unknown; + readonly target?: unknown; + readonly host?: { + readonly language?: unknown; + readonly goVersion?: unknown; + readonly commit?: unknown; + readonly entry?: { readonly path?: unknown; readonly bytes?: unknown; readonly sha256?: unknown }; + }; + readonly contractsSha256?: unknown; + readonly cdpAdapterSha256?: unknown; + readonly sidecars?: unknown; + }; + if (manifest.schemaVersion !== 1 || manifest.target !== target.target || manifest.host?.language !== "go" || + typeof manifest.host.goVersion !== "string" || !/^go\d+\.\d+/.test(manifest.host.goVersion) || + typeof manifest.host.commit !== "string" || !/^[a-f0-9]{40}$/.test(manifest.host.commit) || + manifest.host.entry?.path !== definition.executable || manifest.host.entry.bytes !== executable.length || + manifest.host.entry.sha256 !== sha256(executable) || + typeof manifest.contractsSha256 !== "string" || !/^[a-f0-9]{64}$/.test(manifest.contractsSha256) || + typeof manifest.cdpAdapterSha256 !== "string" || !/^[a-f0-9]{64}$/.test(manifest.cdpAdapterSha256) || + !Array.isArray(manifest.sidecars) || manifest.sidecars.length !== 0) { + throw new Error(`Go spike manifest is invalid: ${target.target}`); + } for (const required of [ "apps/theme-studio/dist/index.html", "themes/catalog.json", @@ -511,7 +625,7 @@ export async function acceptGoSpikePackages( } } const kinds = new Set(report.artifacts.map(({ kind }) => kind)); - const nativeEvidenceComplete = [ + const nativeEvidenceComplete = TARGETS.every(({ target }) => report.targets.some((value) => value.target === target)) && [ "zip-x64", "setup-x64", "tar.gz-arm64", "dmg-arm64", "tar.gz-x64", "dmg-x64", ].every((kind) => kinds.has(kind as GoSpikeArtifactReport["kind"])); if (requireAllNative && !nativeEvidenceComplete) { @@ -530,7 +644,7 @@ export async function mergeGoSpikePackages( const outputDirectory = resolve(outputDirectoryInput); await rm(outputDirectory, { recursive: true, force: true }); await mkdir(outputDirectory, { recursive: true }); - const inputs = inputDirectories.map(resolve); + const inputs = inputDirectories.map((directory) => resolve(directory)); const reports = await Promise.all(inputs.map(async (directory) => ({ directory, report: JSON.parse(await readFile(join(directory, "go-spike-report.json"), "utf8")) as GoSpikeReport, @@ -540,11 +654,34 @@ export async function mergeGoSpikePackages( report.schemaVersion !== foundation.schemaVersion || report.version !== foundation.version || report.imageImplementation !== foundation.imageImplementation || - JSON.stringify(report.targets) !== JSON.stringify(foundation.targets) + report.cgo !== foundation.cgo || + JSON.stringify(report.sidecars) !== JSON.stringify(foundation.sidecars) )) { throw new Error("Go spike reports do not describe the same source build"); } - await cp(join(reports[0]!.directory, "stages"), join(outputDirectory, "stages"), { recursive: true }); + const selectedTargets = new Map(); + for (const input of reports) { + for (const target of input.report.targets) { + const existing = selectedTargets.get(target.target); + if (existing && JSON.stringify(existing.target) !== JSON.stringify(target)) { + throw new Error(`Go spike target reports conflict: ${target.target}`); + } + selectedTargets.set(target.target, { directory: input.directory, target }); + } + } + if (!TARGETS.every(({ target }) => selectedTargets.has(target))) { + throw new Error("Go spike merge is missing a native target"); + } + for (const [target, value] of selectedTargets) { + await cp( + join(value.directory, "stages", target), + join(outputDirectory, "stages", target), + { recursive: true }, + ); + } const selected = new Map selectedTargets.get(target)!.target); + const report: GoSpikeReport = { ...foundation, targets, artifacts }; await writeFile(join(outputDirectory, "go-spike-report.json"), `${JSON.stringify(report, null, 2)}\n`, "utf8"); return report; } diff --git a/tests/go-spike.test.ts b/tests/go-spike.test.ts index f7faf1b..c64b5d5 100644 --- a/tests/go-spike.test.ts +++ b/tests/go-spike.test.ts @@ -1,10 +1,11 @@ -import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { acceptGoSpikePackages, buildGoSpikePackages, + mergeGoSpikePackages, } from "../scripts/release/go-spike.js"; const temporaryRoots: string[] = []; @@ -49,34 +50,73 @@ describe("Go host feasibility packages", () => { expect(workflow).not.toContain("gh release create"); }); - it("cross-builds one host for three targets and packages the reviewed payload", async () => { + it.runIf(process.platform === "win32" || process.platform === "darwin")( + "builds only the current native host when native-only is requested", async () => { const output = await mkdtemp(join(tmpdir(), "openchatgptskin-go-spike-")); temporaryRoots.push(output); const report = await buildGoSpikePackages({ workspaceRoot: process.cwd(), outputDirectory: output, nativeInstallers: false, + nativeArtifactsOnly: true, }); + const expectedTarget = process.platform === "win32" + ? { target: "windows-x64", executableFormat: "pe-x64", artifact: "zip-x64" } + : process.arch === "arm64" + ? { target: "macos-arm64", executableFormat: "mach-o-arm64", artifact: "tar.gz-arm64" } + : { target: "macos-x64", executableFormat: "mach-o-x64", artifact: "tar.gz-x64" }; expect(report.targets.map(({ target, executableFormat }) => ({ target, executableFormat }))) - .toEqual([ - { target: "windows-x64", executableFormat: "pe-x64" }, - { target: "macos-arm64", executableFormat: "mach-o-arm64" }, - { target: "macos-x64", executableFormat: "mach-o-x64" }, - ]); + .toEqual([{ target: expectedTarget.target, executableFormat: expectedTarget.executableFormat }]); expect(report.targets.every(({ stageBytes, baselineStageBytes }) => stageBytes < baselineStageBytes )).toBe(true); - expect(report.artifacts.map(({ kind }) => kind).sort()).toEqual([ - "tar.gz-arm64", - "tar.gz-x64", - "zip-x64", - ]); + expect(report.artifacts.map(({ kind }) => kind)).toEqual([expectedTarget.artifact]); expect(report.artifacts.every(({ bytes, baselineBytes }) => bytes < baselineBytes)).toBe(true); await expect(acceptGoSpikePackages(output, false)).resolves.toMatchObject({ accepted: true, - artifactCount: 3, + artifactCount: 1, nativeEvidenceComplete: false, }); }, 60_000); + + it("merges three single-target native reports without cross-building", async () => { + const root = await mkdtemp(join(tmpdir(), "openchatgptskin-go-spike-merge-")); + temporaryRoots.push(root); + const definitions = [ + { target: "windows-x64", format: "pe-x64", kinds: ["zip-x64", "setup-x64"] }, + { target: "macos-arm64", format: "mach-o-arm64", kinds: ["tar.gz-arm64", "dmg-arm64"] }, + { target: "macos-x64", format: "mach-o-x64", kinds: ["tar.gz-x64", "dmg-x64"] }, + ] as const; + const inputs: string[] = []; + for (const definition of definitions) { + const input = join(root, definition.target); + inputs.push(input); + await mkdir(join(input, "stages", definition.target, "OpenChatGPTSkin"), { recursive: true }); + await writeFile(join(input, "stages", definition.target, "OpenChatGPTSkin", "marker.txt"), definition.target); + const artifacts = await Promise.all(definition.kinds.map(async (kind) => { + const name = `${definition.target}-${kind}.bin`; + await writeFile(join(input, name), kind); + return { name, kind, bytes: kind.length, sha256: "0".repeat(64), baselineBytes: 1_000_000 }; + })); + await writeFile(join(input, "go-spike-report.json"), JSON.stringify({ + schemaVersion: 1, + version: "0.3.0-alpha.1", + imageImplementation: "gen2brain-webp-wasm2go-nodynamic-plus-internal-pipeline", + cgo: false, + sidecars: [], + targets: [{ + target: definition.target, + executableFormat: definition.format, + executableBytes: 1, + stageBytes: 1, + baselineStageBytes: 1_000_000, + }], + artifacts, + })); + } + const merged = await mergeGoSpikePackages(inputs, join(root, "combined")); + expect(merged.targets.map(({ target }) => target)).toEqual(definitions.map(({ target }) => target)); + expect(merged.artifacts).toHaveLength(6); + }); }); From 7cea3d616202c8455e323d95c2ac277e0d034bd7 Mon Sep 17 00:00:00 2001 From: u2bo <285077078@qq.com> Date: Sat, 25 Jul 2026 13:29:31 +0800 Subject: [PATCH 19/23] fix: align Go theme apply request contract --- host/go/internal/studio/server.go | 8 +++----- host/go/internal/studio/server_test.go | 2 +- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/host/go/internal/studio/server.go b/host/go/internal/studio/server.go index ca1452a..d37078d 100644 --- a/host/go/internal/studio/server.go +++ b/host/go/internal/studio/server.go @@ -341,16 +341,14 @@ func (state *handlerState) dispatch(response http.ResponseWriter, request *http. if state.applyTheme == nil { return studioError{code: "RUNTIME_STATUS_UNAVAILABLE", message: "Runtime apply is unavailable", statusCode: http.StatusServiceUnavailable} } - var input struct { - Ref themerepo.Ref `json:"ref"` - } + var input themerepo.Ref if err := decodeBoundedJSON(request.Body, jsonLimitBytes, &input); err != nil { return err } - if _, err := state.readTheme(input.Ref); err != nil { + if _, err := state.readTheme(input); err != nil { return err } - runtime, err := state.applyTheme(request.Context(), input.Ref) + runtime, err := state.applyTheme(request.Context(), input) if err != nil { return err } diff --git a/host/go/internal/studio/server_test.go b/host/go/internal/studio/server_test.go index 1228436..63abc84 100644 --- a/host/go/internal/studio/server_test.go +++ b/host/go/internal/studio/server_test.go @@ -288,7 +288,7 @@ func TestStudioAppliesOnlySavedThemeAndReturnsRuntimeStatus(t *testing.T) { } defer server.Close() client := sessionClient(t, server) - apply, err := http.NewRequest(http.MethodPost, server.Origin+"/api/themes/apply", strings.NewReader(`{"ref":{"id":"mountain-mist","version":"1.3.0"}}`)) + apply, err := http.NewRequest(http.MethodPost, server.Origin+"/api/themes/apply", strings.NewReader(`{"id":"mountain-mist","version":"1.3.0"}`)) if err != nil { t.Fatal(err) } From eb88af4394f6ae6fd60502694335ffb73c3804a4 Mon Sep 17 00:00:00 2001 From: u2bo <285077078@qq.com> Date: Sat, 25 Jul 2026 13:42:14 +0800 Subject: [PATCH 20/23] fix: hide Windows inspection subprocesses --- host/go/internal/platform/windows/adapter_windows.go | 11 +++++++++-- .../internal/platform/windows/adapter_windows_test.go | 7 +++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/host/go/internal/platform/windows/adapter_windows.go b/host/go/internal/platform/windows/adapter_windows.go index f34fb96..d15f668 100644 --- a/host/go/internal/platform/windows/adapter_windows.go +++ b/host/go/internal/platform/windows/adapter_windows.go @@ -27,6 +27,7 @@ const ( expectedResource = "OpenAI OpCo, LLC" expectedEntry = "app/ChatGPT.exe" managedCDPReadyTimeout = 90 * time.Second + createNoWindow = 0x08000000 ) var versionPattern = regexp.MustCompile(`^\d+\.\d+\.\d+\.\d+$`) @@ -82,8 +83,14 @@ type PowerShellRunner interface { type defaultRunner struct{} -func (defaultRunner) Run(ctx context.Context, script string) ([]byte, error) { +func newPowerShellCommand(ctx context.Context) *exec.Cmd { command := exec.CommandContext(ctx, "powershell.exe", "-NoLogo", "-NoProfile", "-NonInteractive", "-Command", "& ([scriptblock]::Create([Console]::In.ReadToEnd()))") + command.SysProcAttr = &syscall.SysProcAttr{CreationFlags: createNoWindow, HideWindow: true} + return command +} + +func (defaultRunner) Run(ctx context.Context, script string) ([]byte, error) { + command := newPowerShellCommand(ctx) command.Stdin = strings.NewReader(script) var stdout, stderr bytes.Buffer command.Stdout, command.Stderr = &stdout, &stderr @@ -257,7 +264,7 @@ func inspectPort(ctx context.Context, port int) (portInspection, error) { } inspectionContext, cancel := context.WithTimeout(ctx, 3*time.Second) defer cancel() - command := exec.CommandContext(inspectionContext, "powershell.exe", "-NoLogo", "-NoProfile", "-NonInteractive", "-Command", "& ([scriptblock]::Create([Console]::In.ReadToEnd()))") + command := newPowerShellCommand(inspectionContext) command.Env = append(os.Environ(), fmt.Sprintf("OPEN_CHATGPT_SKIN_PORT=%d", port)) command.Stdin = strings.NewReader(portScript) var stderr bytes.Buffer diff --git a/host/go/internal/platform/windows/adapter_windows_test.go b/host/go/internal/platform/windows/adapter_windows_test.go index 3c1fd5e..5c23401 100644 --- a/host/go/internal/platform/windows/adapter_windows_test.go +++ b/host/go/internal/platform/windows/adapter_windows_test.go @@ -43,6 +43,13 @@ func TestInspectionAcceptsCompleteOfficialIdentity(t *testing.T) { } } +func TestPowerShellInspectionNeverCreatesAVisibleConsole(t *testing.T) { + command := newPowerShellCommand(context.Background()) + if command.SysProcAttr == nil || !command.SysProcAttr.HideWindow || command.SysProcAttr.CreationFlags&0x08000000 == 0 { + t.Fatalf("PowerShell inspection may create a visible console: %+v", command.SysProcAttr) + } +} + func TestInspectionRejectsSubstitutedPublisher(t *testing.T) { value := validInstall() value.PackagePublisher = "CN=Unknown" From ffafe25bab9028d5eac45fda984b25bef14de7c1 Mon Sep 17 00:00:00 2001 From: u2bo <285077078@qq.com> Date: Sat, 25 Jul 2026 15:03:08 +0800 Subject: [PATCH 21/23] feat: complete Go production cutover --- .github/workflows/go-host-spike.yml | 72 - .github/workflows/release.yml | 195 +-- README.en.md | 67 +- README.md | 67 +- apps/theme-studio/package.json | 6 +- contracts/baseline/v0.2.0/go-spike-sizes.json | 115 -- contracts/data/v1/cases/compatibility.json | 6 +- contracts/data/v1/schemas/index.json | 245 +++- docs/go-host-gate-a.md | 22 +- docs/releases/v0.3.0-alpha.1.md | 36 + docs/runtime-macos.en.md | 8 +- docs/runtime-macos.md | 8 +- docs/runtime-windows.md | 127 +- docs/theme-studio.md | 2 +- host/go/go.mod | 2 +- host/go/internal/app/app_test.go | 129 ++ host/go/internal/app/command.go | 5 +- host/go/internal/app/run.go | 18 +- host/go/internal/app/runtime.go | 227 +++- host/go/internal/app/studio.go | 41 +- .../platform/windows/transport_windows.go | 2 +- .../windows/transport_windows_test.go | 8 + package-lock.json | 113 +- package.json | 35 +- packages/cdp-adapter/package.json | 6 +- packages/runtime-contract/package.json | 17 + packages/runtime-contract/src/index.ts | 214 +++ .../runtime-contract}/tsconfig.json | 4 +- packages/theme-core/package.json | 4 +- packages/theme-schema/package.json | 2 +- packages/theme-studio-core/package.json | 4 +- packages/theme-studio-core/src/index.ts | 1 + packages/theme-studio-core/src/persistence.ts | 25 + runtime/theme-studio-service/package.json | 30 - runtime/theme-studio-service/src/cli.ts | 186 --- runtime/theme-studio-service/src/http.ts | 101 -- runtime/theme-studio-service/src/index.ts | 7 - .../src/production-host.ts | 52 - .../src/runtime-status.ts | 198 --- runtime/theme-studio-service/src/server.ts | 442 ------- runtime/theme-studio-service/src/session.ts | 62 - runtime/theme-studio-service/src/vite-host.ts | 125 -- runtime/theme-studio-service/src/workspace.ts | 1157 ----------------- runtime/theme-studio-service/tsconfig.json | 15 - runtime/windows/package.json | 22 - runtime/windows/src/acceptance-cli.ts | 115 -- runtime/windows/src/acceptance-evidence.ts | 90 -- runtime/windows/src/acceptance-runner.ts | 237 ---- runtime/windows/src/acceptance-sequence.ts | 30 - runtime/windows/src/acceptance-session.ts | 99 -- runtime/windows/src/cli.ts | 53 - runtime/windows/src/cli/arguments.ts | 75 -- runtime/windows/src/cli/run.ts | 278 ---- .../windows/src/control/controller-lock.ts | 140 -- runtime/windows/src/control/dispatcher.ts | 265 ---- runtime/windows/src/control/framing.ts | 41 - runtime/windows/src/control/pipe-client.ts | 166 --- .../windows/src/control/pipe-host-script.ts | 121 -- runtime/windows/src/control/pipe-server.ts | 401 ------ runtime/windows/src/control/protocol.ts | 126 -- runtime/windows/src/control/result.ts | 63 - .../src/control/secure-control-server.ts | 47 - .../windows/src/control/unix-socket-server.ts | 206 --- .../windows/src/controller/exit-monitor.ts | 139 -- .../windows/src/controller/managed-session.ts | 43 - .../windows/src/controller/page-session.ts | 67 - runtime/windows/src/controller/production.ts | 310 ----- runtime/windows/src/controller/recovery.ts | 421 ------ .../src/controller/renderer-lifecycle.ts | 103 -- .../src/controller/runtime-controller.ts | 923 ------------- .../windows/src/controller/theme-engine.ts | 77 -- runtime/windows/src/discovery/discover.ts | 236 ---- .../windows/src/discovery/trusted-cache.ts | 97 -- runtime/windows/src/errors.ts | 92 -- runtime/windows/src/index.ts | 49 - runtime/windows/src/launcher/launcher.ts | 261 ---- runtime/windows/src/launcher/port.ts | 20 - runtime/windows/src/logging.ts | 55 - runtime/windows/src/macos/identity.ts | 10 - runtime/windows/src/macos/macos-provider.ts | 541 -------- runtime/windows/src/paths.ts | 167 --- .../windows/src/platform/provider-factory.ts | 22 - runtime/windows/src/probe-cli.ts | 75 -- runtime/windows/src/probe-command.ts | 19 - runtime/windows/src/probe-evidence.ts | 58 - runtime/windows/src/probe-finalize.ts | 50 - runtime/windows/src/probe-phase-one.ts | 172 --- runtime/windows/src/probe-session.ts | 80 -- runtime/windows/src/session/model.ts | 70 - runtime/windows/src/session/state-machine.ts | 36 - runtime/windows/src/state.ts | 243 ---- runtime/windows/src/themes/ids.ts | 12 - .../src/themes/runtime-theme-repository.ts | 204 --- runtime/windows/src/types.ts | 75 -- runtime/windows/src/windows/command-runner.ts | 61 - .../windows/src/windows/powershell-path.ts | 9 - .../src/windows/powershell-provider.ts | 292 ----- .../windows/src/windows/powershell-script.ts | 436 ------- scripts/contracts/baseline-runner.ts | 689 +--------- scripts/contracts/baseline.ts | 4 +- scripts/contracts/build.ts | 10 +- scripts/dev/start-go-studio.ts | 66 + scripts/release/accept-go-release.ts | 13 + scripts/release/accept-go-spike.ts | 13 - scripts/release/accept-macos-distribution.ts | 47 - scripts/release/accept-portable.ts | 54 - scripts/release/accept-release.ts | 39 - scripts/release/accept-windows-installer.ps1 | 127 -- scripts/release/acceptance.ts | 522 -------- scripts/release/artifact-names.ts | 32 - ...{build-go-spike.ts => build-go-release.ts} | 10 +- scripts/release/build-macos-distribution.ts | 72 - scripts/release/build-windows-installer.ps1 | 50 - scripts/release/build-windows-local.ps1 | 196 --- scripts/release/checksums.ts | 24 + scripts/release/fetch-node-runtime.ts | 68 - .../release/{go-spike.ts => go-release.ts} | 364 ++++-- scripts/release/macos-acceptance.ts | 348 ----- scripts/release/macos-app.ts | 152 --- scripts/release/macos-dmg.ts | 209 --- scripts/release/macos-icon.ts | 54 + scripts/release/macos-metadata.ts | 103 -- scripts/release/manifest.ts | 72 + ...{merge-go-spike.ts => merge-go-release.ts} | 10 +- scripts/release/package-portable.ts | 83 -- scripts/release/package-release.ts | 27 - scripts/release/payload.ts | 578 -------- scripts/release/release-command.ts | 43 - scripts/release/release-files.ts | 31 - scripts/release/report.ts | 12 - scripts/release/stage-release.ts | 51 - scripts/release/windows-installer.iss | 74 -- scripts/release/write-checksums.ts | 2 +- tests/docs.test.ts | 318 +---- tests/go-cutover.test.ts | 52 + .../{go-spike.test.ts => go-release.test.ts} | 60 +- tests/helpers/release-payload-fixture.ts | 72 - tests/helpers/runtime-fixture.ts | 954 -------------- tests/macos-release.test.ts | 381 ------ tests/release-acceptance.test.ts | 510 -------- tests/runtime-acceptance.test.ts | 264 ---- tests/runtime-cli.test.ts | 279 ---- tests/runtime-control-pipe.test.ts | 246 ---- tests/runtime-control-protocol.test.ts | 120 -- tests/runtime-control-unix-socket.test.ts | 56 - tests/runtime-controller-integration.test.ts | 127 -- tests/runtime-controller-launch.test.ts | 89 -- tests/runtime-controller-lock.test.ts | 54 - tests/runtime-controller-theme.test.ts | 203 --- tests/runtime-discovery.test.ts | 241 ---- tests/runtime-launcher.test.ts | 625 --------- tests/runtime-logging.test.ts | 44 - tests/runtime-macos-provider.test.ts | 202 --- tests/runtime-page-session.test.ts | 94 -- tests/runtime-platform-paths.test.ts | 115 -- tests/runtime-probe-evidence.test.ts | 87 -- tests/runtime-probe-session.test.ts | 58 - tests/runtime-probe.test.ts | 382 ------ tests/runtime-recovery.test.ts | 432 ------ tests/runtime-renderer-lifecycle.test.ts | 107 -- tests/runtime-restore.test.ts | 160 --- tests/runtime-state.test.ts | 212 --- tests/runtime-theme-repository.test.ts | 164 --- tests/runtime-windows-provider.test.ts | 323 ----- tests/theme-studio-dev-host.test.ts | 182 --- tests/theme-studio-runtime-status.test.ts | 147 --- tests/theme-studio-service.test.ts | 224 ---- tests/theme-studio-session.test.ts | 29 - tests/theme-studio-workspace.test.ts | 661 ---------- tests/windows-local-release.test.ts | 45 - tests/workspace.test.ts | 35 +- tsconfig.json | 3 +- tsconfig.scripts.json | 4 +- vitest.config.ts | 9 +- 174 files changed, 1806 insertions(+), 23121 deletions(-) delete mode 100644 .github/workflows/go-host-spike.yml delete mode 100644 contracts/baseline/v0.2.0/go-spike-sizes.json create mode 100644 docs/releases/v0.3.0-alpha.1.md create mode 100644 packages/runtime-contract/package.json create mode 100644 packages/runtime-contract/src/index.ts rename {runtime/windows => packages/runtime-contract}/tsconfig.json (55%) create mode 100644 packages/theme-studio-core/src/persistence.ts delete mode 100644 runtime/theme-studio-service/package.json delete mode 100644 runtime/theme-studio-service/src/cli.ts delete mode 100644 runtime/theme-studio-service/src/http.ts delete mode 100644 runtime/theme-studio-service/src/index.ts delete mode 100644 runtime/theme-studio-service/src/production-host.ts delete mode 100644 runtime/theme-studio-service/src/runtime-status.ts delete mode 100644 runtime/theme-studio-service/src/server.ts delete mode 100644 runtime/theme-studio-service/src/session.ts delete mode 100644 runtime/theme-studio-service/src/vite-host.ts delete mode 100644 runtime/theme-studio-service/src/workspace.ts delete mode 100644 runtime/theme-studio-service/tsconfig.json delete mode 100644 runtime/windows/package.json delete mode 100644 runtime/windows/src/acceptance-cli.ts delete mode 100644 runtime/windows/src/acceptance-evidence.ts delete mode 100644 runtime/windows/src/acceptance-runner.ts delete mode 100644 runtime/windows/src/acceptance-sequence.ts delete mode 100644 runtime/windows/src/acceptance-session.ts delete mode 100644 runtime/windows/src/cli.ts delete mode 100644 runtime/windows/src/cli/arguments.ts delete mode 100644 runtime/windows/src/cli/run.ts delete mode 100644 runtime/windows/src/control/controller-lock.ts delete mode 100644 runtime/windows/src/control/dispatcher.ts delete mode 100644 runtime/windows/src/control/framing.ts delete mode 100644 runtime/windows/src/control/pipe-client.ts delete mode 100644 runtime/windows/src/control/pipe-host-script.ts delete mode 100644 runtime/windows/src/control/pipe-server.ts delete mode 100644 runtime/windows/src/control/protocol.ts delete mode 100644 runtime/windows/src/control/result.ts delete mode 100644 runtime/windows/src/control/secure-control-server.ts delete mode 100644 runtime/windows/src/control/unix-socket-server.ts delete mode 100644 runtime/windows/src/controller/exit-monitor.ts delete mode 100644 runtime/windows/src/controller/managed-session.ts delete mode 100644 runtime/windows/src/controller/page-session.ts delete mode 100644 runtime/windows/src/controller/production.ts delete mode 100644 runtime/windows/src/controller/recovery.ts delete mode 100644 runtime/windows/src/controller/renderer-lifecycle.ts delete mode 100644 runtime/windows/src/controller/runtime-controller.ts delete mode 100644 runtime/windows/src/controller/theme-engine.ts delete mode 100644 runtime/windows/src/discovery/discover.ts delete mode 100644 runtime/windows/src/discovery/trusted-cache.ts delete mode 100644 runtime/windows/src/errors.ts delete mode 100644 runtime/windows/src/index.ts delete mode 100644 runtime/windows/src/launcher/launcher.ts delete mode 100644 runtime/windows/src/launcher/port.ts delete mode 100644 runtime/windows/src/logging.ts delete mode 100644 runtime/windows/src/macos/identity.ts delete mode 100644 runtime/windows/src/macos/macos-provider.ts delete mode 100644 runtime/windows/src/paths.ts delete mode 100644 runtime/windows/src/platform/provider-factory.ts delete mode 100644 runtime/windows/src/probe-cli.ts delete mode 100644 runtime/windows/src/probe-command.ts delete mode 100644 runtime/windows/src/probe-evidence.ts delete mode 100644 runtime/windows/src/probe-finalize.ts delete mode 100644 runtime/windows/src/probe-phase-one.ts delete mode 100644 runtime/windows/src/probe-session.ts delete mode 100644 runtime/windows/src/session/model.ts delete mode 100644 runtime/windows/src/session/state-machine.ts delete mode 100644 runtime/windows/src/state.ts delete mode 100644 runtime/windows/src/themes/ids.ts delete mode 100644 runtime/windows/src/themes/runtime-theme-repository.ts delete mode 100644 runtime/windows/src/types.ts delete mode 100644 runtime/windows/src/windows/command-runner.ts delete mode 100644 runtime/windows/src/windows/powershell-path.ts delete mode 100644 runtime/windows/src/windows/powershell-provider.ts delete mode 100644 runtime/windows/src/windows/powershell-script.ts create mode 100644 scripts/dev/start-go-studio.ts create mode 100644 scripts/release/accept-go-release.ts delete mode 100644 scripts/release/accept-go-spike.ts delete mode 100644 scripts/release/accept-macos-distribution.ts delete mode 100644 scripts/release/accept-portable.ts delete mode 100644 scripts/release/accept-release.ts delete mode 100644 scripts/release/accept-windows-installer.ps1 delete mode 100644 scripts/release/acceptance.ts delete mode 100644 scripts/release/artifact-names.ts rename scripts/release/{build-go-spike.ts => build-go-release.ts} (61%) delete mode 100644 scripts/release/build-macos-distribution.ts delete mode 100644 scripts/release/build-windows-installer.ps1 delete mode 100644 scripts/release/build-windows-local.ps1 create mode 100644 scripts/release/checksums.ts delete mode 100644 scripts/release/fetch-node-runtime.ts rename scripts/release/{go-spike.ts => go-release.ts} (64%) delete mode 100644 scripts/release/macos-acceptance.ts delete mode 100644 scripts/release/macos-app.ts delete mode 100644 scripts/release/macos-dmg.ts create mode 100644 scripts/release/macos-icon.ts delete mode 100644 scripts/release/macos-metadata.ts create mode 100644 scripts/release/manifest.ts rename scripts/release/{merge-go-spike.ts => merge-go-release.ts} (52%) delete mode 100644 scripts/release/package-portable.ts delete mode 100644 scripts/release/package-release.ts delete mode 100644 scripts/release/payload.ts delete mode 100644 scripts/release/release-command.ts delete mode 100644 scripts/release/release-files.ts delete mode 100644 scripts/release/report.ts delete mode 100644 scripts/release/stage-release.ts delete mode 100644 scripts/release/windows-installer.iss create mode 100644 tests/go-cutover.test.ts rename tests/{go-spike.test.ts => go-release.test.ts} (64%) delete mode 100644 tests/helpers/release-payload-fixture.ts delete mode 100644 tests/helpers/runtime-fixture.ts delete mode 100644 tests/macos-release.test.ts delete mode 100644 tests/release-acceptance.test.ts delete mode 100644 tests/runtime-acceptance.test.ts delete mode 100644 tests/runtime-cli.test.ts delete mode 100644 tests/runtime-control-pipe.test.ts delete mode 100644 tests/runtime-control-protocol.test.ts delete mode 100644 tests/runtime-control-unix-socket.test.ts delete mode 100644 tests/runtime-controller-integration.test.ts delete mode 100644 tests/runtime-controller-launch.test.ts delete mode 100644 tests/runtime-controller-lock.test.ts delete mode 100644 tests/runtime-controller-theme.test.ts delete mode 100644 tests/runtime-discovery.test.ts delete mode 100644 tests/runtime-launcher.test.ts delete mode 100644 tests/runtime-logging.test.ts delete mode 100644 tests/runtime-macos-provider.test.ts delete mode 100644 tests/runtime-page-session.test.ts delete mode 100644 tests/runtime-platform-paths.test.ts delete mode 100644 tests/runtime-probe-evidence.test.ts delete mode 100644 tests/runtime-probe-session.test.ts delete mode 100644 tests/runtime-probe.test.ts delete mode 100644 tests/runtime-recovery.test.ts delete mode 100644 tests/runtime-renderer-lifecycle.test.ts delete mode 100644 tests/runtime-restore.test.ts delete mode 100644 tests/runtime-state.test.ts delete mode 100644 tests/runtime-theme-repository.test.ts delete mode 100644 tests/runtime-windows-provider.test.ts delete mode 100644 tests/theme-studio-dev-host.test.ts delete mode 100644 tests/theme-studio-runtime-status.test.ts delete mode 100644 tests/theme-studio-service.test.ts delete mode 100644 tests/theme-studio-session.test.ts delete mode 100644 tests/theme-studio-workspace.test.ts delete mode 100644 tests/windows-local-release.test.ts diff --git a/.github/workflows/go-host-spike.yml b/.github/workflows/go-host-spike.yml deleted file mode 100644 index 4996900..0000000 --- a/.github/workflows/go-host-spike.yml +++ /dev/null @@ -1,72 +0,0 @@ -name: Go Host Feasibility Spike - -on: - workflow_dispatch: - -permissions: - contents: read - -jobs: - native-spike: - strategy: - fail-fast: false - matrix: - include: - - id: windows-x64 - runner: windows-latest - - id: macos-arm64 - runner: macos-15 - - id: macos-x64 - runner: macos-15-intel - runs-on: ${{ matrix.runner }} - steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 - - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 - with: - node-version: "22.18.0" - cache: npm - - uses: actions/setup-go@0a12ed9d6a96ab950c8f026ed9f722fe0da7ef32 - with: - go-version: "1.25.5" - cache-dependency-path: host/go/go.sum - - run: npm ci - - run: npm run go:spike:test - - if: runner.os == 'Windows' - shell: pwsh - run: choco install innosetup --version 6.7.1 --allow-downgrade --no-progress --yes - - run: npm run go:spike:package -- --output "${{ runner.temp }}/go-spike" --native-only - - run: npm run go:spike:acceptance -- --output "${{ runner.temp }}/go-spike" - - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 - with: - name: go-host-spike-${{ matrix.id }} - path: ${{ runner.temp }}/go-spike/* - if-no-files-found: error - - combine-native-evidence: - needs: native-spike - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 - - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 - with: - node-version: "22.18.0" - cache: npm - - run: npm ci - - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 - with: - name: go-host-spike-windows-x64 - path: ${{ runner.temp }}/inputs/windows - - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 - with: - name: go-host-spike-macos-arm64 - path: ${{ runner.temp }}/inputs/macos-arm64 - - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 - with: - name: go-host-spike-macos-x64 - path: ${{ runner.temp }}/inputs/macos-x64 - - run: npm run go:spike:merge -- --input "${{ runner.temp }}/inputs/windows" --input "${{ runner.temp }}/inputs/macos-arm64" --input "${{ runner.temp }}/inputs/macos-x64" --output "${{ runner.temp }}/combined" - - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 - with: - name: go-host-spike-combined - path: ${{ runner.temp }}/combined/* - if-no-files-found: error diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9916f55..8af2c54 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,7 +11,7 @@ permissions: env: NODE_VERSION: "22.18.0" - INNO_SETUP_VERSION: "6.7.1" + GO_VERSION: "1.25.12" jobs: verify: @@ -24,100 +24,64 @@ jobs: with: node-version: ${{ env.NODE_VERSION }} cache: npm + - uses: actions/setup-go@0a12ed9d6a96ab950c8f026ed9f722fe0da7ef32 + with: + go-version: ${{ env.GO_VERSION }} + cache-dependency-path: host/go/go.sum - run: npm ci - if: github.event_name == 'workflow_dispatch' run: npm run release:version - if: github.event_name == 'push' run: npm run release:version -- --tag "$env:GITHUB_REF_NAME" - run: npm run verify - - run: npm run studio:build - windows-x64: + native-release: needs: verify - runs-on: windows-latest + strategy: + fail-fast: false + matrix: + include: + - id: windows-x64 + runner: windows-latest + - id: macos-arm64 + runner: macos-15 + - id: macos-x64 + runner: macos-15-intel + runs-on: ${{ matrix.runner }} steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + fetch-depth: 0 - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 with: node-version: ${{ env.NODE_VERSION }} cache: npm + - uses: actions/setup-go@0a12ed9d6a96ab950c8f026ed9f722fe0da7ef32 + with: + go-version: ${{ env.GO_VERSION }} + cache-dependency-path: host/go/go.sum - run: npm ci - - run: npm run build - - run: npm run studio:build - - name: Fetch official Node Runtime - shell: pwsh - run: | - $metadata = npm run --silent release:node -- --version $env:NODE_VERSION --platform win32 --arch x64 --output $env:RUNNER_TEMP | ConvertFrom-Json - 7z x $metadata.archivePath "-o$env:RUNNER_TEMP" -y - "NODE_ROOT=$env:RUNNER_TEMP\$($metadata.rootDirectory)" >> $env:GITHUB_ENV - - name: Stage and accept payload + - run: npm run go:test + - if: runner.os == 'Windows' shell: pwsh - run: | - $stage = "$env:RUNNER_TEMP\release\OpenChatGPTSkin" - $diagnostics = "$env:RUNNER_TEMP\release-diagnostics" - New-Item -ItemType Directory -Path $diagnostics -Force | Out-Null - npm run release:stage -- --output $stage --node-executable "$env:NODE_ROOT\node.exe" --node-license "$env:NODE_ROOT\LICENSE" --node-version $env:NODE_VERSION --build-commit $env:GITHUB_SHA --platform win32 --arch x64 - Copy-Item -LiteralPath "$stage\release-manifest.json" -Destination "$diagnostics\windows-release-manifest.json" - npm run release:acceptance -- --release-root $stage --scenario staged-payload --report "$diagnostics\windows-staged-acceptance.json" - "RELEASE_STAGE=$stage" >> $env:GITHUB_ENV - "RELEASE_DIAGNOSTICS=$diagnostics" >> $env:GITHUB_ENV - - name: Package and accept portable archive - shell: pwsh - run: | - $artifacts = "$env:RUNNER_TEMP\artifacts" - npm run release:package -- --release-root $env:RELEASE_STAGE --output $artifacts - $archive = (Get-ChildItem -LiteralPath $artifacts -Filter '*.zip' | Select-Object -First 1).FullName - "RELEASE_ARTIFACTS=$artifacts" >> $env:GITHUB_ENV - npm run release:acceptance:archive -- --archive $archive --report "$env:RELEASE_DIAGNOSTICS\windows-archive-acceptance.json" - - name: Build Windows installer - shell: pwsh - run: | - $releaseVersion = (Get-Content -Raw -LiteralPath package.json | ConvertFrom-Json).version - choco install innosetup --version $env:INNO_SETUP_VERSION --allow-downgrade --no-progress --yes - if ($LASTEXITCODE -ne 0) { - throw "Inno Setup installation failed with exit code $LASTEXITCODE" - } - & scripts/release/build-windows-installer.ps1 -ReleaseRoot $env:RELEASE_STAGE -OutputDirectory $env:RELEASE_ARTIFACTS -Version $releaseVersion - - name: Accept Windows installer lifecycle - shell: pwsh - run: | - $setup = (Get-ChildItem -LiteralPath $env:RELEASE_ARTIFACTS -Filter '*_Setup.exe' | Select-Object -First 1).FullName - & scripts/release/accept-windows-installer.ps1 -SetupPath $setup -InstallDirectory "$env:RUNNER_TEMP\installed\OpenChatGPTSkin" -DataDirectory "$env:LOCALAPPDATA\OpenChatGPTSkin" -ReportPath "$env:RELEASE_DIAGNOSTICS\windows-installer-acceptance.json" - npm run release:checksums -- --output $env:RELEASE_ARTIFACTS - Copy-Item -LiteralPath "$env:RELEASE_ARTIFACTS\checksums.txt" -Destination "$env:RELEASE_DIAGNOSTICS\windows-checksums.txt" - - name: Record Windows artifact sizes - if: ${{ always() }} - shell: pwsh - run: | - if ($env:RELEASE_DIAGNOSTICS -and $env:RELEASE_ARTIFACTS) { - $items = @(Get-ChildItem -LiteralPath $env:RELEASE_ARTIFACTS -File -ErrorAction SilentlyContinue | Sort-Object Name | ForEach-Object { [ordered]@{ name = $_.Name; bytes = $_.Length } }) - $items | ConvertTo-Json | Set-Content -LiteralPath "$env:RELEASE_DIAGNOSTICS\windows-artifacts.json" -Encoding utf8 - } - - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 - if: ${{ always() }} - with: - name: windows-release-diagnostics - path: ${{ runner.temp }}\release-diagnostics\* - if-no-files-found: warn + run: choco install innosetup --version 6.7.1 --allow-downgrade --no-progress --yes + - run: npm run release:build -- --native-only --output "${{ runner.temp }}/release" + - run: npm run release:acceptance -- --output "${{ runner.temp }}/release" - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 with: - name: windows-release + name: go-release-${{ matrix.id }} path: | - ${{ runner.temp }}\artifacts\OpenChatGPTSkin_*.zip - ${{ runner.temp }}\artifacts\OpenChatGPTSkin_*_Setup.exe + ${{ runner.temp }}/release/OpenChatGPTSkin_*.zip + ${{ runner.temp }}/release/OpenChatGPTSkin_*_Setup.exe + ${{ runner.temp }}/release/OpenChatGPTSkin_*.dmg + ${{ runner.temp }}/release/OpenChatGPTSkin_*.tar.gz + ${{ runner.temp }}/release/go-release-report.json + ${{ runner.temp }}/release/stages/** if-no-files-found: error - macos-release: - needs: verify - strategy: - fail-fast: false - matrix: - include: - - runner: macos-15-intel - arch: x64 - - runner: macos-15 - arch: arm64 - runs-on: ${{ matrix.runner }} + combine-native-release: + needs: native-release + runs-on: ubuntu-latest steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 @@ -125,57 +89,31 @@ jobs: node-version: ${{ env.NODE_VERSION }} cache: npm - run: npm ci - - run: npm run build - - run: npm run studio:build - - name: Fetch official Node Runtime - shell: bash - run: | - set -euo pipefail - metadata="$(npm run --silent release:node -- --version "$NODE_VERSION" --platform darwin --arch "${{ matrix.arch }}" --output "$RUNNER_TEMP")" - archive="$(node -e 'const value=JSON.parse(process.argv[1]); process.stdout.write(value.archivePath)' "$metadata")" - root="$(node -e 'const value=JSON.parse(process.argv[1]); process.stdout.write(value.rootDirectory)' "$metadata")" - tar -xzf "$archive" -C "$RUNNER_TEMP" - echo "NODE_ROOT=$RUNNER_TEMP/$root" >> "$GITHUB_ENV" - - name: Stage, accept, and package macOS distribution - shell: bash - run: | - set -euo pipefail - stage="$RUNNER_TEMP/release/OpenChatGPTSkin" - artifacts="$RUNNER_TEMP/artifacts" - diagnostics="$RUNNER_TEMP/release-diagnostics" - mkdir -p "$diagnostics" - npm run release:stage -- --output "$stage" --node-executable "$NODE_ROOT/bin/node" --node-license "$NODE_ROOT/LICENSE" --node-version "$NODE_VERSION" --build-commit "$GITHUB_SHA" --platform darwin --arch "${{ matrix.arch }}" - cp "$stage/release-manifest.json" "$diagnostics/macos-${{ matrix.arch }}-release-manifest.json" - npm run release:acceptance -- --release-root "$stage" --scenario staged-payload --report "$diagnostics/macos-${{ matrix.arch }}-staged-acceptance.json" - npm run release:package -- --release-root "$stage" --output "$artifacts" - archive="$(find "$artifacts" -maxdepth 1 -name '*.tar.gz' -print -quit)" - npm run release:acceptance:archive -- --archive "$archive" --report "$diagnostics/macos-${{ matrix.arch }}-archive-acceptance.json" - npm run release:macos -- --release-root "$stage" --output "$artifacts" --icon-source "$GITHUB_WORKSPACE/assets/branding/open-chatgpt-skin-icon.svg" - dmg="$(find "$artifacts" -maxdepth 1 -name '*.dmg' -print -quit)" - npm run release:acceptance:macos -- --dmg "$dmg" --report "$diagnostics/macos-${{ matrix.arch }}-dmg-acceptance.json" - npm run release:checksums -- --output "$artifacts" - cp "$artifacts/checksums.txt" "$diagnostics/macos-${{ matrix.arch }}-checksums.txt" - node -e 'const fs=require("fs");const path=process.argv[1];const output=process.argv[2];const values=fs.readdirSync(path).sort().map(name=>({name,bytes:fs.statSync(`${path}/${name}`).size}));fs.writeFileSync(output,JSON.stringify(values,null,2)+"\n")' "$artifacts" "$diagnostics/macos-${{ matrix.arch }}-artifacts.json" - echo "RELEASE_DIAGNOSTICS=$diagnostics" >> "$GITHUB_ENV" - - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 - if: ${{ always() }} + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 with: - name: macos-${{ matrix.arch }}-release-diagnostics - path: ${{ runner.temp }}/release-diagnostics/* - if-no-files-found: warn + name: go-release-windows-x64 + path: ${{ runner.temp }}/inputs/windows + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 + with: + name: go-release-macos-arm64 + path: ${{ runner.temp }}/inputs/macos-arm64 + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 + with: + name: go-release-macos-x64 + path: ${{ runner.temp }}/inputs/macos-x64 + - run: npm run release:merge -- --input "${{ runner.temp }}/inputs/windows" --input "${{ runner.temp }}/inputs/macos-arm64" --input "${{ runner.temp }}/inputs/macos-x64" --output "${{ runner.temp }}/combined" + - run: npm run release:checksums -- --output "${{ runner.temp }}/combined" - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 with: - name: macos-${{ matrix.arch }}-release + name: go-release-combined path: | - ${{ runner.temp }}/artifacts/OpenChatGPTSkin_*.dmg - ${{ runner.temp }}/artifacts/OpenChatGPTSkin_*.tar.gz + ${{ runner.temp }}/combined/OpenChatGPTSkin_* + ${{ runner.temp }}/combined/checksums.txt if-no-files-found: error publish: if: github.event_name == 'push' - needs: - - windows-x64 - - macos-release + needs: combine-native-release runs-on: ubuntu-latest permissions: contents: write @@ -183,16 +121,8 @@ jobs: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 with: - name: windows-release - path: downloads/windows - - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 - with: - name: macos-arm64-release - path: downloads/macos-arm64 - - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 - with: - name: macos-x64-release - path: downloads/macos-x64 + name: go-release-combined + path: artifacts - name: Publish GitHub Release shell: bash env: @@ -200,18 +130,7 @@ jobs: run: | set -euo pipefail version="${GITHUB_REF_NAME#v}" - mkdir artifacts - for directory in downloads/windows downloads/macos-arm64 downloads/macos-x64; do - test "$(find "$directory" -maxdepth 1 -type f -name 'OpenChatGPTSkin_*' | wc -l | tr -d ' ')" -eq 2 - for source in "$directory"/OpenChatGPTSkin_*; do - test -f "$source" - destination="artifacts/$(basename "$source")" - test ! -e "$destination" - cp "$source" "$destination" - done - done test "$(find artifacts -maxdepth 1 -type f -name 'OpenChatGPTSkin_*' | wc -l | tr -d ' ')" -eq 6 - (cd artifacts && sha256sum OpenChatGPTSkin_* > checksums.txt) (cd artifacts && sha256sum --check checksums.txt) notes="docs/releases/${GITHUB_REF_NAME}.md" test -f "$notes" diff --git a/README.en.md b/README.en.md index 4d5b717..62bee6c 100644 --- a/README.en.md +++ b/README.en.md @@ -2,9 +2,9 @@ [简体中文](README.md) · [English](README.en.md) -![Status](https://img.shields.io/badge/status-stable-2ea44f) -![Platform](https://img.shields.io/badge/release-Windows%20x64%20%7C%20macOS%20Preview-0078d4) -![Node.js](https://img.shields.io/badge/Node.js-22%20bundled-339933) +![Status](https://img.shields.io/badge/status-v0.3.0--alpha.1-f59e0b) +![Platform](https://img.shields.io/badge/release-Windows%20x64%20%7C%20macOS%20ARM64%20%7C%20x64-0078d4) +![Go](https://img.shields.io/badge/Go-1.25.12-00ADD8) ![TypeScript](https://img.shields.io/badge/TypeScript-5.9-3178c6) ![License](https://img.shields.io/badge/code%20%26%20docs-MIT-2563eb) [![LINUX DO Community](https://img.shields.io/badge/community-LINUX%20DO-f0b90b)](https://linux.do/) @@ -46,7 +46,7 @@ The two complete concept images below show how far OpenChatGPTSkin can be custom Complete Super Saiyan Goku OpenChatGPTSkin concept > [!IMPORTANT] -> `v0.2.0` is the current stable release, providing a **stable Windows x64 build**, an **unsigned macOS preview**, and five bundled Theme Schema v4 themes. Windows includes an x64 portable ZIP and per-user Setup; macOS includes separate Apple Silicon ARM64 and Intel x64 DMGs/portable archives. Every artifact bundles Node.js and requires neither Git nor development dependencies. macOS has not completed the real-ChatGPT visual loop, Developer ID signing, or notarization. Use the standard Control-click → **Open** flow below and do not disable Gatekeeper. Save your work and **fully quit the regular ChatGPT app** before applying or restoring a theme. OpenChatGPTSkin manages only the ChatGPT instance it launches and never modifies `WindowsApps`, `Codex.app`, `app.asar`, account settings, or API configuration. +> `v0.3.0-alpha.1` is the unified Go Host candidate for Windows x64, macOS ARM64, and macOS x64. Its six installer/portable artifacts include five Theme Schema v4 themes and one Go business host; no user package bundles Node.js or requires Git, Go, or development dependencies. The theme loop, data upgrade, and v0.2.0 rollback have passed real-device acceptance on Windows and both Mac architectures. macOS artifacts remain unsigned and unnotarized, so use the standard Control-click → **Open** flow without disabling Gatekeeper. Save your work and **fully quit the regular ChatGPT app** before applying a theme. OpenChatGPTSkin manages only the ChatGPT instance it launches and never modifies `WindowsApps`, `Codex.app`, `app.asar`, account settings, or API configuration. ## Contents @@ -81,7 +81,7 @@ Themes are data, not arbitrary code. An `.ocskin` package cannot contain JavaScr | Five ready-to-use built-in themes | Complete | | Windows Runtime launch/switch/pause/restore | Stable | | Windows x64 portable ZIP and per-user Setup | Stable | -| macOS ARM64/x64 DMG and Runtime launch/switch/restore | Unsigned preview; real-Mac acceptance pending | +| macOS ARM64/x64 DMG and Runtime launch/switch/restore | Unsigned preview; both architectures accepted on real Macs | | Theme Studio editing/preview/version/import/export/apply | Stable | | Codex plugin-market installation | Not available yet | | Automatic updates, SEA single-file executable, theme marketplace | Planned | @@ -168,7 +168,7 @@ Portrait and decoration assets carry separate authorization identifiers and sour ### Windows Setup (recommended) -1. Download `OpenChatGPTSkin_0.2.0_windows_x64_Setup.exe` and `checksums.txt` from [GitHub Release v0.2.0](https://github.com/u2bo/OpenChatGPTSkin/releases/tag/v0.2.0). +1. Download `OpenChatGPTSkin_0.3.0-alpha.1_windows_x64_Setup.exe` and `checksums.txt` from [GitHub Release v0.3.0-alpha.1](https://github.com/u2bo/OpenChatGPTSkin/releases/tag/v0.3.0-alpha.1). 2. Verify SHA-256, then run Setup. It installs for the current user under `%LOCALAPPDATA%\Programs\OpenChatGPTSkin` and does not request administrator privileges. 3. Start OpenChatGPTSkin from the Start menu. The production Theme Studio opens in your default browser only after its local health check succeeds. @@ -176,35 +176,35 @@ The installer is unsigned, so Windows SmartScreen may warn. Download only from t ### Windows portable ZIP -Download `OpenChatGPTSkin_0.2.0_windows_x64.zip`, verify it, extract it to a stable writable directory, and double-click `OpenChatGPTSkin.cmd`. The portable build does not register an installation and needs no global Node.js or Git. Personal themes remain under `%LOCALAPPDATA%\OpenChatGPTSkin`, outside the program directory. +Download `OpenChatGPTSkin_0.3.0-alpha.1_windows_x64.zip`, verify it, extract it to a stable writable directory, and double-click `OpenChatGPTSkin.exe`. The portable build does not register an installation and needs no global Node.js, Go, or Git. Personal themes remain under `%LOCALAPPDATA%\OpenChatGPTSkin`, outside the program directory. ### macOS DMG (unsigned developer preview) -1. On Apple Silicon (M-series), download `OpenChatGPTSkin_0.2.0_macos_arm64.dmg`. On an Intel Mac, download `OpenChatGPTSkin_0.2.0_macos_x64.dmg`. Intel x64 compatibility depends on an official ChatGPT build for that architecture and has not completed real-device validation. +1. On Apple Silicon (M-series), download `OpenChatGPTSkin_0.3.0-alpha.1_macos_arm64.dmg`. On an Intel Mac, download `OpenChatGPTSkin_0.3.0-alpha.1_macos_x64.dmg`. Both targets have passed acceptance on matching real hardware and the official ChatGPT app. 2. Verify SHA-256 as shown below, open the DMG, and drag `OpenChatGPTSkin.app` to Applications. 3. On first launch, Control-click the app, choose **Open**, and confirm the standard macOS prompt. Do not disable Gatekeeper or use `xattr` to remove quarantine metadata. 4. Theme Studio opens in the default browser after its health check succeeds. Replacing or deleting the `.app` keeps personal themes, drafts, and Runtime state under `~/Library/Application Support/OpenChatGPTSkin`. -Developers can also download `OpenChatGPTSkin_0.2.0_macos_arm64.tar.gz` or `OpenChatGPTSkin_0.2.0_macos_x64.tar.gz`. Most users should choose the DMG. +Developers can also download `OpenChatGPTSkin_0.3.0-alpha.1_macos_arm64.tar.gz` or `OpenChatGPTSkin_0.3.0-alpha.1_macos_x64.tar.gz`. Each archive contains the complete `OpenChatGPTSkin.app`; most users should choose the DMG. -Maintainers can open **Actions → Build and Release → Run workflow** and manually trigger `workflow_dispatch`. GitHub-hosted Windows, ARM64 macOS, and Intel macOS runners build Windows x64, macOS ARM64, and macOS x64 test artifacts. Download `windows-release`, `macos-arm64-release`, `macos-x64-release`, and their diagnostics from the completed run. A manual run never creates a tag or GitHub Release. +Maintainers can open **Actions → Build and Release → Run workflow** and manually trigger `workflow_dispatch`. Three native runners build and accept the Go Host, then merge the result as `go-release-combined`. A manual run never creates a tag or GitHub Release. ### Verify downloads Run from the download directory: ```powershell -Get-FileHash .\OpenChatGPTSkin_0.2.0_windows_x64.zip -Algorithm SHA256 -Get-FileHash .\OpenChatGPTSkin_0.2.0_windows_x64_Setup.exe -Algorithm SHA256 +Get-FileHash .\OpenChatGPTSkin_0.3.0-alpha.1_windows_x64.zip -Algorithm SHA256 +Get-FileHash .\OpenChatGPTSkin_0.3.0-alpha.1_windows_x64_Setup.exe -Algorithm SHA256 Get-Content .\checksums.txt ``` macOS Terminal: ```bash -shasum -a 256 OpenChatGPTSkin_0.2.0_macos_arm64.dmg +shasum -a 256 OpenChatGPTSkin_0.3.0-alpha.1_macos_arm64.dmg # On Intel: -shasum -a 256 OpenChatGPTSkin_0.2.0_macos_x64.dmg +shasum -a 256 OpenChatGPTSkin_0.3.0-alpha.1_macos_x64.dmg cat checksums.txt ``` @@ -212,7 +212,7 @@ Each hash must exactly match the corresponding line in `checksums.txt`. Do not r ### Install from source -Source development requires Windows 11 or macOS, official Codex Desktop, Node.js `>= 22.0.0`, and npm. Git can be replaced with a downloaded source archive. +Source development requires Windows 11 or macOS, official Codex Desktop, Go `1.25.12`, Node.js `>= 22.0.0`, and npm. Node is used only to build the frontend, contracts, and CDP Adapter; it is not shipped to users. Clone or download the repository from GitHub, then run from its root: @@ -233,7 +233,7 @@ Windows developers can generate the same portable ZIP, per-user Setup, and SHA-2 npm run release:windows ``` -The first run only requires Node.js 22, npm, and Git. If Inno Setup 6 is missing, the script downloads a pinned version from the official GitHub Release, verifies its pinned SHA-256, and uses it in portable mode from the temporary build directory without administrator access or a permanent installation. It validates and reuses complete existing dependencies, incrementally repairs missing or inconsistent dependencies from `package-lock.json`, then runs the full verification and Theme Studio build, fetches the official bundled Node.js Runtime, validates the staged payload, and packages the release. Final files are written to `artifacts/windows-x64/`. Failed builds retain and print their temporary directory for diagnosis; successful builds clean it automatically. The command intentionally skips the CI installer lifecycle test that manipulates local installation registration and the personal data directory. +Local release builds require Go `1.25.12`, Node.js 22, npm, and Inno Setup 6. The command builds Theme Studio and the single Go Host, then produces a Node-free stage, ZIP, Setup, and SHA-256 file under `artifacts/windows-x64/`. End users need none of these development tools. When upgrading from the pre-rename development build, the first CLI or Theme Studio start atomically adopts the previous personal themes, drafts, and Runtime state only when the new-brand data directory does not exist. If both directories exist, the new directory wins and neither side is merged or overwritten. @@ -244,7 +244,7 @@ Installing a newer Setup, replacing a portable directory, or replacing the macOS ### Theme Studio (recommended) 1. Save your work and choose **Quit Codex** from the Codex menu or system tray. Make sure the regular app is fully closed. -2. Windows Setup users launch from the Start menu, portable users double-click `OpenChatGPTSkin.cmd`, macOS users launch `OpenChatGPTSkin.app` from Applications, and source users run: +2. Windows Setup users launch from the Start menu, portable users double-click `OpenChatGPTSkin.exe`, macOS users launch `OpenChatGPTSkin.app` from Applications, and source users run: ```powershell npm run studio:dev @@ -309,29 +309,20 @@ npm run runtime -- restore - `restore` restores the official appearance and waits for a normal managed-Codex exit to finish cleanup. - Do not use Task Manager to force-close Codex while restore is pending. -See [Windows Runtime and Compatibility](docs/runtime-windows.md) and [macOS Runtime and Acceptance](docs/runtime-macos.en.md) for the platform safety boundaries. macOS DMGs complete package, bundled-Runtime, Theme Studio, and four-theme acceptance on native ARM64/x64 runners. Real-Codex Runtime probes and visual-loop acceptance still require the manual real-device checklist. +See [Windows Runtime and Compatibility](docs/runtime-windows.md) and [macOS Runtime and Acceptance](docs/runtime-macos.en.md) for the platform safety boundaries. The three native runners verify package structure, the single Go Host, Theme Studio, all five built-ins, and the Node-free manifest. Real-Codex visual and lifecycle acceptance still follows the manual checklist on each target platform. -### Compatibility probe and real-app acceptance (Windows) +### Real-app acceptance after a Codex update -After a Codex update, fully quit the regular app and run the two-phase compatibility probe: +The old Node Host commands `runtime:probe` and `runtime:acceptance` were removed during the Go cutover and are no longer executable entry points. After a Codex update, use a test workspace with no private projects or sensitive chats and: -```powershell -npm run runtime:probe -- --record-evidence -# Quit the managed Codex instance normally from the Codex menu -npm run runtime:probe -- --finalize -``` - -For a release candidate, run the full Runtime acceptance flow: - -```powershell -npm run runtime:acceptance -- --begin -# Quit managed Codex normally, then start official Codex normally from Start -npm run runtime:acceptance -- --finalize -``` +1. Fully quit regular Codex and check all five built-ins, a custom theme, `pause`, `resume`, and `restore`. +2. Quit the managed instance from the Codex menu and verify Controller/control-endpoint cleanup. +3. Start official Codex normally and verify that it has no inherited remote-debugging flags and keeps the official appearance. +4. Record Codex/OpenChatGPTSkin/OS versions, results, and sanitized screenshots. -Acceptance evidence must remain sanitized and must not contain PIDs, ports, paths, command lines, project names, chat content, or screenshots. +Published evidence must not contain PIDs, ports, usernames, absolute paths, command lines, project names, or chat content. -macOS packages receive automated acceptance on native CI runners; the real-Codex visual and Runtime loop still uses a manual checklist. On a real Mac, verify `codesign`, `spctl`, Unix-socket permissions, all five built-in themes, restore, and a regular Codex restart as described in [macOS Runtime and Acceptance](docs/runtime-macos.en.md). +See the full [Windows checklist](docs/runtime-windows.md) and [macOS checklist](docs/runtime-macos.en.md). ## FAQ @@ -379,8 +370,7 @@ packages/theme-schema/ Theme Schema v4, migrations, and visual model packages/theme-core/ Validation, catalog, archive, storage packages/cdp-adapter/ Codex UI surface recognition and compilation packages/theme-studio-core/ Theme Studio contracts and validation -runtime/windows/ Desktop Runtime, Controller, recovery (historical package path) -runtime/theme-studio-service/ Local Theme Studio service +host/go/ Single Go Studio, Controller, Runtime, and platform adapters themes/builtin/ Built-in themes and asset provenance tests/ Schema, Runtime, UI, and documentation tests ``` @@ -400,7 +390,8 @@ UI adaptation changes must include deterministic page fixtures/tests. Theme cont ## Documentation -- [v0.2.0 Release Notes](docs/releases/v0.2.0.md) +- [v0.3.0-alpha.1 Release Notes](docs/releases/v0.3.0-alpha.1.md) +- [v0.2.0 Historical Release Notes](docs/releases/v0.2.0.md) - [v0.1.0 Historical Release Notes](docs/releases/v0.1.0.md) - [v0.1.0-alpha.1 Historical Release Notes](docs/releases/v0.1.0-alpha.1.md) - [Custom Theme Guide](docs/custom-theme-guide.en.md) diff --git a/README.md b/README.md index 5f4fdc1..575982d 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,9 @@ [简体中文](README.md) · [English](README.en.md) -![Status](https://img.shields.io/badge/status-stable-2ea44f) -![Platform](https://img.shields.io/badge/release-Windows%20x64%20%7C%20macOS%20Preview-0078d4) -![Node.js](https://img.shields.io/badge/Node.js-22%20bundled-339933) +![Status](https://img.shields.io/badge/status-v0.3.0--alpha.1-f59e0b) +![Platform](https://img.shields.io/badge/release-Windows%20x64%20%7C%20macOS%20ARM64%20%7C%20x64-0078d4) +![Go](https://img.shields.io/badge/Go-1.25.12-00ADD8) ![TypeScript](https://img.shields.io/badge/TypeScript-5.9-3178c6) ![License](https://img.shields.io/badge/code%20%26%20docs-MIT-2563eb) [![LINUX DO 社区](https://img.shields.io/badge/community-LINUX%20DO-f0b90b)](https://linux.do/) @@ -46,7 +46,7 @@ 赛亚人孙悟空 OpenChatGPTSkin 完整主题概念图 > [!IMPORTANT] -> `v0.2.0` 是当前正式版本,提供 **Windows x64 正式版**和 **macOS 未签名预览版**,并内置五个 Theme Schema v4 主题。Windows 包含 x64 便携 ZIP 与用户级 Setup;macOS 分别提供 Apple Silicon ARM64 和 Intel x64 DMG/便携包。所有产物都内置 Node.js,无需安装 Git 或开发依赖。macOS 尚未完成真实 ChatGPT 视觉闭环、Developer ID 签名和公证;请按下文通过系统标准“右键 → 打开”确认,不要关闭 Gatekeeper。应用或恢复主题前,请保存工作并**完全退出普通 ChatGPT**。OpenChatGPTSkin 只管理自己启动的 ChatGPT 实例,不会强制结束已有 ChatGPT,也不会修改 `WindowsApps`、`Codex.app`、`app.asar`、账号或 API 配置。 +> `v0.3.0-alpha.1` 是统一 Go Host 候选版本,提供 Windows x64、macOS ARM64 与 macOS x64 六类安装/便携产物,并内置五个 Theme Schema v4 主题。用户包只包含一个 Go 业务宿主,不捆绑 Node.js,也不要求安装 Git、Go 或开发依赖。Windows 与两种 Mac 架构的主题闭环、数据升级和 v0.2.0 回滚均已完成实机验收。macOS 产物仍未使用 Developer ID 正式签名或公证;请按下文通过系统标准“右键 → 打开”确认,不要关闭 Gatekeeper。应用主题前,请保存工作并**完全退出普通 ChatGPT**。OpenChatGPTSkin 只管理自己启动的 ChatGPT 实例,不会强制结束已有 ChatGPT,也不会修改 `WindowsApps`、`Codex.app`、`app.asar`、账号或 API 配置。 ## 目录 @@ -81,7 +81,7 @@ OpenChatGPTSkin 由三个相互约束的部分组成: | 五个可直接使用的内置主题 | 已完成 | | Windows Runtime 启动、切换、暂停、恢复 | 正式版 | | Windows x64 便携 ZIP 与用户级 Setup | 正式版 | -| macOS ARM64/x64 DMG、Runtime 启动/切换/恢复 | 未签名预览,实机验收待完成 | +| macOS ARM64/x64 DMG、Runtime 启动/切换/恢复 | 未签名预览,双架构实机验收通过 | | Theme Studio 编辑、预览、版本、导入导出、应用 | 正式版 | | Codex 插件市场安装 | 尚未提供 | | 自动更新、SEA 单文件程序、主题市场 | 规划中 | @@ -168,7 +168,7 @@ OpenChatGPTSkin 的目标不是在首页覆盖一张背景图。Runtime 使用 ### Windows Setup(推荐) -1. 在 [GitHub Releases](https://github.com/u2bo/OpenChatGPTSkin/releases/tag/v0.2.0) 下载 `OpenChatGPTSkin_0.2.0_windows_x64_Setup.exe` 和 `checksums.txt`。 +1. 在 [GitHub Releases](https://github.com/u2bo/OpenChatGPTSkin/releases/tag/v0.3.0-alpha.1) 下载 `OpenChatGPTSkin_0.3.0-alpha.1_windows_x64_Setup.exe` 和 `checksums.txt`。 2. 校验 SHA-256 后双击 Setup。安装范围为当前用户,默认目录是 `%LOCALAPPDATA%\Programs\OpenChatGPTSkin`,不请求管理员权限。 3. 从开始菜单启动 OpenChatGPTSkin;生产 Theme Studio 健康启动后会自动打开默认浏览器。 @@ -176,35 +176,35 @@ OpenChatGPTSkin 的目标不是在首页覆盖一张背景图。Runtime 使用 ### Windows 便携 ZIP -下载 `OpenChatGPTSkin_0.2.0_windows_x64.zip`,校验后解压到可写且稳定的目录,双击 `OpenChatGPTSkin.cmd`。便携版不会注册安装信息,也不依赖全局 Node.js 或 Git;个人主题仍写入 `%LOCALAPPDATA%\OpenChatGPTSkin`,不会写入程序目录。 +下载 `OpenChatGPTSkin_0.3.0-alpha.1_windows_x64.zip`,校验后解压到可写且稳定的目录,双击 `OpenChatGPTSkin.exe`。便携版不会注册安装信息,也不依赖全局 Node.js、Go 或 Git;个人主题仍写入 `%LOCALAPPDATA%\OpenChatGPTSkin`,不会写入程序目录。 ### macOS DMG(未签名开发者预览) -1. Apple Silicon(M 系列)下载 `OpenChatGPTSkin_0.2.0_macos_arm64.dmg`;Intel Mac 下载 `OpenChatGPTSkin_0.2.0_macos_x64.dmg`。Intel x64 兼容性取决于官方 ChatGPT 是否提供对应架构版本,目前尚未完成实机验证。 +1. Apple Silicon(M 系列)下载 `OpenChatGPTSkin_0.3.0-alpha.1_macos_arm64.dmg`;Intel Mac 下载 `OpenChatGPTSkin_0.3.0-alpha.1_macos_x64.dmg`。两个架构均已在对应真实设备与官方 ChatGPT 上完成验收。 2. 先按下方命令核对 SHA-256,再打开 DMG,将 `OpenChatGPTSkin.app` 拖入 Applications。 3. 首次启动时按住 Control 点击或右键点击应用,选择“打开”,再确认 macOS 标准提示。不要关闭 Gatekeeper,也不要使用 `xattr` 移除隔离属性。 4. Theme Studio 健康启动后会自动打开默认浏览器。替换或删除 `.app` 不会删除 `~/Library/Application Support/OpenChatGPTSkin` 下的个人主题、草稿和 Runtime 状态。 -开发者还可以下载同架构的 `OpenChatGPTSkin_0.2.0_macos_arm64.tar.gz` 或 `OpenChatGPTSkin_0.2.0_macos_x64.tar.gz`。普通用户优先使用 DMG。 +开发者还可以下载同架构的 `OpenChatGPTSkin_0.3.0-alpha.1_macos_arm64.tar.gz` 或 `OpenChatGPTSkin_0.3.0-alpha.1_macos_x64.tar.gz`。压缩包内同样是完整 `OpenChatGPTSkin.app`;普通用户优先使用 DMG。 -维护者可以进入仓库 **Actions → Build and Release → Run workflow** 手动触发 `workflow_dispatch`。GitHub 托管的 Windows、ARM64 macOS 和 Intel macOS Runner 会构建 Windows x64、macOS ARM64 和 macOS x64 测试产物;完成后下载 `windows-release`、`macos-arm64-release`、`macos-x64-release` 及对应 diagnostics。手动运行不会创建 Tag 或 GitHub Release。 +维护者可以进入仓库 **Actions → Build and Release → Run workflow** 手动触发 `workflow_dispatch`。三个原生 Runner 会分别构建并验收 Go Host,随后合并为 `go-release-combined`;手动运行不会创建 Tag 或 GitHub Release。 ### 校验下载文件 在下载目录运行: ```powershell -Get-FileHash .\OpenChatGPTSkin_0.2.0_windows_x64.zip -Algorithm SHA256 -Get-FileHash .\OpenChatGPTSkin_0.2.0_windows_x64_Setup.exe -Algorithm SHA256 +Get-FileHash .\OpenChatGPTSkin_0.3.0-alpha.1_windows_x64.zip -Algorithm SHA256 +Get-FileHash .\OpenChatGPTSkin_0.3.0-alpha.1_windows_x64_Setup.exe -Algorithm SHA256 Get-Content .\checksums.txt ``` macOS 终端: ```bash -shasum -a 256 OpenChatGPTSkin_0.2.0_macos_arm64.dmg +shasum -a 256 OpenChatGPTSkin_0.3.0-alpha.1_macos_arm64.dmg # Intel Mac 使用: -shasum -a 256 OpenChatGPTSkin_0.2.0_macos_x64.dmg +shasum -a 256 OpenChatGPTSkin_0.3.0-alpha.1_macos_x64.dmg cat checksums.txt ``` @@ -212,7 +212,7 @@ cat checksums.txt ### 从源码安装 -源码开发需要 Windows 11 或 macOS、官方 Codex Desktop、Node.js `>= 22.0.0` 和 npm;Git 可由源码压缩包替代。 +源码开发需要 Windows 11 或 macOS、官方 Codex Desktop、Go `1.25.12`、Node.js `>= 22.0.0` 和 npm;Node 只用于前端、Contract 与 CDP Adapter 构建,不进入用户发布包。 从 GitHub 页面克隆或下载仓库,然后在仓库根目录运行: @@ -233,7 +233,7 @@ Windows 开发者可以在仓库根目录用一条命令生成与 CI 相同结 npm run release:windows ``` -首次运行前只需要 Node.js 22、npm 与 Git;如果本机没有 Inno Setup 6,脚本会从官方 GitHub Release 下载固定版本、校验固定 SHA-256,并在临时目录中以便携模式使用,不需要管理员权限或永久安装。随后脚本会校验并复用完整的现有依赖,仅在依赖缺失或不一致时依据 `package-lock.json` 增量修复,再完成完整验证、Theme Studio 构建、官方 Node.js Runtime 下载、发布载荷验收和打包。最终产物位于 `artifacts/windows-x64/`。如果构建失败,临时目录会保留并打印路径用于排障;成功后会自动清理临时文件。该命令不会运行会触碰本机安装注册与个人数据目录的 CI 安装生命周期测试。 +本地构建需要 Go `1.25.12`、Node.js 22、npm 和 Inno Setup 6。命令会构建 Theme Studio 与单一 Go Host,生成 Node-free Stage、ZIP、Setup 和 SHA-256;最终产物位于 `artifacts/windows-x64/`。用户不需要安装这些开发工具。 从重命名前的开发版本升级时,首次启动 CLI 或 Theme Studio 会在新品牌数据目录不存在的前提下,原子迁移上一版本的个人主题、草稿和 Runtime 状态。若新旧目录同时存在,新目录优先,程序不会自动合并或覆盖任何一边。 @@ -244,7 +244,7 @@ npm run release:windows ### 使用 Theme Studio(推荐) 1. 保存正在进行的工作,通过 Codex 菜单或系统托盘执行“退出 / Quit Codex”,确认普通 Codex 已完全退出。 -2. Windows Setup 用户从开始菜单启动,便携版双击 `OpenChatGPTSkin.cmd`;macOS 用户从“应用程序”启动 `OpenChatGPTSkin.app`;源码用户运行: +2. Windows Setup 用户从开始菜单启动,便携版双击 `OpenChatGPTSkin.exe`;macOS 用户从“应用程序”启动 `OpenChatGPTSkin.app`;源码用户运行: ```powershell npm run studio:dev @@ -309,29 +309,20 @@ npm run runtime -- restore - `restore`:恢复官方外观,并等待用户正常退出受管理 Codex 完成清理; - 不要使用任务管理器强制结束恢复中的 Codex。 -完整安全边界见 [Windows Runtime 说明](docs/runtime-windows.md) 与 [macOS Runtime 说明](docs/runtime-macos.md)。macOS DMG 会在原生 ARM64/x64 Runner 完成包结构、内置 Runtime、Theme Studio 和四主题自动验收;真实 Codex 的 Runtime Probe 与视觉闭环仍须按 macOS 文档在真实设备手动完成。 +完整安全边界见 [Windows Runtime 说明](docs/runtime-windows.md) 与 [macOS Runtime 说明](docs/runtime-macos.md)。三个原生 Runner 会验证包结构、单一 Go Host、Theme Studio、五个内置主题及 Node-free manifest;真实 Codex 的视觉和生命周期闭环仍按对应平台文档在真实设备手动验收。 -### 兼容性 Probe 与真实验收(Windows) +### Codex 更新后的真实验收 -Codex 升级后,先在完全退出普通 Codex 的前提下运行两阶段兼容性 Probe: +旧 Node Host 的 `runtime:probe` 与 `runtime:acceptance` 已随 Go cutover 删除,不再作为可执行入口。Codex 升级后,在无私人项目或敏感聊天的测试工作区完成以下检查: -```powershell -npm run runtime:probe -- --record-evidence -# 使用 Codex 的“退出 / Quit Codex”正常退出受管理实例 -npm run runtime:probe -- --finalize -``` - -发布候选版本可以进一步运行完整 Runtime 验收: - -```powershell -npm run runtime:acceptance -- --begin -# 正常退出受管理 Codex,并从开始菜单正常启动官方 Codex -npm run runtime:acceptance -- --finalize -``` +1. 完全退出普通 Codex,依次检查五个内置主题、自定义主题、`pause`、`resume` 与 `restore`; +2. 从 Codex 菜单正常退出受管理实例,确认 Controller 和本地控制端点完成清理; +3. 正常启动官方 Codex,确认未继承远程调试参数且保持官方外观; +4. 记录 Codex/OpenChatGPTSkin/系统版本、结果和脱敏截图。 -验收证据必须脱敏,不得包含 PID、端口、路径、命令行、项目名、聊天内容或截图。 +公开的验收记录不得包含 PID、端口、用户名、绝对路径、命令行、项目名或聊天内容。 -macOS 安装包会在原生 CI 中完成自动验收;真实 Codex 的视觉与 Runtime 闭环仍使用手动清单。请在真实 Mac 上核对 `codesign`、`spctl`、Unix socket 权限、五个内置主题、恢复流程和普通 Codex 重启,详见 [macOS Runtime 说明](docs/runtime-macos.md)。 +Windows 与 macOS 的完整检查项分别见 [Windows Runtime 说明](docs/runtime-windows.md) 和 [macOS Runtime 说明](docs/runtime-macos.md)。 ## 常见问题 @@ -379,8 +370,7 @@ packages/theme-schema/ Theme Schema v4、迁移与视觉模型 packages/theme-core/ 校验、目录、打包、存储 packages/cdp-adapter/ Codex UI surface 识别与主题编译 packages/theme-studio-core/ Theme Studio 合约与校验 -runtime/windows/ Desktop Runtime、Controller、恢复(保留历史包路径) -runtime/theme-studio-service/ 本地 Theme Studio 服务 +host/go/ 单一 Go Studio、Controller、Runtime 与平台适配 themes/builtin/ 五个内置主题及素材来源记录 tests/ Schema、Runtime、UI 和文档测试 ``` @@ -400,7 +390,8 @@ npm run build ## 更多文档 -- [v0.2.0 发布说明](docs/releases/v0.2.0.md) +- [v0.3.0-alpha.1 发布说明](docs/releases/v0.3.0-alpha.1.md) +- [v0.2.0 历史发布说明](docs/releases/v0.2.0.md) - [v0.1.0 历史发布说明](docs/releases/v0.1.0.md) - [v0.1.0-alpha.1 历史发布说明](docs/releases/v0.1.0-alpha.1.md) - [自定义主题指南](docs/custom-theme-guide.md) diff --git a/apps/theme-studio/package.json b/apps/theme-studio/package.json index 29738b9..e7baa45 100644 --- a/apps/theme-studio/package.json +++ b/apps/theme-studio/package.json @@ -1,6 +1,6 @@ { "name": "@open-chatgpt-skin/theme-studio", - "version": "0.2.0", + "version": "0.3.0-alpha.1", "private": true, "type": "module", "scripts": { @@ -8,8 +8,8 @@ "typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.node.json --noEmit" }, "dependencies": { - "@open-chatgpt-skin/theme-schema": "0.2.0", - "@open-chatgpt-skin/theme-studio-core": "0.2.0", + "@open-chatgpt-skin/theme-schema": "0.3.0-alpha.1", + "@open-chatgpt-skin/theme-studio-core": "0.3.0-alpha.1", "@phosphor-icons/react": "^2.1.10", "react": "19.0.0", "react-dom": "19.0.0" diff --git a/contracts/baseline/v0.2.0/go-spike-sizes.json b/contracts/baseline/v0.2.0/go-spike-sizes.json deleted file mode 100644 index 35d0cde..0000000 --- a/contracts/baseline/v0.2.0/go-spike-sizes.json +++ /dev/null @@ -1,115 +0,0 @@ -{ - "schemaVersion": 1, - "candidateVersion": "0.3.0-alpha.1", - "measuredAt": "2026-07-24", - "toolchain": { - "go": "1.25.5", - "cgo": false, - "buildTags": ["nodynamic"], - "sidecars": [] - }, - "imageDecision": { - "selected": "gen2brain-webp-wasm2go-nodynamic-plus-internal-pipeline", - "capabilities": [ - "jpeg-png-webp-decode", - "exif-orientation", - "alpha-preservation", - "center-crop", - "catmull-rom-resize", - "webp-encode", - "bounded-input", - "atomic-output" - ], - "candidates": [ - { - "id": "gen2brain-webp-codec-only", - "kind": "libwebp-only", - "license": "MIT", - "cgo": false, - "sidecars": false, - "decision": "rejected", - "reason": "codec supports WebP decode/encode but has no crop, resize, input policy, or atomic file pipeline" - }, - { - "id": "bimg-libvips", - "kind": "complete-native-pipeline", - "license": "MIT plus LGPL-2.1-or-later libvips", - "cgo": true, - "sidecars": true, - "decision": "rejected", - "reason": "requires cross-platform CGO and libvips distribution, recreating the current native sidecar cost" - }, - { - "id": "gen2brain-webp-wasm2go-nodynamic-plus-internal-pipeline", - "kind": "complete-cgo-free-pipeline", - "license": "MIT plus BSD-3-Clause golang.org/x/image", - "cgo": false, - "sidecars": false, - "decision": "selected", - "reason": "complete reviewed corpus with deterministic CGo-free three-target builds" - } - ] - }, - "targets": [ - { - "target": "windows-x64", - "executableBytes": 6620672, - "stageBytes": 11548401, - "baselineStageBytes": 115070815 - }, - { - "target": "macos-arm64", - "executableBytes": 5968498, - "stageBytes": 10896227, - "baselineStageBytes": 136974297 - }, - { - "target": "macos-x64", - "executableBytes": 6405696, - "stageBytes": 11333423, - "baselineStageBytes": 141958550 - } - ], - "artifacts": [ - { - "kind": "windows-x64-zip", - "bytes": 7340127, - "sha256": "2716bae351a1c095243015d8d9ba673d514b9b689ca0127fe60552e1a5afa195", - "baselineBytes": 44744668, - "nativeVerified": true - }, - { - "kind": "windows-x64-setup", - "bytes": 8612015, - "sha256": "287c1482ea71abfe6359e9a7137b418593dfe5f85c5bf47b4909e5954aac9db4", - "baselineBytes": 34083496, - "nativeVerified": true - }, - { - "kind": "macos-arm64-tar.gz", - "bytes": 6945173, - "sha256": "0c5072a99ea9e5edf71a0926a3d2a9a3f08617297e69e718edaec55ef66d6f42", - "baselineBytes": 48772737, - "nativeVerified": false - }, - { - "kind": "macos-x64-tar.gz", - "bytes": 7150596, - "sha256": "9f43d9e80234e26a7713631ee24e4abad7f494a1ec8cddc199e64e2962fa5a74", - "baselineBytes": 51253278, - "nativeVerified": false - } - ], - "security": { - "scanner": "govulncheck", - "reachableThirdPartyVulnerabilities": 0, - "reachableStandardLibraryVulnerabilities": 10, - "blockingRemediation": "upgrade Go toolchain from 1.25.5 to at least 1.25.12 and rerun" - }, - "nativeEvidenceComplete": false, - "blockingEvidence": [ - "macOS ARM64 Unix socket round-trip and DMG require a native macOS run", - "macOS x64 Unix socket round-trip and DMG require a native macOS run", - "Go 1.25.5 standard library vulnerability findings require toolchain upgrade" - ] -} diff --git a/contracts/data/v1/cases/compatibility.json b/contracts/data/v1/cases/compatibility.json index cb194c1..c4a84d0 100644 --- a/contracts/data/v1/cases/compatibility.json +++ b/contracts/data/v1/cases/compatibility.json @@ -31,10 +31,10 @@ "version": 1 }, { - "name": "release manifest v1", + "name": "release manifest v2", "schema": "releaseManifest", "valid": true, - "version": 1 + "version": 2 }, { "expectedErrorCode": "DATA_SCHEMA_INVALID", @@ -73,7 +73,7 @@ }, { "expectedErrorCode": "RELEASE_MANIFEST_INVALID", - "expectedPath": "/files/sha256", + "expectedPath": "/files/0/sha256", "name": "release file hash malformed", "schema": "releaseManifest", "valid": false diff --git a/contracts/data/v1/schemas/index.json b/contracts/data/v1/schemas/index.json index bf6b753..6e92665 100644 --- a/contracts/data/v1/schemas/index.json +++ b/contracts/data/v1/schemas/index.json @@ -1847,9 +1847,13 @@ "additionalProperties": false, "properties": { "id": { + "maxLength": 80, + "minLength": 3, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", "type": "string" }, "version": { + "pattern": "^(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)$", "type": "string" } }, @@ -2815,9 +2819,13 @@ "additionalProperties": false, "properties": { "id": { + "maxLength": 80, + "minLength": 3, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", "type": "string" }, "version": { + "pattern": "^(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)$", "type": "string" } }, @@ -3145,115 +3153,238 @@ "releaseManifest": { "additionalProperties": false, "properties": { - "build": { + "cdpAdapter": { "additionalProperties": false, "properties": { - "commit": { - "pattern": "^[0-9a-f]{40}$", + "sha256": { + "pattern": "^[0-9a-f]{64}$", "type": "string" } }, "required": [ - "commit" + "sha256" ], "type": "object" }, - "entry": { - "enum": [ - "OpenChatGPTSkin.cmd", - "OpenChatGPTSkin" + "contracts": { + "additionalProperties": false, + "properties": { + "data": { + "const": 1, + "type": "number" + }, + "runtime": { + "const": 1, + "type": "number" + }, + "sha256": { + "pattern": "^[0-9a-f]{64}$", + "type": "string" + }, + "studio": { + "const": 2, + "type": "number" + }, + "theme": { + "const": 4, + "type": "number" + } + }, + "required": [ + "studio", + "runtime", + "theme", + "data", + "sha256" ], - "type": "string" + "type": "object" }, "files": { - "additionalProperties": { + "items": { "additionalProperties": false, "properties": { "bytes": { - "minimum": 0, + "exclusiveMinimum": 0, "type": "integer" }, + "path": { + "maxLength": 500, + "minLength": 1, + "type": "string" + }, "sha256": { "pattern": "^[0-9a-f]{64}$", "type": "string" } }, "required": [ + "path", "bytes", "sha256" ], "type": "object" }, - "type": "object" - }, - "product": { - "const": "OpenChatGPTSkin", - "type": "string" + "minItems": 1, + "type": "array" }, - "runtime": { + "host": { "additionalProperties": false, "properties": { - "nodeVersion": { - "pattern": "^\\d+\\.\\d+\\.\\d+$", + "commit": { + "pattern": "^[0-9a-f]{40}$", + "type": "string" + }, + "dirty": { + "type": "boolean" + }, + "entry": { + "additionalProperties": false, + "properties": { + "bytes": { + "exclusiveMinimum": 0, + "type": "integer" + }, + "path": { + "maxLength": 500, + "minLength": 1, + "type": "string" + }, + "sha256": { + "pattern": "^[0-9a-f]{64}$", + "type": "string" + } + }, + "required": [ + "path", + "bytes", + "sha256" + ], + "type": "object" + }, + "goVersion": { + "pattern": "^go\\d+\\.\\d+(?:\\.\\d+)?$", + "type": "string" + }, + "language": { + "const": "go", "type": "string" } }, "required": [ - "nodeVersion" + "language", + "goVersion", + "commit", + "dirty", + "entry" ], "type": "object" }, - "schemaVersion": { - "const": 1, - "type": "number" - }, - "target": { + "image": { "additionalProperties": false, "properties": { - "arch": { - "enum": [ - "x64", - "arm64" - ], - "type": "string" + "cgo": { + "const": false, + "type": "boolean" }, - "platform": { - "enum": [ - "win32", - "darwin" - ], + "implementation": { + "maxLength": 160, + "minLength": 1, "type": "string" } }, "required": [ - "platform", - "arch" + "implementation", + "cgo" ], "type": "object" }, - "themeCatalog": { + "product": { + "const": "OpenChatGPTSkin", + "type": "string" + }, + "roles": { + "items": [ + { + "const": "studio", + "type": "string" + }, + { + "const": "controller", + "type": "string" + }, + { + "const": "runtime", + "type": "string" + } + ], + "maxItems": 3, + "minItems": 3, + "type": "array" + }, + "schemaVersion": { + "const": 2, + "type": "number" + }, + "sidecars": { + "items": { + "additionalProperties": false, + "properties": { + "bytes": { + "exclusiveMinimum": 0, + "type": "integer" + }, + "path": { + "maxLength": 500, + "minLength": 1, + "type": "string" + }, + "sha256": { + "pattern": "^[0-9a-f]{64}$", + "type": "string" + } + }, + "required": [ + "path", + "bytes", + "sha256" + ], + "type": "object" + }, + "maxItems": 0, + "type": "array" + }, + "target": { + "enum": [ + "windows-x64", + "macos-arm64", + "macos-x64" + ], + "type": "string" + }, + "themes": { "additionalProperties": false, "properties": { - "schemaVersion": { - "exclusiveMinimum": 0, - "type": "integer" + "builtins": { + "items": { + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "type": "string" + }, + "minItems": 1, + "type": "array" + }, + "catalogSchemaVersion": { + "const": 1, + "type": "number" } }, "required": [ - "schemaVersion" + "catalogSchemaVersion", + "builtins" ], "type": "object" }, - "themes": { - "items": { - "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", - "type": "string" - }, - "minItems": 1, - "type": "array" - }, "version": { - "pattern": "^\\d+\\.\\d+\\.\\d+(?:-[0-9A-Za-z.-]+)?$", + "pattern": "^(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)(?:-[0-9A-Za-z.-]+)?$", "type": "string" } }, @@ -3262,11 +3393,13 @@ "product", "version", "target", - "runtime", - "build", - "themeCatalog", - "entry", + "roles", + "host", + "contracts", + "cdpAdapter", "themes", + "image", + "sidecars", "files" ], "type": "object" diff --git a/docs/go-host-gate-a.md b/docs/go-host-gate-a.md index 62335d9..6d68f15 100644 --- a/docs/go-host-gate-a.md +++ b/docs/go-host-gate-a.md @@ -2,28 +2,26 @@ ## 决策 -**状态:开发继续,发布未批准。** +**状态:本地 cutover 已收口;本次不推送、不发布。** 2026-07-24,项目负责人明确要求不以 Gate A 阻断后续 Go Host tickets。因此允许继续实现 Ticket 06–16,并保持 `feat/go-host` 作为隔离开发分支。 -此决定不是 `v0.3.0-alpha.1` 的发布、cutover 或删除 Node 生产宿主的批准。任何发布入口切换仍须完成本文件列出的原生验收和回滚证据。 +后续 Ticket 06–16 已按该决定继续实施。默认开发、Runtime、构建和 Release workflow 已切换到 Go,Node 生产 Host 与下载/stage/launcher 已删除;是否推送或创建 Release 仍由项目负责人单独决定。 ## 已确认的证据 - v0.2.0 发布、协议、主题与数据格式基线已冻结。 -- Studio v2、Runtime v1、Theme v4 与 Data v1 contract 已生成并由 Node baseline corpus 覆盖。 -- 单 Go 二进制的 Studio、Controller、Runtime Spike 已在 Windows 本地测试;控制通道、锁、图片处理与包体均有自动化测试。 -- Windows ZIP/Setup 与 macOS ARM64/x64 tar.gz Spike 产物均小于 v0.2.0 对应基线。 +- Studio v2、Runtime v1、Theme v4、Data v1 与 Release Manifest v2 contract 已生成;v0.2.0 Node 结果只以 reviewed golden/corpus 只读保留,不再执行旧 Node Host。 +- 单 Go 二进制的 Studio、Controller、Runtime 已在 Windows 本地完成单元、合约、类型、构建和原生包测试。 +- 项目负责人已明确确认 Windows、macOS ARM64/x64、五主题、自定义主题、恢复、v0.2.0 数据升级及 Go → Node 回滚实机验收成功。该条证据来自人工验收确认,不冒充当前会话运行的自动化证据。 - 选定图片实现为 CGo-free 的 `gen2brain-webp-wasm2go-nodynamic-plus-internal-pipeline`,不引入长期 native sidecar。 +- Go 工具链已固定为 `1.25.12`;2026-07-25 在 `host/go` 执行 `govulncheck ./...`,结果为 0 个代码可达漏洞。 -机器可读包体、许可证与安全扫描记录见 `contracts/baseline/v0.2.0/go-spike-sizes.json`。 +## 发布前仍需完成 -## 未完成的 Gate A / 发布门槛 - -- macOS ARM64 与 x64 必须在原生 Runner 产出 Unix socket 往返、App Bundle、DMG 和安装包 evidence。 -- Go 1.25.5 的可达标准库漏洞必须升级到已修复的安全补丁版本后重新扫描。 -- 所有六类产物必须以同一候选提交生成、验收并保存精确 SHA-256。 -- Ticket 15 的真实 ChatGPT/Codex 三平台主题、恢复和 Go → Node 回滚验收不得以 CI fixture 或跨编译代替。 +- 在同一候选提交上运行 GitHub `windows-latest`、`macos-15` 和 `macos-15-intel` 原生矩阵,生成并验收六类产物;当前本地会话没有运行这些远端 Jobs。 +- 合并三平台报告后重新生成 `checksums.txt`,确认六类产物精确 SHA-256,再决定是否创建 prerelease。 +- Codex 更新后重新执行 Windows/macOS 真实设备清单,不能沿用旧版本视觉结论。 ## 不变量 diff --git a/docs/releases/v0.3.0-alpha.1.md b/docs/releases/v0.3.0-alpha.1.md new file mode 100644 index 0000000..9ff47bd --- /dev/null +++ b/docs/releases/v0.3.0-alpha.1.md @@ -0,0 +1,36 @@ +# 0.3.0-alpha.1 + +OpenChatGPTSkin `0.3.0-alpha.1` 将生产 Theme Studio、Runtime Controller 和平台执行路径统一切换为一个跨平台 Go Host。React/Vite 前端、Theme Schema、Contract 作者源和 TypeScript CDP Adapter 继续保留,但用户安装包不再包含 Node Runtime 或 Node 业务 `node_modules`。 + +## 主要变化 + +- Windows x64、macOS ARM64、macOS x64 使用同一 Go 业务实现和 `studio | controller | runtime` 多进程角色。 +- 保留五个内置主题、自定义主题、图片处理、草稿、版本、导入导出以及 launch/switch/pause/resume/restore 完整闭环。 +- Windows Setup/ZIP 与 macOS DMG/tar.gz 均为 Node-free 产物;macOS 压缩包包含真实 `OpenChatGPTSkin.app` 和目标架构 Mach-O。 +- `release-manifest.json` 记录 Go 工具链、commit、entry/file SHA-256、Contract/CDP 哈希、图片实现和主题清单。 +- 已在真实 Windows、macOS ARM64 和 macOS x64 上完成应用、恢复、v0.2.0 数据升级与 Go → Node 回滚验收。 + +## 已验证基线 + +- Go 工具链:`1.25.12`。 +- Windows 官方应用基线:`OpenAI.Codex 26.721.4979.0`。 +- Runtime control contract v1、Studio HTTP contract v2、Theme Schema v4、Data contract v1。 +- 五个内置主题:`future-idol-cyan`、`glacier-aurora`、`mountain-mist`、`rose-carpet-star`、`yua-mikami-starlight`。 +- Go `1.25.12` 的 `govulncheck ./...` 结果为 0 个代码可达漏洞;依赖中不可达的报告项不被误报为当前代码可利用。 + +## macOS 未签名限制 + +本版本不包含 Apple Developer ID 正式签名或 notarization。首次打开请使用系统标准的 Control-click/右键 → **打开**流程;不要关闭 Gatekeeper,也不要使用 `xattr` 移除隔离属性。OpenChatGPTSkin 对官方 ChatGPT/Codex 的签名、Team ID 和 notarization 身份校验不会因此放宽。 + +## 回滚到 v0.2.0 + +1. 在 Theme Studio 中恢复原始皮肤,并正常退出受管理的 ChatGPT。 +2. 卸载或删除 `0.3.0-alpha.1` 程序文件;保留 `%LOCALAPPDATA%\OpenChatGPTSkin` 或 `~/Library/Application Support/OpenChatGPTSkin`。 +3. 重新安装 [v0.2.0](https://github.com/u2bo/OpenChatGPTSkin/releases/tag/v0.2.0)。 +4. v0.2.0 Node Host 可以读取 Go 写入的兼容草稿、版本、素材和主题数据。 + +## Known limitations + +- 官方 ChatGPT/Codex 更新可能改变内部 UI;未知 surface 会安全拒绝应用,而不会注入未经验证的 fallback。 +- 项目没有自动更新、主题市场或 Codex 插件市场安装能力。 +- 安装器和 macOS App Bundle 尚未进行商业代码签名。 diff --git a/docs/runtime-macos.en.md b/docs/runtime-macos.en.md index e4f9ac6..60592e5 100644 --- a/docs/runtime-macos.en.md +++ b/docs/runtime-macos.en.md @@ -82,7 +82,7 @@ Enumerating macOS windows normally requires Accessibility consent. OpenChatGPTSk | Release-package acceptance | ZIP, Setup, and installer lifecycle | Payload, `.tar.gz`, `.app`, Mach-O, and DMG mount | | Real-Codex probe/visual acceptance | Available | Not automated; use the manual checklist below | -The historical path and package name `runtime/windows` / `@open-chatgpt-skin/windows-runtime` remain for compatibility and to avoid an unrelated repository-wide move. Its Controller and public provider interface are now platform-neutral. +Starting with `v0.3.0-alpha.1`, production Studio, Controller, and Runtime roles come from the same implementation under `host/go`. TypeScript remains only for the frontend, Theme/Contract authoring sources, and the CDP Adapter; there is no Node business host or platform fallback. ## Real-Mac acceptance checklist @@ -103,12 +103,12 @@ Use a test account/workspace with no private projects or sensitive chats: 5. Run `restore`, verify the official appearance, and quit from the Codex menu so cleanup can finish. 6. During the run, `ls -l /tmp/OpenChatGPTSkin-*.sock` must show a current-user `srw-------` socket. The endpoint must disappear after Controller exit. 7. Start official Codex normally. It must not inherit `--remote-debugging-address` or `--remote-debugging-port`, and the regular app must remain unthemed. -8. Record Codex/macOS versions, four-theme results, restore result, and sanitized screenshots. Do not record PIDs, ports, usernames, paths, command lines, project names, or chat content. +8. Record Codex/macOS versions, five-theme results, restore result, and sanitized screenshots. Do not record PIDs, ports, usernames, paths, command lines, project names, or chat content. -`npm run runtime:probe` and `npm run runtime:acceptance` currently return `RUNTIME_ENVIRONMENT_INVALID` on macOS. That is an explicit platform boundary, not a silent downgrade. +The old Node Host commands `runtime:probe` and `runtime:acceptance` have been removed. Current acceptance combines Go unit/contract/native-package automation with the real-device observations in this checklist; no hidden fallback claims platform support. ## Known risks - Codex updates may change signing metadata, process structure, or DOM surface contracts and require adapter updates plus new acceptance. - The first real-Mac run must confirm that the Apple Team ID in code matches the current official Codex signature. If it differs, verify the official source first; never weaken the policy to accept any valid signature. -- Do not claim complete macOS compatibility or full real-app UI validation before this checklist is completed on a Mac. +- Repeat this checklist after every Codex update; do not extrapolate real-device results from an older Codex version. diff --git a/docs/runtime-macos.md b/docs/runtime-macos.md index 694aeb5..8c40302 100644 --- a/docs/runtime-macos.md +++ b/docs/runtime-macos.md @@ -88,7 +88,7 @@ macOS 窗口枚举通常需要 Accessibility 权限。OpenChatGPTSkin 不为换 | 发布包自动验收 | ZIP、Setup 与安装生命周期 | payload、`.tar.gz`、`.app`、Mach-O 与 DMG 挂载 | | 真实 Codex Probe/视觉验收 | 已提供 | 尚未自动化,使用下方手动清单 | -仓库暂时保留历史目录和包名 `runtime/windows` / `@open-chatgpt-skin/windows-runtime`,避免在本次平台适配中进行无关的大范围搬迁;其中 Controller 和公共接口已经平台中立。 +`v0.3.0-alpha.1` 起生产 Studio、Controller 与 Runtime 由 `host/go` 的同一实现提供;TypeScript 仅保留前端、Theme/Contract 作者源与 CDP Adapter,不存在 Node 业务 Host 或平台 fallback。 ## 真实 Mac 验收清单 @@ -115,12 +115,12 @@ macOS 窗口枚举通常需要 Accessibility 权限。OpenChatGPTSkin 不为换 运行期间应为当前用户所有、权限 `srw-------`;Controller 退出后对应 socket 应被删除。 7. 正常启动官方 Codex,确认没有继承 `--remote-debugging-address` 或 `--remote-debugging-port`,且普通应用不受主题影响。 -8. 记录 Codex 版本、macOS 版本、四主题结果、恢复结果和脱敏截图;不要记录 PID、端口、用户名、路径、命令行、项目名或聊天内容。 +8. 记录 Codex 版本、macOS 版本、五主题结果、恢复结果和脱敏截图;不要记录 PID、端口、用户名、路径、命令行、项目名或聊天内容。 -`npm run runtime:probe` 与 `npm run runtime:acceptance` 当前会在 macOS 返回 `RUNTIME_ENVIRONMENT_INVALID`,这是明确的平台边界,不是静默降级。 +旧 Node Host 的 `runtime:probe` 与 `runtime:acceptance` 已删除。当前验收以 Go 单元/合约/原生包自动检查加本清单的真实设备观察为准,不通过隐藏 fallback 伪造平台能力。 ## 已知风险 - Codex 更新可能改变 bundle 签名信息、进程结构或 DOM surface contract,需要更新适配并重新验收; - 首次实机验收必须确认代码中的 Apple Team ID 与当前官方 Codex 签名一致;不一致时应先核对官方来源,不能放宽为“接受任意有效签名”; -- 真实 Mac 验收完成前,不应发布“macOS 已完全兼容”或“所有 UI 已实机验证”的声明。 +- Codex 升级后必须重新执行本清单,不能把旧版本的实机结果外推到新版本。 diff --git a/docs/runtime-windows.md b/docs/runtime-windows.md index f451a28..df96fb3 100644 --- a/docs/runtime-windows.md +++ b/docs/runtime-windows.md @@ -1,114 +1,73 @@ -# Windows Runtime、发布包与兼容性门 +# Windows Runtime、发布包与兼容性验收 -> Runtime 控制器更新(以本节为准):当前仓库提供开发者预览命令与自动化测试,但尚未在本文档中声明真实 Codex 的视觉验收或本机 `runtime:acceptance` 证据已经完成。下面较早的 Probe 流程仍用于 Codex 包升级后的兼容性检查。 +[返回 README](../README.md) -## Windows 发布包 +`v0.3.0-alpha.1` 的 Windows 生产包使用单一 Go Host 承担 Theme Studio、Controller 与 Runtime 角色。便携 ZIP 和当前用户 Setup 均不包含 Node.js 或 Node 业务 `node_modules`;React/Vite 前端、Theme/Contract 作者源与 TypeScript CDP Adapter 只在构建阶段使用。 -`v0.1.0` 提供 Windows x64 便携 ZIP 与用户级 Setup,当时的发布验收读取四个内置主题。当前源码的后续构建会通过同一 Release staging、固定 Node.js 22 Runtime 和平台匹配的 `sharp` 校验五个内置主题,并继续覆盖完整文件 manifest、生产 UI、一次性 session、真实图片处理、`.ocskin` 保存导出和正常关闭。Setup 额外验证当前用户静默安装、覆盖安装与默认卸载均保留个人主题和草稿。 +## 安装与数据目录 -Setup 默认安装到 `%LOCALAPPDATA%\Programs\OpenChatGPTSkin`,不要求管理员权限;程序数据位于 `%LOCALAPPDATA%\OpenChatGPTSkin`。交互式卸载会询问是否同时删除个人数据,默认选择为“否”,确认删除时会再次明确提示不可恢复。未签名安装包可能触发 SmartScreen,应先用 Release 的 `checksums.txt` 验证 SHA-256。 +- Setup 默认安装到 `%LOCALAPPDATA%\Programs\OpenChatGPTSkin`,不请求管理员权限; +- 便携 ZIP 解压后运行 `OpenChatGPTSkin.exe`; +- 用户数据位于 `%LOCALAPPDATA%\OpenChatGPTSkin`,覆盖安装和默认卸载不会删除个人主题、草稿或版本; +- 未签名安装包可能触发 SmartScreen,只应从项目 GitHub Release 下载并先核对 `checksums.txt`。 -## Runtime 控制器开发者用法 +发布包必须包含 `release-manifest.json` schema v2。Manifest 记录 Go Host 版本/提交/入口 Hash、Contract Hash、CDP Adapter Hash、五个内置主题和 Stage 中每个文件的 SHA-256,并明确声明空 sidecar;验收会拒绝 `node.exe`、`node` 或 `node_modules`。 -前提:Windows 11、Node.js `>=22`、依赖已安装。先保存工作并完全退出普通 Codex;若发现普通实例仍在运行,Runtime 会拒绝接管,不会结束该实例。 +## 源码开发命令 -新版 Codex 在官方 AUMID 激活期间可能短暂创建第二个同包根进程,或把窗口与 CDP -交接给新根。Runtime 会先检查直接启动的实例,并在 AUMID 前给予窗口最多 5 秒的 -收敛时间;当唯一可信根、可见窗口和原回环 CDP 所有权已经成立时,不再执行冗余 -AUMID 激活,避免制造第二个根进程。只有该收敛期结束后窗口仍未就绪时才进入 AUMID -流程。该流程不依赖 PID 必须保持不变,并要求最终根唯一、 -入口路径仍等于已验证的官方 Appx 入口、可见窗口属于最终根进程树、原回环 CDP 端口 -也属于最终根进程树。只有这些条件同时成立才会更新受管会话并继续应用主题。 +源码开发需要 Windows 11、Go `1.25.12`、Node.js `>=22` 与 npm。Node 只用于前端、Contract 和 Adapter 构建,不进入用户包。 ```powershell npm run runtime -- list-themes -npm run runtime -- import --theme-file "D:\\Themes\\personal-theme.ocskin" +npm run runtime -- import --theme-file "D:\Themes\personal-theme.ocskin" npm run runtime -- launch --theme mountain-mist npm run runtime -- switch --theme glacier-aurora +npm run runtime -- launch --theme personal-theme --version 1.0.0 npm run runtime -- pause npm run runtime -- resume +npm run runtime -- status npm run runtime -- restore ``` -可用主题为 `future-idol-cyan`、`rose-carpet-star`、`mountain-mist`、`glacier-aurora`。`import --theme-file` 会使用现有 `.ocskin` 安全校验并原子安装个人主题,不启动 Controller;导入后使用 `launch` 或 `switch` 的 `--theme --version ` 形式选择精确版本。`pause` 会移除已应用皮肤;暂停期间 `switch` 只更新选择,不修改 DOM;`resume` 才重新应用选择。`restore` 恢复官方外观后进入等待退出状态,不能继续切换主题。请从 Codex 菜单或系统托盘执行“退出 / Quit Codex”,不要使用任务管理器强制结束。 +五个内置主题是 `future-idol-cyan`、`glacier-aurora`、`mountain-mist`、`rose-carpet-star` 和 `yua-mikami-starlight`。内置主题可省略版本;个人主题必须使用 `--version ` 选择精确版本。 -## 两阶段 Runtime 验收 +`list-themes` 与 `import --theme-file` 只访问主题仓库,不启动 Controller 或连接 Codex。导入仍经过 `.ocskin` Schema、大小、Hash 和路径安全校验,并原子安装到个人主题仓库。 -自动化验收会通过固定公开控制协议验证四主题、12 条有向切换、暂停后切换、恢复、性能阈值和正常启动无远程调试参数。它只生成严格脱敏的 evidence,绝不写入 PID、端口、用户名、路径、命令行、WebSocket URL、项目内容、聊天内容或截图。 +## Runtime 行为 -```powershell -npm run runtime:acceptance -- --begin -# 完全退出受控 Codex;从 Windows 开始菜单正常启动 Codex -npm run runtime:acceptance -- --finalize -``` - -`--begin` 后不得使用任务管理器结束受控实例。`--finalize` 仅在旧根进程已退出、旧 CDP 端口关闭、且正常 Codex 没有 `--remote-debugging-address` / `--remote-debugging-port` 时写入 `docs/runtime-acceptance/codex-.json`。正常启动的 Codex 不会被关闭。 - -Theme Studio 完整本地闭环已经提供,包括主题编辑、图片与字体上传、首页 / 任务双视图预览、版本保存、导入导出和精确版本 Runtime 应用。Runtime 会在路由变化和 portal 弹层创建后持续重标记任务、工作台、终端、应用菜单与弹层。Windows 安装器与便携包已进入 Alpha;SEA、MCP 和可安装 Codex 插件仍是后续工作。兼容性 Probe 命令继续保留,用于 Codex 升级后的安全检查。 - -> 当前结论:Windows 兼容性门已通过。本页描述的是开发验证流程,不是面向最终用户的换肤功能。 +- `launch` 前必须完全退出普通 Codex;发现未受管理实例时会安全拒绝,不会接管或结束它; +- `switch` 只在受管理会话中切换主题; +- `pause` 移除主题投影但保留选择,`resume` 重新应用已选主题; +- `restore` 恢复官方外观并等待用户从 Codex 菜单或系统托盘正常退出; +- 不要通过任务管理器强制结束受管理实例,否则 Runtime 无法验证清理完成。 -## 已验证的范围 - -本机对已安装的官方 Codex Desktop 完成了一次两阶段兼容性探测,并生成与包版本绑定的脱敏证据。探测确认: - -- 官方 Appx 身份链和受管进程树通过验证; -- CDP 只监听 `127.0.0.1`,不会接受局域网、IPv6 或主机名端点; -- 当前页面具有主内容区、导航区和输入区所需能力; -- 仅创建并移除无视觉影响的受控标记,`markerRoundTrip` 成功; -- 标记清理后官方外观仍然存在; -- 用户完全退出受管 Codex 后,受管根进程和 CDP 监听均已关闭; -- 从开始菜单正常启动 Codex 时,没有 `--remote-debugging-port` 参数。 - -这份结论只适用于已记录的 Codex 包版本。升级 Codex 后必须重新运行兼容性门;证据文件名由经过验证的包版本自动推导,不能由命令行指定。 +新版 Codex 在官方 App 激活期间可能短暂创建或交接根进程。Runtime 只有在官方包身份、唯一可信根、可见窗口和原回环 CDP 端口所有权同时成立后才继续,不依赖 PID 永远不变,也不会放宽为任意可执行文件或任意调试端点。 ## 安全边界 -- 只管理由 OpenChatGPTSkin 本次探测明确启动的 Codex;发现普通运行中的 Codex 会拒绝接管,不会结束它。 -- CDP 仅允许 `127.0.0.1`,并验证端口所属进程和官方 Codex 进程树。 -- 不接受来自主题包或命令行的任意 CSS、JavaScript、HTML、DOM 选择器、可执行路径或 CDP URL。 -- 不会修改 WindowsApps、`app.asar`、认证信息、API 配置、项目文件或聊天内容。 -- 任何身份、端点、Target、DOM 适配或清理检查失败都会停止探测,并保持或恢复官方外观。 -- 不要使用任务管理器强制结束 Codex;等待应用正常退出,或使用应用菜单/系统托盘中的“退出”。 - -## 复现兼容性探测 - -前提:Windows 11、Node.js `>=22.0.0`,并已在仓库根目录安装依赖。 - -1. 如需首次建立可信安装缓存,可在普通 Codex 仍打开时执行: - - ```powershell - npm run runtime:probe - ``` - - 该命令预期拒绝普通实例并返回 `CODEX_ALREADY_RUNNING_UNMANAGED`;这不是失败后的强制接管,也不会关闭应用。 - -2. 保存工作并完全退出普通 Codex。随后执行第一阶段探测: - - ```powershell - npm run runtime:probe -- --record-evidence - ``` - - 命令会通过官方 App 身份启动一个受管实例,完成无视觉标记往返并恢复官方外观。完成后它会保留该实例,等待用户手动完全退出。 - -3. 使用 Codex 的“退出 / Quit Codex”命令完全退出该实例。不要只关闭窗口,也不要使用任务管理器强制结束。 - -4. 执行第二阶段收尾: - - ```powershell - npm run runtime:probe -- --finalize - ``` - - 只有受管根进程与 CDP 监听都已关闭时,这一步才会写入 `docs/runtime-probes/codex-.json`。如果返回 `PROBE_EXIT_PENDING`,说明应用尚未完全退出;此时会话保留,供用户完成正常退出后重试。 +- CDP 只允许 `127.0.0.1`,并验证端口属于受管理的官方 Codex 进程树; +- 主题不能携带 JavaScript、HTML、CSS、可执行文件、远程 URL、自定义 DOM 选择器或 CDP 地址; +- 不修改 `WindowsApps`、`app.asar`、官方签名、账号/API 配置、项目文件或聊天内容; +- 身份、Target、DOM Adapter、主题校验或恢复检查失败时返回结构化错误,不使用隐藏 fallback; +- Theme Studio 会把精确 `{id, version}` 交给同一个 Go Runtime,不存在 Node/Go 双写或第二业务后端。 -5. 从 Windows 开始菜单正常启动 Codex,确认其可用且没有调试参数。此步骤验证普通启动不会继承本次探测的 CDP 暴露。 +## 真实 Windows 验收清单 -## 脱敏证据 +在无私人项目或敏感聊天的测试工作区执行。当前 `v0.3.0-alpha.1` 候选代码已完成一次 Windows x64 实机换肤与恢复验收;Codex 更新后仍必须重新执行本清单。 -当前开发机的证据为 [codex-26.707.12708.0.json](runtime-probes/codex-26.707.12708.0.json)。它仅包含包身份和版本、Target 分类、能力布尔值以及退出/清理结果;不包含 PID、端口号、WebSocket URL、用户名、磁盘路径、命令行、项目内容、聊天内容或认证信息。 +1. 校验 Setup/ZIP SHA-256,分别完成安装/启动;确认 Theme Studio 能打开且用户数据不写入程序目录。 +2. 完全退出普通 Codex,依次应用五个内置主题;检查首页、历史、任务、设置、插件、菜单、弹层、输入框、侧边栏和终端。 +3. 导入并应用一个包含自定义背景、颜色、字体、装饰、头像、建议图标和项目图标的 `.ocskin`。 +4. 执行 `pause`、`switch`、`resume`,确认暂停时官方外观可见,恢复时只应用已选主题。 +5. 执行 `restore`,确认官方外观恢复;从 Codex 菜单或系统托盘正常退出,确认 Controller 清理完成。 +6. 从开始菜单正常启动官方 Codex,确认未继承 `--remote-debugging-address` 或 `--remote-debugging-port`,且保持官方外观。 +7. 验证覆盖安装和卸载默认保留 `%LOCALAPPDATA%\OpenChatGPTSkin` 下的个人主题、草稿与版本。 +8. 记录 Codex/OpenChatGPTSkin/Windows 版本、主题结果和脱敏截图。公开记录不得包含 PID、端口、用户名、绝对路径、命令行、项目名或聊天内容。 -## 当前未交付的能力 +旧 Node Host 的 `runtime:probe` 与 `runtime:acceptance` 已随 Go cutover 删除。历史脱敏证据 [codex-26.707.12708.0.json](runtime-probes/codex-26.707.12708.0.json) 只用于说明早期兼容性门,不是当前 Go 版本的自动验收结果,也不能替代上面的真实设备检查。 -尚未提供可安装的 Codex 插件、SEA 单文件程序、自动更新或 Windows ARM64 包。Theme Studio 已交付安全会话、三栏编辑器、用户素材处理、不可变版本、导入导出、受约束模块布局以及编辑后应用到真实 Codex 的闭环。 +## 已知风险 -Runtime Adapter 已覆盖 Hero、建议卡、任务路由、审阅 / 终端 / 浏览器 / 文件工作区、应用菜单和 portal 弹层;不会通过修改官方安装目录、接受用户 DOM 选择器或扩大 CDP 暴露范围来缩短路径。Codex 升级后仍需重新运行兼容性门和真实视觉验收。 +- Codex 更新可能改变包身份、进程结构或 DOM surface contract,需要 Adapter 更新和重新验收; +- Windows 安装包尚未代码签名,SmartScreen 提示不会通过关闭系统安全功能规避; +- 尚未提供 Windows ARM64、自动更新、Codex 插件市场安装或单文件 SEA。 diff --git a/docs/theme-studio.md b/docs/theme-studio.md index 21ebd9f..36e082a 100644 --- a/docs/theme-studio.md +++ b/docs/theme-studio.md @@ -10,7 +10,7 @@ Theme Studio 是 OpenChatGPTSkin 的本地三栏编辑器。它完成“创建 ### Windows 发布包 -Setup 用户从开始菜单启动 OpenChatGPTSkin;便携版用户双击解压目录中的 `OpenChatGPTSkin.cmd`。发布包内置 Node.js、生产依赖和单 HTML Theme Studio,不需要全局 Node.js、npm、Git 或 Vite。生产 Host 通过健康检查后自动打开默认浏览器;使用 `--no-open` 时只输出启动 URL,供自动验收使用。 +Setup 用户从开始菜单启动 OpenChatGPTSkin;便携版用户双击解压目录中的 `OpenChatGPTSkin.exe`。发布包包含单一 Go Host 和单 HTML Theme Studio,不捆绑 Node.js,也不需要全局 Node.js、Go、npm、Git 或 Vite。生产 Host 通过健康检查后自动打开默认浏览器;使用 `--no-open` 时只输出启动 URL,供自动验收使用。 生产启动失败会返回结构化 `code`、`message` 和 `nextAction`,并把不含用户名、绝对路径、端口或 bootstrap token 的记录写入 `%LOCALAPPDATA%\OpenChatGPTSkin\runtime\logs\theme-studio.jsonl`。 diff --git a/host/go/go.mod b/host/go/go.mod index 0342941..0f01a28 100644 --- a/host/go/go.mod +++ b/host/go/go.mod @@ -2,7 +2,7 @@ module github.com/u2bo/OpenChatGPTSkin/host/go go 1.25.0 -toolchain go1.25.5 +toolchain go1.25.12 require ( github.com/Microsoft/go-winio v0.6.2 diff --git a/host/go/internal/app/app_test.go b/host/go/internal/app/app_test.go index 3cc703d..c8862bc 100644 --- a/host/go/internal/app/app_test.go +++ b/host/go/internal/app/app_test.go @@ -1,6 +1,7 @@ package app import ( + "bytes" "context" "encoding/json" "net/http" @@ -8,9 +9,25 @@ import ( "path/filepath" "testing" + "github.com/u2bo/OpenChatGPTSkin/host/go/internal/control" "github.com/u2bo/OpenChatGPTSkin/host/go/internal/studio" + "github.com/u2bo/OpenChatGPTSkin/host/go/internal/themerepo" ) +type runtimeThemeRepositoryStub struct { + library themerepo.Library + imported []byte +} + +func (repository *runtimeThemeRepositoryStub) List() (themerepo.Library, error) { + return repository.library, nil +} + +func (repository *runtimeThemeRepositoryStub) ImportArchive(contents []byte) (themerepo.Ref, error) { + repository.imported = append([]byte(nil), contents...) + return themerepo.Ref{ID: "personal-theme", Version: "1.0.0"}, nil +} + func TestParseDefaultsToStudio(t *testing.T) { command, err := Parse(nil) if err != nil { @@ -38,6 +55,39 @@ func TestStudioDevRequiresAnExplicitLoopbackOrigin(t *testing.T) { } } +func TestInstallRootCandidatesIncludeMacOSBundlePayload(t *testing.T) { + executable := filepath.Join("Applications", "OpenChatGPTSkin.app", "Contents", "MacOS", "OpenChatGPTSkin") + candidates := installRootCandidates(executable, filepath.Join("workspace", "OpenChatGPTSkin")) + expected := filepath.Clean(filepath.Join("Applications", "OpenChatGPTSkin.app", "Contents", "Resources", "payload")) + if len(candidates) != 3 || candidates[1] != expected { + t.Fatalf("install root candidates = %v, want payload %q", candidates, expected) + } +} + +func TestInstallRootRequiresManifestOrRepositoryMarkers(t *testing.T) { + root := t.TempDir() + for _, path := range []string{ + filepath.Join(root, "themes", "catalog.json"), + filepath.Join(root, "apps", "theme-studio", "dist", "index.html"), + } { + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte("{}"), 0o600); err != nil { + t.Fatal(err) + } + } + if isInstallRoot(root, true) { + t.Fatal("unmanifested production root was accepted") + } + if err := os.WriteFile(filepath.Join(root, "release-manifest.json"), []byte("{}"), 0o600); err != nil { + t.Fatal(err) + } + if !isInstallRoot(root, true) { + t.Fatal("manifested production root was rejected") + } +} + func TestRuntimeRejectsMissingAndCommandSpecificThemeOptions(t *testing.T) { dataRoot := t.TempDir() _, err := runRuntime(context.Background(), []string{"launch", "--data-root", dataRoot}) @@ -52,6 +102,85 @@ func TestRuntimeRejectsMissingAndCommandSpecificThemeOptions(t *testing.T) { } } +func TestRuntimeThemeCommandsListAndImportWithoutController(t *testing.T) { + repository := &runtimeThemeRepositoryStub{library: themerepo.Library{Themes: []themerepo.ListItem{{ + Ref: themerepo.Ref{ID: "mountain-mist", Version: "1.3.0"}, Name: "Mountain Mist", + }}}} + listed, err := executeRuntimeThemeCommand(runtimeThemeCommand{name: "list-themes"}, repository) + if err != nil || len(listed.(themerepo.Library).Themes) != 1 { + t.Fatalf("list result=%+v error=%v", listed, err) + } + + archive := filepath.Join(t.TempDir(), "personal-theme.ocskin") + if err := os.WriteFile(archive, []byte("archive"), 0o600); err != nil { + t.Fatal(err) + } + imported, err := executeRuntimeThemeCommand(runtimeThemeCommand{name: "import", themeFile: archive}, repository) + if err != nil || string(repository.imported) != "archive" { + t.Fatalf("import result=%+v bytes=%q error=%v", imported, repository.imported, err) + } + result := imported.(map[string]any)["theme"].(themerepo.Ref) + if result.ID != "personal-theme" || result.Version != "1.0.0" { + t.Fatalf("imported ref=%+v", result) + } +} + +func TestRuntimeThemeCommandArgumentsAreFixed(t *testing.T) { + if _, err := parseRuntimeThemeCommand([]string{"import"}); err == nil { + t.Fatal("import without --theme-file was accepted") + } + if _, err := parseRuntimeThemeCommand([]string{"list-themes", "--theme-file", "theme.ocskin"}); err == nil { + t.Fatal("list-themes with --theme-file was accepted") + } + if _, err := parseRuntimeThemeCommand([]string{"import", "--theme-file", "bad\x00path"}); err == nil { + t.Fatal("theme file containing NUL was accepted") + } +} + +func TestPersonalRuntimeThemeRequiresExactVersion(t *testing.T) { + builtins := []themerepo.Ref{{ID: "mountain-mist", Version: "1.3.0"}} + if err := validateUnversionedRuntimeTheme("mountain-mist", builtins); err != nil { + t.Fatalf("builtin rejected: %v", err) + } + if err := validateUnversionedRuntimeTheme("personal-theme", builtins); err == nil || ErrorCode(err) != "THEME_NOT_FOUND" { + t.Fatalf("personal theme error=%v", err) + } +} + +func TestRuntimeCLIUnwrapsSuccessAndPreservesSafeRejection(t *testing.T) { + result, err := runtimeCLIControlResult(control.Response{ + ProtocolVersion: 1, RequestID: "00000000-0000-4000-8000-000000000001", OK: true, + Result: json.RawMessage(`{"status":"active"}`), + }) + if err != nil || result.(map[string]any)["status"] != "active" { + t.Fatalf("success result=%+v error=%v", result, err) + } + _, err = runtimeCLIControlResult(control.Response{ + ProtocolVersion: 1, RequestID: "00000000-0000-4000-8000-000000000002", OK: false, + Error: &control.Error{Code: "THEME_CLEANUP_FAILED", Message: "cleanup rejected", NextAction: "Quit Codex and retry."}, + }) + if err == nil || ErrorCode(err) != "THEME_CLEANUP_FAILED" { + t.Fatalf("rejection error=%v", err) + } + var output bytes.Buffer + writeCLIError(&output, err) + if !bytes.Contains(output.Bytes(), []byte(`"nextAction":"Quit Codex and retry."`)) { + t.Fatalf("error output=%s", output.String()) + } +} + +func TestStoppedRuntimeResponseUsesPublicStatusShape(t *testing.T) { + response, err := stoppedRuntimeResponse("00000000-0000-4000-8000-000000000003") + if err != nil { + t.Fatal(err) + } + result, err := runtimeCLIControlResult(response) + status := result.(map[string]any) + if err != nil || status["status"] != "stopped" || status["controllerAvailable"] != false { + t.Fatalf("status=%+v error=%v", status, err) + } +} + func TestStudioHealthUsesLoopbackAndRejectsUnauthenticatedAPI(t *testing.T) { studio := startTestStudio(t) var err error diff --git a/host/go/internal/app/command.go b/host/go/internal/app/command.go index eec4961..266ed4b 100644 --- a/host/go/internal/app/command.go +++ b/host/go/internal/app/command.go @@ -20,8 +20,9 @@ type Command struct { } type commandError struct { - code string - message string + code string + message string + nextAction string } func (err commandError) Error() string { return err.message } diff --git a/host/go/internal/app/run.go b/host/go/internal/app/run.go index a44bea3..f8b479c 100644 --- a/host/go/internal/app/run.go +++ b/host/go/internal/app/run.go @@ -3,6 +3,7 @@ package app import ( "context" "encoding/json" + "errors" "io" "os" "os/signal" @@ -105,7 +106,11 @@ func Run(ctx context.Context, arguments []string, stdout, stderr io.Writer) int writeCLIError(stderr, startErr) return 1 } - _ = json.NewEncoder(stdout).Encode(map[string]any{"ok": true, "role": "studio"}) + result := map[string]any{"ok": true, "role": "studio"} + if options.noOpen { + result["url"] = studio.BootstrapURL + } + _ = json.NewEncoder(stdout).Encode(result) if options.healthOnce { _ = studio.Close() return 0 @@ -132,7 +137,7 @@ func Run(ctx context.Context, arguments []string, stdout, stderr io.Writer) int } return 0 case RoleRuntime: - response, runtimeErr := runRuntime(ctx, command.Args) + response, runtimeErr := runRuntimeCLI(ctx, command.Args) if runtimeErr != nil { writeCLIError(stderr, runtimeErr) return 1 @@ -154,9 +159,12 @@ func Run(ctx context.Context, arguments []string, stdout, stderr io.Writer) int } func writeCLIError(writer io.Writer, err error) { - _ = json.NewEncoder(writer).Encode(map[string]any{ - "error": map[string]string{"code": ErrorCode(err), "message": err.Error()}, - }) + value := map[string]string{"code": ErrorCode(err), "message": err.Error()} + var command commandError + if errors.As(err, &command) && command.nextAction != "" { + value["nextAction"] = command.nextAction + } + _ = json.NewEncoder(writer).Encode(map[string]any{"error": value}) } func Main(arguments []string, stdout, stderr io.Writer) int { diff --git a/host/go/internal/app/runtime.go b/host/go/internal/app/runtime.go index 80adb38..385b2fc 100644 --- a/host/go/internal/app/runtime.go +++ b/host/go/internal/app/runtime.go @@ -5,14 +5,30 @@ import ( "crypto/rand" "encoding/hex" "encoding/json" + "errors" + "io" "os" "os/exec" "path/filepath" "time" "github.com/u2bo/OpenChatGPTSkin/host/go/internal/control" + "github.com/u2bo/OpenChatGPTSkin/host/go/internal/themerepo" ) +const maxRuntimeThemeArchiveBytes = 32 * 1024 * 1024 + +type runtimeThemeRepository interface { + List() (themerepo.Library, error) + ImportArchive([]byte) (themerepo.Ref, error) +} + +type runtimeThemeCommand struct { + name string + dataRoot string + themeFile string +} + func requestID() (string, error) { value := make([]byte, 16) if _, err := rand.Read(value); err != nil { @@ -61,6 +77,184 @@ func sendRuntime(ctx context.Context, dataRoot string, request control.Request) return control.RoundTrip(ctx, func() (control.Connection, error) { return dialControl(endpoint) }, request) } +func parseRuntimeThemeCommand(arguments []string) (runtimeThemeCommand, error) { + if len(arguments) == 0 || arguments[0] != "list-themes" && arguments[0] != "import" { + return runtimeThemeCommand{}, commandError{code: "CLI_ARGUMENT_INVALID", message: "runtime theme command is invalid"} + } + command := runtimeThemeCommand{name: arguments[0]} + for index := 1; index < len(arguments); index++ { + switch arguments[index] { + case "--data-root", "--theme-file": + name := arguments[index] + index++ + if index >= len(arguments) || arguments[index] == "" || len(arguments[index]) > 4096 || containsNull(arguments[index]) { + return runtimeThemeCommand{}, commandError{code: "CLI_ARGUMENT_INVALID", message: name + " requires a valid value"} + } + if name == "--data-root" { + if command.dataRoot != "" { + return runtimeThemeCommand{}, commandError{code: "CLI_ARGUMENT_INVALID", message: "--data-root may be specified only once"} + } + command.dataRoot = arguments[index] + } else { + if command.themeFile != "" { + return runtimeThemeCommand{}, commandError{code: "CLI_ARGUMENT_INVALID", message: "--theme-file may be specified only once"} + } + command.themeFile = arguments[index] + } + default: + return runtimeThemeCommand{}, commandError{code: "CLI_ARGUMENT_INVALID", message: "unknown runtime option: " + arguments[index]} + } + } + if command.name == "list-themes" && command.themeFile != "" { + return runtimeThemeCommand{}, commandError{code: "CLI_ARGUMENT_INVALID", message: "--theme-file is valid only for import"} + } + if command.name == "import" && command.themeFile == "" { + return runtimeThemeCommand{}, commandError{code: "CLI_ARGUMENT_INVALID", message: "--theme-file is required for import"} + } + return command, nil +} + +func containsNull(value string) bool { + for _, character := range value { + if character == 0 { + return true + } + } + return false +} + +func readRuntimeThemeArchive(path string) ([]byte, error) { + file, err := os.Open(path) + if err != nil { + return nil, commandError{code: "STUDIO_IMPORT_INVALID", message: "Theme archive could not be opened"} + } + defer file.Close() + info, err := file.Stat() + if err != nil || !info.Mode().IsRegular() || info.Size() < 1 || info.Size() > maxRuntimeThemeArchiveBytes { + return nil, commandError{code: "STUDIO_IMPORT_INVALID", message: "Theme archive size is invalid"} + } + contents, err := io.ReadAll(io.LimitReader(file, maxRuntimeThemeArchiveBytes+1)) + if err != nil || len(contents) < 1 || len(contents) > maxRuntimeThemeArchiveBytes { + return nil, commandError{code: "STUDIO_IMPORT_INVALID", message: "Theme archive could not be read"} + } + return contents, nil +} + +func executeRuntimeThemeCommand(command runtimeThemeCommand, repository runtimeThemeRepository) (any, error) { + if command.name == "list-themes" { + return repository.List() + } + contents, err := readRuntimeThemeArchive(command.themeFile) + if err != nil { + return nil, err + } + ref, err := repository.ImportArchive(contents) + if err != nil { + var repositoryError themerepo.Error + if errors.As(err, &repositoryError) { + return nil, commandError{code: repositoryError.Code, message: repositoryError.Message} + } + return nil, commandError{code: "STUDIO_IMPORT_INVALID", message: "Theme archive could not be imported"} + } + return map[string]any{"theme": ref}, nil +} + +func runRuntimeThemeCommand(arguments []string) (any, error) { + command, err := parseRuntimeThemeCommand(arguments) + if err != nil { + return nil, err + } + if command.dataRoot == "" { + command.dataRoot, err = defaultDataRoot() + if err != nil { + return nil, commandError{code: "RUNTIME_CONTROL_UNAVAILABLE", message: "Runtime data root is unavailable"} + } + } + installRoot, err := findInstallRoot(false) + if err != nil { + return nil, commandError{code: "THEME_NOT_FOUND", message: "Installed themes are unavailable"} + } + repository, err := themerepo.OpenWithPersonal( + filepath.Join(installRoot, "themes"), + filepath.Join(command.dataRoot, "theme-store"), + ) + if err != nil { + return nil, commandError{code: "RUNTIME_ENVIRONMENT_INVALID", message: "Runtime theme repository is unavailable"} + } + return executeRuntimeThemeCommand(command, repository) +} + +func runRuntimeCLI(ctx context.Context, arguments []string) (any, error) { + if len(arguments) == 0 { + return nil, commandError{code: "CLI_ARGUMENT_INVALID", message: "runtime command is required"} + } + if arguments[0] == "list-themes" || arguments[0] == "import" { + return runRuntimeThemeCommand(arguments) + } + response, err := runRuntime(ctx, arguments) + if err != nil { + return nil, err + } + return runtimeCLIControlResult(response) +} + +func runtimeCLIControlResult(response control.Response) (any, error) { + if !response.OK { + if response.Error == nil { + return nil, commandError{code: "RUNTIME_CONTROL_UNAVAILABLE", message: "Runtime returned an invalid rejection"} + } + return nil, commandError{ + code: response.Error.Code, message: response.Error.Message, nextAction: response.Error.NextAction, + } + } + var result any + if len(response.Result) == 0 || json.Unmarshal(response.Result, &result) != nil { + return nil, commandError{code: "RUNTIME_CONTROL_UNAVAILABLE", message: "Runtime returned an invalid result"} + } + return result, nil +} + +func stoppedRuntimeResponse(requestID string) (control.Response, error) { + result, err := json.Marshal(map[string]any{ + "status": "stopped", "controllerAvailable": false, + "selectedTheme": nil, "appliedTheme": nil, "skinApplied": nil, + "packageVersion": nil, "operation": nil, + "nextAction": "Open a saved theme and apply it to start Runtime.", + }) + if err != nil { + return control.Response{}, err + } + return control.Response{ProtocolVersion: control.ProtocolVersion, RequestID: requestID, OK: true, Result: result}, nil +} + +func validateUnversionedRuntimeTheme(themeID string, builtins []themerepo.Ref) error { + for _, builtin := range builtins { + if builtin.ID == themeID { + return nil + } + } + return commandError{code: "THEME_NOT_FOUND", message: "Personal themes require an exact --version"} +} + +func validateRuntimeThemeSelection(themeID, themeVersion string) error { + if themeVersion != "" { + return nil + } + installRoot, err := findInstallRoot(false) + if err != nil { + return commandError{code: "THEME_NOT_FOUND", message: "Installed themes are unavailable"} + } + repository, err := themerepo.Open(filepath.Join(installRoot, "themes")) + if err != nil { + return commandError{code: "THEME_NOT_FOUND", message: "Installed themes are unavailable"} + } + builtins, err := repository.BuiltinRefs() + if err != nil { + return commandError{code: "THEME_NOT_FOUND", message: "Installed theme catalog is unavailable"} + } + return validateUnversionedRuntimeTheme(themeID, builtins) +} + func runRuntime(ctx context.Context, arguments []string) (control.Response, error) { if len(arguments) == 0 { return control.Response{}, commandError{code: "CLI_ARGUMENT_INVALID", message: "runtime command is required"} @@ -88,10 +282,13 @@ func runRuntime(ctx context.Context, arguments []string) (control.Response, erro } themeID = arguments[index] themeProvided = true - case "--theme-version": + case "--theme-version", "--version": + if themeVersionProvided { + return control.Response{}, commandError{code: "CLI_ARGUMENT_INVALID", message: "theme version may be specified only once"} + } index++ if index >= len(arguments) { - return control.Response{}, commandError{code: "CLI_ARGUMENT_INVALID", message: "--theme-version requires a value"} + return control.Response{}, commandError{code: "CLI_ARGUMENT_INVALID", message: "theme version requires a value"} } themeVersion = arguments[index] themeVersionProvided = true @@ -99,8 +296,15 @@ func runRuntime(ctx context.Context, arguments []string) (control.Response, erro return control.Response{}, commandError{code: "CLI_ARGUMENT_INVALID", message: "unknown runtime option: " + arguments[index]} } } + if themeVersionProvided && themeVersion == "" { + return control.Response{}, commandError{code: "CLI_ARGUMENT_INVALID", message: "--theme-version requires a value"} + } if dataRoot == "" { - return control.Response{}, commandError{code: "CLI_ARGUMENT_INVALID", message: "--data-root is required for the spike"} + var err error + dataRoot, err = defaultDataRoot() + if err != nil { + return control.Response{}, commandError{code: "RUNTIME_CONTROL_UNAVAILABLE", message: "Runtime data root is unavailable"} + } } themeCommand := commandName == "launch" || commandName == "switch" if themeCommand && !themeProvided { @@ -109,6 +313,11 @@ func runRuntime(ctx context.Context, arguments []string) (control.Response, erro if !themeCommand && (themeProvided || themeVersionProvided) { return control.Response{}, commandError{code: "CLI_ARGUMENT_INVALID", message: "theme options are valid only for launch and switch"} } + if themeCommand { + if err := validateRuntimeThemeSelection(themeID, themeVersion); err != nil { + return control.Response{}, err + } + } id, err := requestID() if err != nil { return control.Response{}, err @@ -122,8 +331,16 @@ func runRuntime(ctx context.Context, arguments []string) (control.Response, erro } request := control.Request{ProtocolVersion: 1, RequestID: id, Command: commandName, Params: params} response, err := sendRuntime(ctx, dataRoot, request) - if err == nil || commandName == "status" { - return response, err + if err == nil { + return response, nil + } + if commandName == "status" { + if errors.Is(err, os.ErrNotExist) { + return stoppedRuntimeResponse(id) + } + return control.Response{}, commandError{ + code: "RUNTIME_CONTROL_UNAVAILABLE", message: "Runtime controller status could not be read", + } } executable, executableErr := os.Executable() if executableErr != nil { diff --git a/host/go/internal/app/studio.go b/host/go/internal/app/studio.go index 5895307..134086f 100644 --- a/host/go/internal/app/studio.go +++ b/host/go/internal/app/studio.go @@ -10,7 +10,7 @@ import ( "github.com/u2bo/OpenChatGPTSkin/host/go/internal/themerepo" ) -const goHostVersion = "0.3.0-alpha.1" +var goHostVersion = "0.3.0-alpha.1" type RunningStudio = studio.RunningServer @@ -75,18 +75,16 @@ func startStudio(ctx context.Context, viteOrigin, configuredDataRoot string) (*R } func findInstallRoot(requireProductionUI bool) (string, error) { - candidates := make([]string, 0, 2) - if executable, err := os.Executable(); err == nil { - candidates = append(candidates, filepath.Dir(executable)) + executable, current := "", "" + if value, err := os.Executable(); err == nil { + executable = value } - if current, err := os.Getwd(); err == nil { - candidates = append(candidates, current) + if value, err := os.Getwd(); err == nil { + current = value } - for _, candidate := range candidates { + for _, candidate := range installRootCandidates(executable, current) { for root := candidate; ; root = filepath.Dir(root) { - hasThemes := fileExists(filepath.Join(root, "themes", "catalog.json")) - hasProductionUI := fileExists(filepath.Join(root, "apps", "theme-studio", "dist", "index.html")) - if hasThemes && (!requireProductionUI || hasProductionUI) { + if isInstallRoot(root, requireProductionUI) { return root, nil } parent := filepath.Dir(root) @@ -98,6 +96,29 @@ func findInstallRoot(requireProductionUI bool) (string, error) { return "", errors.New("Studio install root is unavailable") } +func installRootCandidates(executable, current string) []string { + candidates := make([]string, 0, 3) + if executable != "" { + directory := filepath.Dir(executable) + candidates = append(candidates, directory) + candidates = append(candidates, filepath.Clean(filepath.Join(directory, "..", "Resources", "payload"))) + } + if current != "" { + candidates = append(candidates, current) + } + return candidates +} + +func isInstallRoot(root string, requireProductionUI bool) bool { + hasThemes := fileExists(filepath.Join(root, "themes", "catalog.json")) + hasProductionUI := fileExists(filepath.Join(root, "apps", "theme-studio", "dist", "index.html")) + hasReleaseManifest := fileExists(filepath.Join(root, "release-manifest.json")) + hasRepositoryMarkers := fileExists(filepath.Join(root, "package.json")) && + fileExists(filepath.Join(root, "host", "go", "go.mod")) + return hasThemes && (!requireProductionUI || hasProductionUI) && + (hasReleaseManifest || hasRepositoryMarkers) +} + func fileExists(path string) bool { info, err := os.Stat(path) return err == nil && info.Mode().IsRegular() diff --git a/host/go/internal/platform/windows/transport_windows.go b/host/go/internal/platform/windows/transport_windows.go index b31843f..47c293c 100644 --- a/host/go/internal/platform/windows/transport_windows.go +++ b/host/go/internal/platform/windows/transport_windows.go @@ -16,7 +16,7 @@ import ( func Endpoint(identity string) string { digest := sha256.Sum256([]byte(identity)) - return `\\.\pipe\OpenChatGPTSkin-Go-Spike-` + hex.EncodeToString(digest[:12]) + return `\\.\pipe\OpenChatGPTSkin-` + hex.EncodeToString(digest[:12]) } func TestEndpoint(identity string) string { return Endpoint(identity) } diff --git a/host/go/internal/platform/windows/transport_windows_test.go b/host/go/internal/platform/windows/transport_windows_test.go index 8eb2477..3512e37 100644 --- a/host/go/internal/platform/windows/transport_windows_test.go +++ b/host/go/internal/platform/windows/transport_windows_test.go @@ -4,11 +4,19 @@ package windows import ( "context" + "strings" "testing" "github.com/u2bo/OpenChatGPTSkin/host/go/internal/control" ) +func TestNamedPipeUsesProductionIdentity(t *testing.T) { + endpoint := Endpoint("current-user") + if !strings.HasPrefix(endpoint, `\\.\pipe\OpenChatGPTSkin-`) || strings.Contains(strings.ToLower(endpoint), "spike") { + t.Fatalf("endpoint = %q", endpoint) + } +} + func TestNamedPipeRoundTrip(t *testing.T) { endpoint := TestEndpoint(t.Name()) listener, err := Listen(endpoint) diff --git a/package-lock.json b/package-lock.json index 7681acb..09516d4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,15 @@ { "name": "open-chatgpt-skin", - "version": "0.2.0", + "version": "0.3.0-alpha.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "open-chatgpt-skin", - "version": "0.2.0", + "version": "0.3.0-alpha.1", "workspaces": [ "apps/*", - "packages/*", - "runtime/*" + "packages/*" ], "devDependencies": { "@types/node": "^22.20.1", @@ -23,6 +22,7 @@ "typescript": "^5.9.3", "vitest": "^3.2.7", "ws": "^8.18.3", + "zod": "^3.25.76", "zod-to-json-schema": "^3.25.2" }, "engines": { @@ -31,10 +31,10 @@ }, "apps/theme-studio": { "name": "@open-chatgpt-skin/theme-studio", - "version": "0.2.0", + "version": "0.3.0-alpha.1", "dependencies": { - "@open-chatgpt-skin/theme-schema": "0.2.0", - "@open-chatgpt-skin/theme-studio-core": "0.2.0", + "@open-chatgpt-skin/theme-schema": "0.3.0-alpha.1", + "@open-chatgpt-skin/theme-studio-core": "0.3.0-alpha.1", "@phosphor-icons/react": "^2.1.10", "react": "19.0.0", "react-dom": "19.0.0" @@ -517,6 +517,7 @@ "version": "1.11.2", "resolved": "https://registry.npmmirror.com/@emnapi/runtime/-/runtime-1.11.2.tgz", "integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -969,6 +970,7 @@ "version": "1.1.0", "resolved": "https://registry.npmmirror.com/@img/colour/-/colour-1.1.0.tgz", "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -981,6 +983,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1003,6 +1006,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1025,6 +1029,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1041,6 +1046,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1057,6 +1063,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1073,6 +1080,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1089,6 +1097,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1105,6 +1114,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1121,6 +1131,7 @@ "cpu": [ "s390x" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1137,6 +1148,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1153,6 +1165,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1169,6 +1182,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1185,6 +1199,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1207,6 +1222,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1229,6 +1245,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1251,6 +1268,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1273,6 +1291,7 @@ "cpu": [ "s390x" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1295,6 +1314,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1317,6 +1337,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1339,6 +1360,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1361,6 +1383,7 @@ "cpu": [ "wasm32" ], + "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "optional": true, "dependencies": { @@ -1380,6 +1403,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later", "optional": true, "os": [ @@ -1399,6 +1423,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later", "optional": true, "os": [ @@ -1418,6 +1443,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later", "optional": true, "os": [ @@ -1484,6 +1510,10 @@ "resolved": "packages/cdp-adapter", "link": true }, + "node_modules/@open-chatgpt-skin/runtime-contract": { + "resolved": "packages/runtime-contract", + "link": true + }, "node_modules/@open-chatgpt-skin/theme-core": { "resolved": "packages/theme-core", "link": true @@ -1500,14 +1530,6 @@ "resolved": "packages/theme-studio-core", "link": true }, - "node_modules/@open-chatgpt-skin/theme-studio-service": { - "resolved": "runtime/theme-studio-service", - "link": true - }, - "node_modules/@open-chatgpt-skin/windows-runtime": { - "resolved": "runtime/windows", - "link": true - }, "node_modules/@phosphor-icons/react": { "version": "2.1.10", "resolved": "https://registry.npmmirror.com/@phosphor-icons/react/-/react-2.1.10.tgz", @@ -2622,6 +2644,7 @@ "version": "2.1.2", "resolved": "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, "license": "Apache-2.0", "engines": { "node": ">=8" @@ -3796,6 +3819,7 @@ "version": "7.8.5", "resolved": "https://registry.npmmirror.com/semver/-/semver-7.8.5.tgz", "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -3808,6 +3832,7 @@ "version": "0.34.5", "resolved": "https://registry.npmmirror.com/sharp/-/sharp-0.34.5.tgz", "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "dev": true, "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { @@ -4049,6 +4074,7 @@ "version": "2.8.1", "resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, "license": "0BSD", "optional": true }, @@ -4926,17 +4952,25 @@ }, "packages/cdp-adapter": { "name": "@open-chatgpt-skin/cdp-adapter", - "version": "0.2.0", + "version": "0.3.0-alpha.1", + "dependencies": { + "@open-chatgpt-skin/theme-core": "0.3.0-alpha.1", + "@open-chatgpt-skin/theme-schema": "0.3.0-alpha.1" + } + }, + "packages/runtime-contract": { + "name": "@open-chatgpt-skin/runtime-contract", + "version": "0.3.0-alpha.1", "dependencies": { - "@open-chatgpt-skin/theme-core": "0.2.0", - "@open-chatgpt-skin/theme-schema": "0.2.0" + "@open-chatgpt-skin/theme-schema": "0.3.0-alpha.1", + "zod": "^3.25.76" } }, "packages/theme-core": { "name": "@open-chatgpt-skin/theme-core", - "version": "0.2.0", + "version": "0.3.0-alpha.1", "dependencies": { - "@open-chatgpt-skin/theme-schema": "0.2.0", + "@open-chatgpt-skin/theme-schema": "0.3.0-alpha.1", "fflate": "^0.8.3", "zod": "^3.25.76" }, @@ -4946,50 +4980,17 @@ }, "packages/theme-schema": { "name": "@open-chatgpt-skin/theme-schema", - "version": "0.2.0", + "version": "0.3.0-alpha.1", "dependencies": { "zod": "^3.25.76" } }, "packages/theme-studio-core": { "name": "@open-chatgpt-skin/theme-studio-core", - "version": "0.2.0", - "dependencies": { - "@open-chatgpt-skin/theme-schema": "0.2.0", - "zod": "^3.25.76" - } - }, - "runtime/theme-studio-service": { - "name": "@open-chatgpt-skin/theme-studio-service", - "version": "0.2.0", - "dependencies": { - "@open-chatgpt-skin/cdp-adapter": "0.2.0", - "@open-chatgpt-skin/theme-core": "0.2.0", - "@open-chatgpt-skin/theme-schema": "0.2.0", - "@open-chatgpt-skin/theme-studio-core": "0.2.0", - "@open-chatgpt-skin/windows-runtime": "0.2.0", - "sharp": "^0.34.5", - "zod": "^3.25.76" - }, - "bin": { - "open-chatgpt-skin-studio": "dist/cli.js" - }, - "devDependencies": { - "tsx": "^4.23.1", - "vite": "6.1.0" - } - }, - "runtime/windows": { - "name": "@open-chatgpt-skin/windows-runtime", - "version": "0.2.0", + "version": "0.3.0-alpha.1", "dependencies": { - "@open-chatgpt-skin/cdp-adapter": "0.2.0", - "@open-chatgpt-skin/theme-core": "0.2.0", - "@open-chatgpt-skin/theme-schema": "0.2.0", + "@open-chatgpt-skin/theme-schema": "0.3.0-alpha.1", "zod": "^3.25.76" - }, - "bin": { - "open-chatgpt-skin-runtime": "dist/cli.js" } } } diff --git a/package.json b/package.json index 445ec8f..86357c4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "open-chatgpt-skin", - "version": "0.2.0", + "version": "0.3.0-alpha.1", "private": true, "type": "module", "repository": { @@ -13,8 +13,7 @@ }, "workspaces": [ "apps/*", - "packages/*", - "runtime/*" + "packages/*" ], "scripts": { "build": "tsc -b", @@ -23,32 +22,23 @@ "contracts:build": "tsx --tsconfig tsconfig.scripts.json scripts/contracts/build.ts", "contracts:verify": "tsx --tsconfig tsconfig.scripts.json scripts/contracts/verify.ts", "contracts:baseline": "tsx --tsconfig tsconfig.scripts.json scripts/contracts/baseline-runner.ts", - "go:spike:test": "go -C host/go test -buildvcs=false -tags nodynamic ./... -count=1 -timeout 60s", + "go:test": "go -C host/go test -buildvcs=false -tags nodynamic ./... -count=1 -timeout 60s", + "go:verify": "npm run go:test && go -C host/go vet -buildvcs=false -tags nodynamic ./...", "go:cdp-adapter:build": "tsx --tsconfig tsconfig.scripts.json scripts/build-go-cdp-adapter.ts", - "go:spike:package": "tsx --tsconfig tsconfig.scripts.json scripts/release/build-go-spike.ts", - "go:spike:acceptance": "tsx --tsconfig tsconfig.scripts.json scripts/release/accept-go-spike.ts", - "go:spike:merge": "tsx --tsconfig tsconfig.scripts.json scripts/release/merge-go-spike.ts", + "release:build": "tsx --tsconfig tsconfig.scripts.json scripts/release/build-go-release.ts", + "release:acceptance": "tsx --tsconfig tsconfig.scripts.json scripts/release/accept-go-release.ts", + "release:merge": "tsx --tsconfig tsconfig.scripts.json scripts/release/merge-go-release.ts", "typecheck": "tsc -b --pretty false", "test": "vitest run", "test:watch": "vitest", - "runtime": "npm run build && node runtime/windows/dist/cli.js", - "runtime:probe": "npm run build && node runtime/windows/dist/probe-cli.js", - "runtime:acceptance": "npm run build && node runtime/windows/dist/acceptance-cli.js", + "runtime": "go -C host/go run -buildvcs=false -tags nodynamic ./cmd/openchatgptskin runtime", "studio:build": "npm run build -w @open-chatgpt-skin/theme-studio", - "release:stage": "tsx scripts/release/stage-release.ts", - "studio:dev:go": "go -C host/go run -buildvcs=false -tags nodynamic ./cmd/openchatgptskin studio --dev --vite-origin http://127.0.0.1:5173", - "release:acceptance": "tsx scripts/release/accept-release.ts", - "release:package": "tsx scripts/release/package-release.ts", - "release:acceptance:archive": "tsx scripts/release/accept-portable.ts", "release:checksums": "tsx scripts/release/write-checksums.ts", "release:version": "tsx scripts/release/verify-version.ts", - "release:node": "tsx scripts/release/fetch-node-runtime.ts", - "release:windows": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts/release/build-windows-local.ps1", - "release:macos": "tsx scripts/release/build-macos-distribution.ts", - "release:acceptance:macos": "tsx scripts/release/accept-macos-distribution.ts", - "studio:dev": "npm run build && npm run dev -w @open-chatgpt-skin/theme-studio-service", - "verify": "npm run contracts:verify && npm run build && npm run test && npm run typecheck", - "verify:runtime": "npm run build && npm run test && npm run typecheck", + "release:windows": "npm run release:build -- --native-only --output artifacts/windows-x64 && npm run release:checksums -- --output artifacts/windows-x64", + "release:macos": "npm run release:build -- --native-only --output artifacts/macos-native && npm run release:checksums -- --output artifacts/macos-native", + "studio:dev": "tsx scripts/dev/start-go-studio.ts", + "verify": "npm run contracts:verify && npm run build && npm run test && npm run typecheck && npm run go:verify", "verify:foundation": "npm run themes:build && npm run build && npm run test && npm run typecheck && node packages/theme-core/dist/cli.js catalog --root themes" }, "engines": { @@ -65,6 +55,7 @@ "typescript": "^5.9.3", "vitest": "^3.2.7", "ws": "^8.18.3", + "zod": "^3.25.76", "zod-to-json-schema": "^3.25.2" } } diff --git a/packages/cdp-adapter/package.json b/packages/cdp-adapter/package.json index 2258ee2..73edae2 100644 --- a/packages/cdp-adapter/package.json +++ b/packages/cdp-adapter/package.json @@ -1,6 +1,6 @@ { "name": "@open-chatgpt-skin/cdp-adapter", - "version": "0.2.0", + "version": "0.3.0-alpha.1", "type": "module", "exports": "./dist/index.js", "types": "./dist/index.d.ts", @@ -11,7 +11,7 @@ "build": "tsc -b" }, "dependencies": { - "@open-chatgpt-skin/theme-core": "0.2.0", - "@open-chatgpt-skin/theme-schema": "0.2.0" + "@open-chatgpt-skin/theme-core": "0.3.0-alpha.1", + "@open-chatgpt-skin/theme-schema": "0.3.0-alpha.1" } } diff --git a/packages/runtime-contract/package.json b/packages/runtime-contract/package.json new file mode 100644 index 0000000..682cf46 --- /dev/null +++ b/packages/runtime-contract/package.json @@ -0,0 +1,17 @@ +{ + "name": "@open-chatgpt-skin/runtime-contract", + "version": "0.3.0-alpha.1", + "type": "module", + "exports": "./dist/index.js", + "types": "./dist/index.d.ts", + "files": [ + "dist" + ], + "scripts": { + "build": "tsc -b" + }, + "dependencies": { + "@open-chatgpt-skin/theme-schema": "0.3.0-alpha.1", + "zod": "^3.25.76" + } +} diff --git a/packages/runtime-contract/src/index.ts b/packages/runtime-contract/src/index.ts new file mode 100644 index 0000000..e188b44 --- /dev/null +++ b/packages/runtime-contract/src/index.ts @@ -0,0 +1,214 @@ +import { z } from "zod"; +import { ThemeIdSchema, ThemeVersionSchema } from "@open-chatgpt-skin/theme-schema"; + +export const CONTROL_PROTOCOL_VERSION = 1 as const; +export const CONTROL_MAX_FRAME_BYTES = 64 * 1024; +export const CONTROL_COMMANDS = ["launch", "status", "switch", "pause", "resume", "restore"] as const; + +export const RUNTIME_ERROR_CODES = [ + "CODEX_NOT_INSTALLED", "CODEX_DISCOVERY_REQUIRES_BOOTSTRAP", "CODEX_IDENTITY_INVALID", + "CODEX_ALREADY_RUNNING_UNMANAGED", "CODEX_LAUNCH_FAILED", "CODEX_WINDOW_ACTIVATION_FAILED", + "PROCESS_INSPECTION_DENIED", "CDP_NOT_READY", "CDP_ENDPOINT_UNSAFE", "CDP_PROCESS_MISMATCH", + "CDP_TARGET_NOT_FOUND", "CDP_TARGET_AMBIGUOUS", "ADAPTER_INCOMPATIBLE", "THEME_APPLY_FAILED", + "THEME_VERIFY_FAILED", "THEME_CLEANUP_FAILED", "THEME_SWITCH_FAILED", "THEME_ROLLBACK_FAILED", + "THEME_RUNTIME_TOO_LARGE", "THEME_SCHEMA_VERSION_UNSUPPORTED", "THEME_WELCOME_INVALID", + "THEME_DISPLAY_FONT_MISSING", "THEME_COMPOSITION_INVALID", "THEME_HOME_WELCOME_UNSUPPORTED", + "THEME_REQUIRED_LAYER_UNRESOLVED", "THEME_NOT_FOUND", "THEME_NOT_READY", "RUNTIME_SESSION_STALE", + "RUNTIME_INVALID_STATE", "RUNTIME_BUSY", "RUNTIME_ENVIRONMENT_INVALID", "RUNTIME_CONTROL_UNAVAILABLE", + "RESTORE_AWAITING_EXIT", "PROBE_EXIT_PENDING", "PROBE_FINALIZE_REQUIRED", + "PROBE_PENDING_SESSION_INVALID", "INTERNAL", +] as const; + +export const RuntimeStatusSchema = z.enum([ + "launching", "active", "paused", "paused-incompatible", "recovery-required", "restoring", + "restored-awaiting-exit", "restored-cleanup-required", +]); +export type RuntimeStatus = z.infer; + +export const RuntimeOperationSchema = z.enum(["launch", "switch", "pause", "resume", "restore"]); +export const RuntimeThemeRefSchema = z.object({ id: ThemeIdSchema, version: ThemeVersionSchema }).strict(); +export type RuntimeThemeRef = z.infer; +export const PendingOperationSchema = z.object({ + kind: RuntimeOperationSchema, + requestId: z.string().uuid(), + startedAt: z.string().datetime(), + previousStatus: RuntimeStatusSchema.nullable(), + previousSelectedTheme: RuntimeThemeRefSchema.nullable(), + previousAppliedTheme: RuntimeThemeRefSchema.nullable(), + candidateTheme: RuntimeThemeRefSchema.nullable(), +}).strict(); +export const RuntimeProcessSchema = z.object({ pid: z.number().int().positive(), startedAt: z.string().datetime() }).strict(); +export const RuntimeCodexIdentitySchema = z.object({ + rootPid: z.number().int().positive(), + startedAt: z.string().datetime(), + executablePath: z.string().min(1), + packageRoot: z.string().min(1), + packageVersion: z.string().regex(/^\d+\.\d+\.\d+\.\d+$/), +}).strict(); +export const RuntimeCdpIdentitySchema = z.object({ + host: z.literal("127.0.0.1"), + port: z.number().int().min(1).max(65_535), +}).strict(); +export const RuntimeAdapterIdentitySchema = z.object({ id: z.string().min(1), version: z.literal(1) }).strict(); + +export const ControlCommandSchema = z.enum(CONTROL_COMMANDS); +export const RuntimeStatusViewSchema = z.object({ + status: z.union([RuntimeStatusSchema, z.literal("stopped")]), + controllerAvailable: z.boolean(), + selectedTheme: RuntimeThemeRefSchema.nullable(), + appliedTheme: RuntimeThemeRefSchema.nullable(), + skinApplied: z.boolean().nullable(), + packageVersion: z.string().max(40).nullable(), + operation: RuntimeOperationSchema.nullable(), + nextAction: z.string().max(500), +}).strict(); +export const ControlErrorSchema = z.object({ + code: z.enum(RUNTIME_ERROR_CODES), + message: z.string().min(1).max(500), + nextAction: z.string().max(500).optional(), +}).strict(); +export const ControlResponseSchema = z.discriminatedUnion("ok", [ + z.object({ + protocolVersion: z.literal(CONTROL_PROTOCOL_VERSION), requestId: z.string().uuid(), + ok: z.literal(true), result: RuntimeStatusViewSchema, + }).strict(), + z.object({ + protocolVersion: z.literal(CONTROL_PROTOCOL_VERSION), requestId: z.string().uuid(), + ok: z.literal(false), error: ControlErrorSchema, + }).strict(), +]); + +const emptyParams = z.object({}).strict(); +const RUNTIME_BUILTIN_THEME_IDS = new Set([ + "future-idol-cyan", "glacier-aurora", "mountain-mist", "rose-carpet-star", "yua-mikami-starlight", +]); +const themeParams = z.object({ themeId: ThemeIdSchema, themeVersion: ThemeVersionSchema.optional() }).strict() + .superRefine((value, context) => { + if (value.themeVersion === undefined && !RUNTIME_BUILTIN_THEME_IDS.has(value.themeId)) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["themeVersion"], + message: "personal themes require an exact version", + }); + } + }); +export const ControlRequestSchema = z.discriminatedUnion("command", [ + z.object({ protocolVersion: z.literal(1), requestId: z.string().uuid(), command: z.literal("launch"), params: themeParams }).strict(), + z.object({ protocolVersion: z.literal(1), requestId: z.string().uuid(), command: z.literal("status"), params: emptyParams }).strict(), + z.object({ protocolVersion: z.literal(1), requestId: z.string().uuid(), command: z.literal("switch"), params: themeParams }).strict(), + z.object({ protocolVersion: z.literal(1), requestId: z.string().uuid(), command: z.literal("pause"), params: emptyParams }).strict(), + z.object({ protocolVersion: z.literal(1), requestId: z.string().uuid(), command: z.literal("resume"), params: emptyParams }).strict(), + z.object({ protocolVersion: z.literal(1), requestId: z.string().uuid(), command: z.literal("restore"), params: emptyParams }).strict(), +]); + +export const RecentRequestSchema = z.object({ + requestId: z.string().uuid(), command: ControlCommandSchema, response: ControlResponseSchema, + completedAt: z.string().datetime(), +}).strict(); + +function sameTheme(left: RuntimeThemeRef | null, right: RuntimeThemeRef | null): boolean { + return left !== null && right !== null && left.id === right.id && left.version === right.version; +} + +export const RuntimeSessionStateSchema = z.object({ + schemaVersion: z.literal(2), + sessionId: z.string().uuid(), + status: RuntimeStatusSchema, + runtime: RuntimeProcessSchema, + codex: RuntimeCodexIdentitySchema.nullable(), + cdp: RuntimeCdpIdentitySchema.nullable(), + adapter: RuntimeAdapterIdentitySchema.nullable(), + selectedTheme: RuntimeThemeRefSchema, + appliedTheme: RuntimeThemeRefSchema.nullable(), + skinApplied: z.boolean().nullable(), + pendingOperation: PendingOperationSchema.nullable(), + recentRequests: z.array(RecentRequestSchema).max(32), + createdAt: z.string().datetime(), + updatedAt: z.string().datetime(), +}).strict().superRefine((state, context) => { + const issue = (message: string, path: (string | number)[] = []) => + context.addIssue({ code: z.ZodIssueCode.custom, message, path }); + if (state.status === "active") { + if (!state.codex || !state.cdp || !state.adapter) issue("active state requires complete managed identities"); + if (state.skinApplied !== true || !sameTheme(state.selectedTheme, state.appliedTheme)) { + issue("active state requires the selected theme to be verified as applied"); + } + } + if (state.status !== "launching" && (!state.codex || !state.cdp)) issue(`${state.status} requires exact Codex and CDP identities`); + if (["active", "paused", "paused-incompatible"].includes(state.status) && !state.adapter) issue(`${state.status} requires the last verified Adapter identity`); + if (["paused", "paused-incompatible", "restored-awaiting-exit", "restored-cleanup-required"].includes(state.status) && + (state.appliedTheme !== null || state.skinApplied !== false)) issue(`${state.status} requires verified official appearance`); + if (state.status === "recovery-required" && (state.appliedTheme !== null || state.skinApplied !== null)) issue("recovery-required must represent unknown appearance"); + if (state.status === "restoring" && state.pendingOperation?.kind !== "restore") issue("restoring requires a restore pending operation"); + const requests = new Map(); + state.recentRequests.forEach((record, index) => { + if (record.response.requestId !== record.requestId) issue("recent response request ID must match its record", ["recentRequests", index]); + const previous = requests.get(record.requestId); + if (previous && previous !== record.command) issue("recent request IDs cannot belong to different commands", ["recentRequests", index]); + requests.set(record.requestId, record.command); + }); +}); + +export const ControllerLockRecordSchema = z.object({ + schemaVersion: z.literal(1), pid: z.number().int().positive(), startedAt: z.string().datetime(), +}).strict(); + +export const TrustedWindowsCodexInstallSchema = z.object({ + schemaVersion: z.literal(1), packageRoot: z.string().min(1), entryPath: z.string().min(1), + identityName: z.literal("OpenAI.Codex"), packageVersion: z.string().regex(/^\d+\.\d+\.\d+\.\d+$/), + packagePublisher: z.string().min(1), appId: z.literal("App"), entryRelativePath: z.literal("app/ChatGPT.exe"), + entryPoint: z.literal("Windows.FullTrustApplication"), packageSignatureStatus: z.literal("Valid"), + packageSignerCommonName: z.literal("50BDFD77-8903-4850-9FFE-6E8522F64D5B"), catalogSignatureStatus: z.literal("Valid"), + catalogSignerCommonName: z.literal("50BDFD77-8903-4850-9FFE-6E8522F64D5B"), entryBlockMapValid: z.literal(true), + resourceSignatureStatus: z.literal("Valid"), resourceSignerCommonName: z.literal("OpenAI OpCo, LLC"), + verifiedAt: z.string().datetime(), +}).strict(); +export const TrustedMacOsCodexInstallSchema = z.object({ + schemaVersion: z.literal(1), packageRoot: z.string().endsWith("/Codex.app"), entryPath: z.string().min(1), + identityName: z.literal("OpenAI.Codex"), packageVersion: z.string().regex(/^\d+\.\d+\.\d+\.\d+$/), + packagePublisher: z.literal("2DC432GLL2"), appId: z.literal("com.openai.codex"), + entryRelativePath: z.string().regex(/^Contents\/MacOS\/[^/]+$/), entryPoint: z.literal("macOS.Application"), + packageSignatureStatus: z.literal("Valid"), packageSignerCommonName: z.literal("2DC432GLL2"), + catalogSignatureStatus: z.literal("Valid"), catalogSignerCommonName: z.literal("Notarized Developer ID"), + entryBlockMapValid: z.literal(true), resourceSignatureStatus: z.literal("Valid"), + resourceSignerCommonName: z.literal("OpenAI, L.L.C."), verifiedAt: z.string().datetime(), +}).strict(); +export const TrustedCodexInstallSchema = z.union([TrustedWindowsCodexInstallSchema, TrustedMacOsCodexInstallSchema]); + +export const RUNTIME_STATE_TRANSITIONS: Readonly> = { + launching: ["launching", "active", "recovery-required", "restored-awaiting-exit"], + active: ["active", "paused", "paused-incompatible", "recovery-required", "restoring"], + paused: ["paused", "active", "paused-incompatible", "recovery-required", "restoring"], + "paused-incompatible": ["paused-incompatible", "paused", "active", "recovery-required", "restoring"], + "recovery-required": ["recovery-required", "restoring"], + restoring: ["restoring", "active", "recovery-required", "restored-awaiting-exit"], + "restored-awaiting-exit": ["restored-awaiting-exit", "restored-cleanup-required"], + "restored-cleanup-required": ["restored-cleanup-required"], +}; + +export const RUNTIME_RECOVERY_SEMANTIC_CASES = [ + { name: "stable active appearance", valid: true, sourceStatus: "active", expectedStatus: "active" }, + { name: "stable paused appearance", valid: true, sourceStatus: "paused", expectedStatus: "paused" }, + { name: "interrupted launch restores official appearance", valid: true, operation: "launch", expectedStatus: "restored-awaiting-exit" }, + { name: "interrupted restore retries official appearance", valid: true, operation: "restore", expectedStatus: "restored-awaiting-exit" }, + { name: "failed cleanup marks appearance unknown", valid: true, cleanupSucceeded: false, expectedStatus: "recovery-required" }, + { name: "managed process identity changed", valid: false, expectedErrorCode: "RUNTIME_SESSION_STALE", expectedPath: "/codex/startedAt" }, +] as const; + +export const RUNTIME_CONTROL_CONTRACT = { + protocolVersion: CONTROL_PROTOCOL_VERSION, + frame: { encoding: "json", byteOrder: "little-endian", lengthPrefixBytes: 4, maxPayloadBytes: CONTROL_MAX_FRAME_BYTES }, + commands: CONTROL_COMMANDS, + readOnlyCommands: ["status"], mutationConcurrency: "serialized", statusConcurrency: "parallel-with-mutation", + requestIdSemantics: "same-id-same-command-replays; same-id-different-command-rejected", + afterResponseCommands: ["launch", "restore"], errors: RUNTIME_ERROR_CODES, +} as const; + +export const RUNTIME_CONTROL_SEMANTIC_CASES = [ + { name: "status during mutation", valid: true, command: "status", expectedConcurrency: "parallel-with-mutation" }, + { name: "duplicate request replay", valid: true, command: "switch", expectedBehavior: "replay-stored-response" }, + { name: "request ID reused for another command", valid: false, command: "pause", expectedErrorCode: "RUNTIME_INVALID_STATE", expectedPath: "/requestId" }, + { name: "oversized frame", valid: false, expectedErrorCode: "RUNTIME_CONTROL_UNAVAILABLE", expectedPath: "/frame/length" }, + { name: "restore callback after response", valid: true, command: "restore", expectedBehavior: "after-response" }, + { name: "personal theme without version", valid: false, command: "launch", expectedErrorCode: "THEME_NOT_FOUND", expectedPath: "/params/themeVersion" }, +] as const; diff --git a/runtime/windows/tsconfig.json b/packages/runtime-contract/tsconfig.json similarity index 55% rename from runtime/windows/tsconfig.json rename to packages/runtime-contract/tsconfig.json index fc1121a..3de07d6 100644 --- a/runtime/windows/tsconfig.json +++ b/packages/runtime-contract/tsconfig.json @@ -5,9 +5,7 @@ "outDir": "dist" }, "references": [ - { "path": "../../packages/theme-schema" }, - { "path": "../../packages/theme-core" }, - { "path": "../../packages/cdp-adapter" } + { "path": "../theme-schema" } ], "include": ["src/**/*.ts"] } diff --git a/packages/theme-core/package.json b/packages/theme-core/package.json index fe945b9..76a8543 100644 --- a/packages/theme-core/package.json +++ b/packages/theme-core/package.json @@ -1,6 +1,6 @@ { "name": "@open-chatgpt-skin/theme-core", - "version": "0.2.0", + "version": "0.3.0-alpha.1", "type": "module", "exports": "./dist/index.js", "bin": { "open-chatgpt-skin-theme": "./dist/cli.js" }, @@ -12,7 +12,7 @@ "build": "tsc -b" }, "dependencies": { - "@open-chatgpt-skin/theme-schema": "0.2.0", + "@open-chatgpt-skin/theme-schema": "0.3.0-alpha.1", "fflate": "^0.8.3", "zod": "^3.25.76" } diff --git a/packages/theme-schema/package.json b/packages/theme-schema/package.json index 6621b33..92d3e4d 100644 --- a/packages/theme-schema/package.json +++ b/packages/theme-schema/package.json @@ -1,6 +1,6 @@ { "name": "@open-chatgpt-skin/theme-schema", - "version": "0.2.0", + "version": "0.3.0-alpha.1", "type": "module", "exports": "./dist/index.js", "types": "./dist/index.d.ts", diff --git a/packages/theme-studio-core/package.json b/packages/theme-studio-core/package.json index 5749f31..fd549fd 100644 --- a/packages/theme-studio-core/package.json +++ b/packages/theme-studio-core/package.json @@ -1,6 +1,6 @@ { "name": "@open-chatgpt-skin/theme-studio-core", - "version": "0.2.0", + "version": "0.3.0-alpha.1", "type": "module", "exports": "./dist/index.js", "types": "./dist/index.d.ts", @@ -11,7 +11,7 @@ "build": "tsc -b" }, "dependencies": { - "@open-chatgpt-skin/theme-schema": "0.2.0", + "@open-chatgpt-skin/theme-schema": "0.3.0-alpha.1", "zod": "^3.25.76" } } diff --git a/packages/theme-studio-core/src/index.ts b/packages/theme-studio-core/src/index.ts index fcb5bc9..58c43d0 100644 --- a/packages/theme-studio-core/src/index.ts +++ b/packages/theme-studio-core/src/index.ts @@ -2,6 +2,7 @@ export * from "./contracts.js"; export * from "./errors.js"; export * from "./http-contract.js"; export * from "./personal-theme.js"; +export * from "./persistence.js"; export * from "./project.js"; export * from "./security.js"; export * from "./validation.js"; diff --git a/packages/theme-studio-core/src/persistence.ts b/packages/theme-studio-core/src/persistence.ts new file mode 100644 index 0000000..a0a0467 --- /dev/null +++ b/packages/theme-studio-core/src/persistence.ts @@ -0,0 +1,25 @@ +import { z } from "zod"; +import { ThemeDraftDocumentSchema } from "@open-chatgpt-skin/theme-schema"; +import { StudioThemeRefSchema } from "./contracts.js"; + +export const STUDIO_DRAFT_HISTORY_LIMIT = 50; + +export const DraftRecordSchema = z.object({ + schemaVersion: z.literal(1), + draftId: z.string().uuid(), + theme: ThemeDraftDocumentSchema, + revision: z.number().int().nonnegative(), + updatedAt: z.string().datetime(), + savedRef: StudioThemeRefSchema.nullable(), + dirty: z.boolean(), + past: z.array(ThemeDraftDocumentSchema).max(STUDIO_DRAFT_HISTORY_LIMIT), + future: z.array(ThemeDraftDocumentSchema).max(STUDIO_DRAFT_HISTORY_LIMIT), +}).strict(); + +export const PersistedDraftRecordSchema = DraftRecordSchema.omit({ theme: true, past: true, future: true }).extend({ + theme: z.unknown(), + past: z.array(z.unknown()).max(STUDIO_DRAFT_HISTORY_LIMIT), + future: z.array(z.unknown()).max(STUDIO_DRAFT_HISTORY_LIMIT), +}).strict(); + +export type DraftRecord = z.infer; diff --git a/runtime/theme-studio-service/package.json b/runtime/theme-studio-service/package.json deleted file mode 100644 index 4ddb099..0000000 --- a/runtime/theme-studio-service/package.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "name": "@open-chatgpt-skin/theme-studio-service", - "version": "0.2.0", - "type": "module", - "exports": "./dist/index.js", - "types": "./dist/index.d.ts", - "bin": { - "open-chatgpt-skin-studio": "./dist/cli.js" - }, - "files": [ - "dist" - ], - "scripts": { - "build": "tsc -b", - "dev": "tsx src/cli.ts --dev" - }, - "dependencies": { - "@open-chatgpt-skin/cdp-adapter": "0.2.0", - "@open-chatgpt-skin/theme-core": "0.2.0", - "@open-chatgpt-skin/theme-schema": "0.2.0", - "@open-chatgpt-skin/theme-studio-core": "0.2.0", - "@open-chatgpt-skin/windows-runtime": "0.2.0", - "sharp": "^0.34.5", - "zod": "^3.25.76" - }, - "devDependencies": { - "tsx": "^4.23.1", - "vite": "6.1.0" - } -} diff --git a/runtime/theme-studio-service/src/cli.ts b/runtime/theme-studio-service/src/cli.ts deleted file mode 100644 index f3adc5a..0000000 --- a/runtime/theme-studio-service/src/cli.ts +++ /dev/null @@ -1,186 +0,0 @@ -#!/usr/bin/env node -import { spawn } from "node:child_process"; -import { appendFile, mkdir } from "node:fs/promises"; -import { join } from "node:path"; -import { THEME_CORE_VERSION } from "@open-chatgpt-skin/theme-core"; -import { StudioError } from "@open-chatgpt-skin/theme-studio-core"; -import { - prepareProductionRuntimePaths, - RuntimeError, - type RuntimePaths, -} from "@open-chatgpt-skin/windows-runtime"; -import { - applyProductionRuntimeTheme, - readProductionRuntimeStatus, - restoreProductionRuntimeTheme, -} from "./runtime-status.js"; -import { startThemeStudioProductionHost } from "./production-host.js"; -import { ThemeStudioWorkspace } from "./workspace.js"; - -interface CliOptions { - readonly development: boolean; - readonly openBrowser: boolean; -} - -interface StartupFailure { - readonly code: string; - readonly message: string; - readonly nextAction: string; -} - -const STUDIO_VERSION = process.env.OPEN_CHATGPT_SKIN_VERSION ?? - THEME_CORE_VERSION; - -function parseOptions(args: readonly string[]): CliOptions { - const allowed = new Set(["--dev", "--no-open"]); - const unsupported = args.find((argument) => !allowed.has(argument)); - if (unsupported) { - throw new StudioError("INTERNAL", `Unsupported Studio option: ${unsupported}`); - } - return { - development: args.includes("--dev"), - openBrowser: !args.includes("--no-open") && !args.includes("--dev"), - }; -} - -async function openSystemBrowser(url: string): Promise { - const command = process.platform === "win32" - ? { file: "rundll32.exe", args: ["url.dll,FileProtocolHandler", url] } - : { file: "open", args: [url] }; - await new Promise((resolve, reject) => { - const child = spawn(command.file, command.args, { - detached: true, - stdio: "ignore", - windowsHide: true, - }); - child.once("error", reject); - child.once("spawn", () => { - child.unref(); - resolve(); - }); - }); -} - -function startupFailure(error: unknown): StartupFailure { - if (error instanceof StudioError) { - return { - code: error.code, - message: error.message, - nextAction: error.nextAction ?? - "Review the startup log and retry OpenChatGPTSkin.", - }; - } - if (error instanceof RuntimeError) { - return { - code: error.code, - message: "The OpenChatGPTSkin Runtime environment is not ready.", - nextAction: error.nextAction ?? - "Review the startup log and retry OpenChatGPTSkin.", - }; - } - return { - code: "INTERNAL", - message: "Theme Studio failed to start.", - nextAction: "Review the startup log and report this error code if retry still fails.", - }; -} - -function publicStartupLogPath(): string { - return process.platform === "win32" - ? "%LOCALAPPDATA%\\OpenChatGPTSkin\\runtime\\logs\\theme-studio.jsonl" - : "~/Library/Application Support/OpenChatGPTSkin/runtime/logs/theme-studio.jsonl"; -} - -async function writeFailure( - error: unknown, - paths?: RuntimePaths, -): Promise { - const failure = startupFailure(error); - let log: string | undefined; - let logWriteFailed = false; - if (paths) { - try { - await mkdir(paths.logDirectory, { recursive: true }); - await appendFile(join(paths.logDirectory, "theme-studio.jsonl"), `${JSON.stringify({ - schemaVersion: 1, - timestamp: new Date().toISOString(), - event: "studio-startup-error", - studioVersion: STUDIO_VERSION, - errorCode: failure.code, - })}\n`, "utf8"); - log = publicStartupLogPath(); - } catch { - logWriteFailed = true; - } - } - process.stderr.write(`${JSON.stringify({ - error: { - ...failure, - ...(log ? { log } : {}), - ...(logWriteFailed ? { logWriteFailed: true } : {}), - }, - })}\n`); -} - -async function main(): Promise { - let paths: RuntimePaths | undefined; - try { - const options = parseOptions(process.argv.slice(2)); - paths = await prepareProductionRuntimePaths(); - const workspace = new ThemeStudioWorkspace({ - paths, - runtimeStatus: readProductionRuntimeStatus, - applyRuntimeTheme: applyProductionRuntimeTheme, - restoreRuntimeTheme: restoreProductionRuntimeTheme, - }); - await workspace.initialize(); - const host = options.development - ? await import("./vite-host.js").then(({ startThemeStudioDevHost }) => - startThemeStudioDevHost({ - runtimeStatus: readProductionRuntimeStatus, - workspace, - })) - : await startThemeStudioProductionHost({ - indexHtmlPath: join( - paths.installRoot, - "apps", - "theme-studio", - "dist", - "index.html", - ), - runtimeStatus: readProductionRuntimeStatus, - workspace, - studioVersion: STUDIO_VERSION, - }); - process.stdout.write(`${JSON.stringify({ url: host.bootstrapUrl })}\n`); - if (options.openBrowser) { - try { - await openSystemBrowser(host.bootstrapUrl); - } catch { - await host.close(); - throw new StudioError( - "INTERNAL", - "The system browser could not be opened.", - "Open the startup URL in your default browser or retry OpenChatGPTSkin.", - ); - } - } - - let closePromise: Promise | null = null; - const close = () => { - if (closePromise) return closePromise; - closePromise = host.close().catch((error: unknown) => { - process.exitCode = 1; - return writeFailure(error, paths); - }); - return closePromise; - }; - process.once("SIGINT", () => { void close(); }); - process.once("SIGTERM", () => { void close(); }); - } catch (error) { - process.exitCode = 1; - await writeFailure(error, paths); - } -} - -void main(); diff --git a/runtime/theme-studio-service/src/http.ts b/runtime/theme-studio-service/src/http.ts deleted file mode 100644 index d9b3268..0000000 --- a/runtime/theme-studio-service/src/http.ts +++ /dev/null @@ -1,101 +0,0 @@ -import type { IncomingMessage, ServerResponse } from "node:http"; -import { - STUDIO_BODY_LIMITS, - STUDIO_RESPONSE_POLICIES, - StudioError, -} from "@open-chatgpt-skin/theme-studio-core"; - -export const STUDIO_JSON_LIMIT_BYTES = STUDIO_BODY_LIMITS.jsonBytes; - -export async function readBoundedJson( - request: IncomingMessage, - limit: number = STUDIO_JSON_LIMIT_BYTES, -): Promise { - const chunks: Buffer[] = []; - let received = 0; - for await (const chunk of request) { - const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); - received += bytes.length; - if (received > limit) { - throw new StudioError( - "STUDIO_REQUEST_TOO_LARGE", - "JSON request exceeds its limit", - ); - } - chunks.push(bytes); - } - try { - return JSON.parse(Buffer.concat(chunks).toString("utf8")); - } catch { - throw new StudioError( - "STUDIO_REQUEST_INVALID", - "Request is not valid JSON", - ); - } -} - -export async function readBoundedBytes( - request: IncomingMessage, - limit: number, -): Promise { - if (!Number.isSafeInteger(limit) || limit < 1) { - throw new StudioError("INTERNAL", "Binary request limit is invalid"); - } - const contentLength = request.headers["content-length"]; - if (contentLength !== undefined && Number(contentLength) > limit) { - throw new StudioError("STUDIO_REQUEST_TOO_LARGE", "Binary request exceeds its limit"); - } - const chunks: Buffer[] = []; - let received = 0; - for await (const chunk of request) { - const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); - received += bytes.length; - if (received > limit) { - throw new StudioError("STUDIO_REQUEST_TOO_LARGE", "Binary request exceeds its limit"); - } - chunks.push(bytes); - } - return Buffer.concat(chunks); -} - -export function assertExactOrigin( - request: IncomingMessage, - origin: string, -): void { - if (request.headers.origin !== origin) { - throw new StudioError( - "STUDIO_ORIGIN_REJECTED", - "Origin is not authorized", - ); - } -} - -export function writeJson( - response: ServerResponse, - status: number, - body: unknown, -): void { - response.writeHead(status, { - "Content-Type": STUDIO_RESPONSE_POLICIES.json.contentType, - "Cache-Control": STUDIO_RESPONSE_POLICIES.json.cacheControl, - "X-Content-Type-Options": STUDIO_RESPONSE_POLICIES.json.contentTypeOptions, - }); - response.end(`${JSON.stringify(body)}\n`); -} - -export function writeBytes( - response: ServerResponse, - status: number, - body: Uint8Array, - contentType: string, - headers: Readonly> = {}, -): void { - response.writeHead(status, { - "Content-Type": contentType, - "Content-Length": String(body.length), - "Cache-Control": STUDIO_RESPONSE_POLICIES.binary.cacheControl, - "X-Content-Type-Options": STUDIO_RESPONSE_POLICIES.binary.contentTypeOptions, - ...headers, - }); - response.end(body); -} diff --git a/runtime/theme-studio-service/src/index.ts b/runtime/theme-studio-service/src/index.ts deleted file mode 100644 index dc88809..0000000 --- a/runtime/theme-studio-service/src/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -export * from "./http.js"; -export * from "./production-host.js"; -export * from "./runtime-status.js"; -export * from "./server.js"; -export * from "./session.js"; -export * from "./vite-host.js"; -export * from "./workspace.js"; diff --git a/runtime/theme-studio-service/src/production-host.ts b/runtime/theme-studio-service/src/production-host.ts deleted file mode 100644 index 5200361..0000000 --- a/runtime/theme-studio-service/src/production-host.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { randomBytes } from "node:crypto"; -import { readFile } from "node:fs/promises"; -import { THEME_CORE_VERSION } from "@open-chatgpt-skin/theme-core"; -import type { StudioRuntimeStatus } from "@open-chatgpt-skin/theme-studio-core"; -import { - OPEN_CHATGPT_SKIN_REPOSITORY_URL, - STUDIO_CSP_NONCE_PLACEHOLDER, - StudioError, -} from "@open-chatgpt-skin/theme-studio-core"; -import { - startThemeStudioServer, - type RunningThemeStudioServer, -} from "./server.js"; -import type { ThemeStudioWorkspace } from "./workspace.js"; - -export interface ThemeStudioProductionHostDependencies { - readonly indexHtmlPath: string; - readonly runtimeStatus: () => Promise; - readonly workspace?: ThemeStudioWorkspace; - readonly studioVersion?: string; - readonly repositoryUrl?: string | null; -} - -export async function startThemeStudioProductionHost( - dependencies: ThemeStudioProductionHostDependencies, -): Promise { - const template = await readFile(dependencies.indexHtmlPath, "utf8"); - if (!template.includes(STUDIO_CSP_NONCE_PLACEHOLDER)) { - throw new StudioError( - "INTERNAL", - "Production Theme Studio is missing its CSP nonce placeholder", - "Rebuild the Theme Studio release assets before starting OpenChatGPTSkin.", - ); - } - - const cspNonce = randomBytes(18).toString("base64"); - const indexHtml = template.replaceAll( - STUDIO_CSP_NONCE_PLACEHOLDER, - cspNonce, - ); - - return startThemeStudioServer({ - studioVersion: dependencies.studioVersion ?? THEME_CORE_VERSION, - repositoryUrl: dependencies.repositoryUrl ?? - process.env.OPEN_CHATGPT_SKIN_REPOSITORY_URL ?? - OPEN_CHATGPT_SKIN_REPOSITORY_URL, - runtimeStatus: dependencies.runtimeStatus, - ...(dependencies.workspace ? { workspace: dependencies.workspace } : {}), - indexHtml, - cspNonce, - }); -} diff --git a/runtime/theme-studio-service/src/runtime-status.ts b/runtime/theme-studio-service/src/runtime-status.ts deleted file mode 100644 index 853ecfb..0000000 --- a/runtime/theme-studio-service/src/runtime-status.ts +++ /dev/null @@ -1,198 +0,0 @@ -import { - StudioError, - StudioRuntimeStatusSchema, - type StudioRuntimeStatus, - type StudioThemeRef, -} from "@open-chatgpt-skin/theme-studio-core"; -import { - createProductionRuntimeCliDependencies, - executeRuntimeControlCommand, - RuntimeError, - type ControlResponse, - type RuntimeControlCliCommand, - type RuntimeStatusView, -} from "@open-chatgpt-skin/windows-runtime"; - -type RuntimeControlResult = Awaited>; - -export interface ProductionRuntimeThemeDependencies { - readonly readStatus: () => Promise; - readonly execute: (command: RuntimeControlCliCommand) => Promise; -} - -async function executeRuntimeCommand( - execute: ProductionRuntimeThemeDependencies["execute"], - command: RuntimeControlCliCommand, -): Promise { - const result = await execute(command); - if ("protocolVersion" in result && !result.ok) { - throw new StudioError( - "STUDIO_APPLY_FAILED", - result.error.message, - result.error.nextAction, - ); - } - return mapRuntimeControlResult(result); -} - -export function mapRuntimeStatus( - status: RuntimeStatusView, -): StudioRuntimeStatus { - return StudioRuntimeStatusSchema.parse({ - status: status.status, - controllerAvailable: status.controllerAvailable, - selectedTheme: status.selectedTheme, - appliedTheme: status.appliedTheme, - skinApplied: status.skinApplied, - packageVersion: status.packageVersion, - operation: status.operation, - nextAction: status.nextAction, - }); -} - -export function mapRuntimeControlResult( - result: ControlResponse | RuntimeStatusView, -): StudioRuntimeStatus { - if (!("protocolVersion" in result)) return mapRuntimeStatus(result); - if (!result.ok) { - throw new StudioError( - "RUNTIME_STATUS_UNAVAILABLE", - "Runtime status could not be read", - result.error.nextAction, - ); - } - return mapRuntimeStatus(result.result); -} - -export async function readProductionRuntimeStatus(): Promise { - try { - return mapRuntimeControlResult(await executeRuntimeControlCommand( - { kind: "status" }, - createProductionRuntimeCliDependencies(), - )); - } catch (error) { - const nextAction = error instanceof StudioError - ? error.nextAction ?? error.message - : error instanceof RuntimeError - ? error.nextAction ?? error.message - : "Build or start the Runtime before applying a theme."; - return StudioRuntimeStatusSchema.parse({ - status: "stopped", - controllerAvailable: false, - selectedTheme: null, - appliedTheme: null, - skinApplied: false, - packageVersion: null, - operation: null, - nextAction, - }); - } -} - -export async function applyProductionRuntimeTheme( - ref: StudioThemeRef, - dependencies: ProductionRuntimeThemeDependencies = { - readStatus: readProductionRuntimeStatus, - execute: (command) => executeRuntimeControlCommand( - command, - createProductionRuntimeCliDependencies(), - ), - }, -): Promise { - const current = await dependencies.readStatus(); - if (!current.controllerAvailable && current.status !== "stopped") { - throw new StudioError( - "STUDIO_APPLY_FAILED", - "Runtime controller is unavailable", - current.nextAction, - ); - } - if (current.status !== "stopped" && current.status !== "active" && - current.status !== "paused" && current.status !== "paused-incompatible") { - throw new StudioError( - "STUDIO_APPLY_FAILED", - "Runtime is not ready to apply a theme", - current.nextAction, - ); - } - - try { - let applied: StudioRuntimeStatus; - if (current.status === "stopped") { - applied = await executeRuntimeCommand(dependencies.execute, { - kind: "launch", - themeId: ref.id, - themeVersion: ref.version, - }); - } else { - const switched = await executeRuntimeCommand(dependencies.execute, { - kind: "switch", - themeId: ref.id, - themeVersion: ref.version, - }); - applied = switched.status === "active" - ? switched - : await executeRuntimeCommand(dependencies.execute, { kind: "resume" }); - } - if (applied.status !== "active" || applied.skinApplied !== true || - applied.appliedTheme?.id !== ref.id || applied.appliedTheme.version !== ref.version) { - throw new StudioError( - "STUDIO_APPLY_FAILED", - "Runtime did not confirm the exact theme as active", - applied.nextAction, - ); - } - return applied; - } catch (error) { - if (error instanceof StudioError) throw error; - throw new StudioError( - "STUDIO_APPLY_FAILED", - "Runtime theme application failed", - error instanceof RuntimeError ? error.nextAction : undefined, - ); - } -} - -export async function restoreProductionRuntimeTheme( - dependencies: ProductionRuntimeThemeDependencies = { - readStatus: readProductionRuntimeStatus, - execute: (command) => executeRuntimeControlCommand( - command, - createProductionRuntimeCliDependencies(), - ), - }, -): Promise { - const current = await dependencies.readStatus(); - if (current.status === "stopped" || current.status === "restored-awaiting-exit" || - current.status === "restored-cleanup-required") { - return current; - } - if (!current.controllerAvailable || current.status === "launching" || - current.status === "restoring") { - throw new StudioError( - "STUDIO_APPLY_FAILED", - "Runtime is not ready to restore the official appearance", - current.nextAction, - ); - } - - try { - const restored = await executeRuntimeCommand(dependencies.execute, { kind: "restore" }); - if (restored.status !== "restored-awaiting-exit" && - restored.status !== "restored-cleanup-required") { - throw new StudioError( - "STUDIO_APPLY_FAILED", - "Runtime did not confirm the official appearance was restored", - restored.nextAction, - ); - } - return restored; - } catch (error) { - if (error instanceof StudioError) throw error; - throw new StudioError( - "STUDIO_APPLY_FAILED", - "Codex official appearance could not be restored", - error instanceof RuntimeError ? error.nextAction : undefined, - ); - } -} diff --git a/runtime/theme-studio-service/src/server.ts b/runtime/theme-studio-service/src/server.ts deleted file mode 100644 index 44717ac..0000000 --- a/runtime/theme-studio-service/src/server.ts +++ /dev/null @@ -1,442 +0,0 @@ -import { - createServer, - type IncomingMessage, - type Server, - type ServerResponse, -} from "node:http"; -import { - STUDIO_PROTOCOL_VERSION, - STUDIO_BODY_LIMITS, - STUDIO_RESPONSE_POLICIES, - StudioBootstrapSchema, - StudioCreateDraftInputSchema, - StudioDeleteThemeInputSchema, - StudioDraftCommandInputSchema, - StudioError, - STUDIO_ERROR_HTTP_STATUS, - isStudioRoute, - StudioEventSchema, - StudioImportThemeInputSchema, - StudioSessionExchangeSchema, - StudioThemeRefSchema, - StudioUpdateDraftInputSchema, - StudioUploadAssetInputSchema, - type StudioErrorCode, - type StudioRuntimeStatus, -} from "@open-chatgpt-skin/theme-studio-core"; -import { ZodError } from "zod"; -import { - assertExactOrigin, - readBoundedBytes, - readBoundedJson, - writeBytes, - writeJson, -} from "./http.js"; -import { - createStudioSession, - STUDIO_COOKIE_NAME, -} from "./session.js"; -import type { ThemeStudioWorkspace } from "./workspace.js"; - -const RUNTIME_EVENT_INTERVAL_MS = 5_000; -export interface ThemeStudioServerDependencies { - readonly studioVersion: string; - readonly repositoryUrl?: string | null; - readonly runtimeStatus: () => Promise; - readonly workspace?: ThemeStudioWorkspace; - readonly indexHtml: string; - readonly cspNonce?: string; - readonly fallback?: ( - request: IncomingMessage, - response: ServerResponse, - ) => Promise; - readonly newToken?: () => string; -} - -export interface RunningThemeStudioServer { - readonly origin: string; - readonly bootstrapUrl: string; - readonly httpServer: Server; - close(): Promise; -} - -function statusFor(error: unknown): number { - if (error instanceof ZodError) return 400; - return error instanceof StudioError ? STUDIO_ERROR_HTTP_STATUS[error.code] : 500; -} - -function requireWorkspace( - workspace: ThemeStudioWorkspace | undefined, -): ThemeStudioWorkspace { - if (!workspace) { - throw new StudioError("INTERNAL", "Theme Studio workspace is unavailable"); - } - return workspace; -} - -function headerValue(value: string | string[] | undefined): string { - if (typeof value !== "string" || value.length === 0) { - throw new StudioError("STUDIO_REQUEST_INVALID", "Required request header is missing"); - } - return value; -} - -function errorCode(error: unknown): StudioErrorCode { - if (error instanceof ZodError) return "STUDIO_REQUEST_INVALID"; - return error instanceof StudioError ? error.code : "INTERNAL"; -} - -function serveIndex( - response: ServerResponse, - indexHtml: string, -): void { - response.writeHead(200, { - "Content-Type": STUDIO_RESPONSE_POLICIES.html.contentType, - "Cache-Control": STUDIO_RESPONSE_POLICIES.html.cacheControl, - "X-Content-Type-Options": STUDIO_RESPONSE_POLICIES.html.contentTypeOptions, - }); - response.end(indexHtml); -} - -function applySecurityHeaders( - response: ServerResponse, - cspNonce: string | undefined, - origin: string, -): void { - const nonceSource = cspNonce ? ` 'nonce-${cspNonce}'` : ""; - const websocketOrigin = origin.replace(/^http:/, "ws:"); - response.setHeader( - "Content-Security-Policy", - `default-src 'self'; connect-src 'self' ${websocketOrigin}; img-src 'self' blob:; font-src 'self' blob:; style-src 'self'${nonceSource}; style-src-attr 'unsafe-inline'; script-src 'self'${nonceSource}`, - ); - response.setHeader("Cross-Origin-Opener-Policy", "same-origin"); - response.setHeader("Referrer-Policy", "no-referrer"); - response.setHeader("X-Content-Type-Options", "nosniff"); - response.setHeader("X-Frame-Options", "DENY"); -} - -export async function startThemeStudioServer( - dependencies: ThemeStudioServerDependencies, -): Promise { - const session = createStudioSession(dependencies.newToken); - const closeEventStreams = new Set<() => void>(); - let eventSequence = 0; - let origin = ""; - - const server = createServer(async (request, response) => { - applySecurityHeaders(response, dependencies.cspNonce, origin); - try { - if (request.headers.host !== origin.slice("http://".length)) { - throw new StudioError( - "STUDIO_ORIGIN_REJECTED", - "Request host is not authorized", - ); - } - - const url = new URL(request.url ?? "/", origin); - if (isStudioRoute("session", request.method, url.pathname)) { - assertExactOrigin(request, origin); - const body = StudioSessionExchangeSchema.parse( - await readBoundedJson(request, STUDIO_BODY_LIMITS.sessionJsonBytes), - ); - const cookie = session.exchange(body.token); - response.writeHead(204, { - "Cache-Control": "no-store", - "Set-Cookie": `${STUDIO_COOKIE_NAME}=${cookie}; HttpOnly; SameSite=Strict; Path=/`, - }); - response.end(); - return; - } - - if (url.pathname.startsWith("/api/") && - !session.verifyCookie(request.headers.cookie)) { - throw new StudioError( - "STUDIO_SESSION_INVALID", - "Studio session is not authenticated", - ); - } - - if (isStudioRoute("bootstrap", request.method, url.pathname)) { - writeJson(response, 200, StudioBootstrapSchema.parse({ - protocolVersion: STUDIO_PROTOCOL_VERSION, - studioVersion: dependencies.studioVersion, - repositoryUrl: dependencies.repositoryUrl ?? null, - capabilities: dependencies.workspace - ? [ - "studio-shell", - "theme-library", - "draft-editing", - "asset-upload", - "version-save", - "theme-import-export", - "theme-delete", - "runtime-apply", - "runtime-restore", - ] - : ["studio-shell"], - runtime: await dependencies.runtimeStatus(), - })); - return; - } - - if (isStudioRoute("themes", request.method, url.pathname)) { - writeJson(response, 200, await requireWorkspace(dependencies.workspace).listThemes()); - return; - } - - if (isStudioRoute("applySavedTheme", request.method, url.pathname)) { - assertExactOrigin(request, origin); - const ref = StudioThemeRefSchema.parse(await readBoundedJson(request)); - writeJson( - response, - 200, - await requireWorkspace(dependencies.workspace).applySavedTheme(ref), - ); - return; - } - - const themeMatch = /^\/api\/themes\/([a-z0-9]+(?:-[a-z0-9]+)*)$/.exec(url.pathname); - if (request.method === "DELETE" && themeMatch) { - assertExactOrigin(request, origin); - const input = StudioDeleteThemeInputSchema.parse({ - id: themeMatch[1], - version: url.searchParams.get("version") ?? undefined, - }); - writeJson( - response, - 200, - await requireWorkspace(dependencies.workspace).deletePersonalTheme(input), - ); - return; - } - - if (isStudioRoute("createDraft", request.method, url.pathname)) { - assertExactOrigin(request, origin); - const input = StudioCreateDraftInputSchema.parse(await readBoundedJson(request)); - writeJson(response, 201, await requireWorkspace(dependencies.workspace).createDraft(input)); - return; - } - - if (isStudioRoute("latestDraft", request.method, url.pathname)) { - writeJson( - response, - 200, - await requireWorkspace(dependencies.workspace).openLatestDraft(), - ); - return; - } - - const draftMatch = /^\/api\/drafts\/([0-9a-f-]+)(?:\/(undo|redo|validate|save|apply|assets))?$/.exec( - url.pathname, - ); - if (draftMatch) { - const draftId = draftMatch[1]!; - const action = draftMatch[2]; - const workspace = requireWorkspace(dependencies.workspace); - if (request.method === "GET" && action === undefined) { - writeJson(response, 200, await workspace.openDraft(draftId)); - return; - } - if (request.method === "PUT" && action === undefined) { - assertExactOrigin(request, origin); - const body = StudioUpdateDraftInputSchema.omit({ draftId: true }) - .parse(await readBoundedJson(request)); - writeJson(response, 200, await workspace.updateDraft({ draftId, ...body })); - return; - } - if (request.method === "POST" && action && action !== "assets") { - assertExactOrigin(request, origin); - if (action === "validate") { - writeJson(response, 200, await workspace.validateDraft(draftId)); - return; - } - const body = StudioDraftCommandInputSchema.omit({ draftId: true }) - .parse(await readBoundedJson(request)); - const input = { draftId, ...body }; - if (action === "undo") writeJson(response, 200, await workspace.undo(input)); - if (action === "redo") writeJson(response, 200, await workspace.redo(input)); - if (action === "save") writeJson(response, 200, await workspace.saveVersion(input)); - if (action === "apply") writeJson(response, 200, await workspace.applyTheme(input)); - return; - } - if (request.method === "POST" && action === "assets") { - assertExactOrigin(request, origin); - const bytes = await readBoundedBytes(request, STUDIO_BODY_LIMITS.imageBytes); - const input = StudioUploadAssetInputSchema.parse({ - draftId, - expectedRevision: Number(url.searchParams.get("revision")), - slot: url.searchParams.get("slot"), - assetKey: url.searchParams.get("assetKey") ?? undefined, - fileName: headerValue(request.headers["x-file-name"]), - mimeType: headerValue(request.headers["content-type"]), - bytes, - }); - writeJson(response, 200, await workspace.uploadAsset(input)); - return; - } - } - - if (isStudioRoute("importTheme", request.method, url.pathname)) { - assertExactOrigin(request, origin); - const bytes = await readBoundedBytes(request, STUDIO_BODY_LIMITS.archiveBytes); - const input = StudioImportThemeInputSchema.parse({ - fileName: headerValue(request.headers["x-file-name"]), - bytes, - }); - writeJson(response, 201, await requireWorkspace(dependencies.workspace).importTheme(input)); - return; - } - - if (isStudioRoute("exportTheme", request.method, url.pathname)) { - const ref = StudioThemeRefSchema.parse({ - id: url.searchParams.get("id"), - version: url.searchParams.get("version"), - }); - const exported = await requireWorkspace(dependencies.workspace).exportTheme(ref); - writeBytes(response, 200, exported.bytes, exported.mimeType, { - "Content-Disposition": `attachment; filename="${exported.fileName}"`, - }); - return; - } - - if (isStudioRoute("runtime", request.method, url.pathname)) { - writeJson(response, 200, await requireWorkspace(dependencies.workspace).getRuntimeStatus()); - return; - } - - if (isStudioRoute("restoreRuntime", request.method, url.pathname)) { - assertExactOrigin(request, origin); - writeJson(response, 200, await requireWorkspace(dependencies.workspace).restoreRuntime()); - return; - } - - if (isStudioRoute("draftAsset", request.method, url.pathname)) { - const asset = await requireWorkspace(dependencies.workspace).readDraftAsset( - url.searchParams.get("draftId") ?? "", - url.searchParams.get("path") ?? "", - ); - writeBytes(response, 200, asset.bytes, asset.mimeType); - return; - } - - if (isStudioRoute("themePreview", request.method, url.pathname)) { - const source = url.searchParams.get("source"); - if (source !== "builtin" && source !== "personal") { - throw new StudioError("STUDIO_REQUEST_INVALID", "Theme preview source is invalid"); - } - const ref = StudioThemeRefSchema.parse({ - id: url.searchParams.get("id"), - version: url.searchParams.get("version"), - }); - const preview = await requireWorkspace(dependencies.workspace).readThemePreview(source, ref); - writeBytes(response, 200, preview.bytes, preview.mimeType); - return; - } - - if (isStudioRoute("events", request.method, url.pathname)) { - const firstRuntime = await dependencies.runtimeStatus(); - response.writeHead(200, { - "Content-Type": STUDIO_RESPONSE_POLICIES.sse.contentType, - "Cache-Control": STUDIO_RESPONSE_POLICIES.sse.cacheControl, - Connection: "keep-alive", - "X-Content-Type-Options": STUDIO_RESPONSE_POLICIES.sse.contentTypeOptions, - }); - - let closed = false; - let timer: ReturnType | null = null; - const close = () => { - if (closed) return; - closed = true; - if (timer) clearTimeout(timer); - closeEventStreams.delete(close); - request.off("close", close); - if (!response.writableEnded) response.end(); - }; - const writeEvent = (runtime: StudioRuntimeStatus) => { - const event = StudioEventSchema.parse({ - protocolVersion: STUDIO_PROTOCOL_VERSION, - sequence: ++eventSequence, - kind: "runtime-status", - runtime, - }); - response.write(`data: ${JSON.stringify(event)}\n\n`); - }; - const schedule = () => { - timer = setTimeout(() => { - void dependencies.runtimeStatus() - .then((runtime) => { - if (closed) return; - writeEvent(runtime); - schedule(); - }) - .catch(close); - }, RUNTIME_EVENT_INTERVAL_MS); - timer.unref(); - }; - - closeEventStreams.add(close); - request.once("close", close); - writeEvent(firstRuntime); - schedule(); - return; - } - - if (dependencies.fallback && await dependencies.fallback(request, response)) { - return; - } - if (isStudioRoute("home", request.method, url.pathname)) { - serveIndex(response, dependencies.indexHtml); - return; - } - - writeJson(response, 404, { - error: { code: "STUDIO_REQUEST_INVALID" }, - }); - } catch (error) { - if (response.headersSent) { - response.destroy(); - return; - } - writeJson(response, statusFor(error), { - error: { - code: errorCode(error), - ...(error instanceof StudioError ? { - message: error.message, - ...(error.nextAction ? { nextAction: error.nextAction } : {}), - } : {}), - }, - }); - } - }); - - await new Promise((resolve, reject) => { - server.once("error", reject); - server.listen(0, "127.0.0.1", resolve); - }); - - const address = server.address(); - if (!address || typeof address === "string") { - await new Promise((resolve) => server.close(() => resolve())); - throw new StudioError( - "INTERNAL", - "Studio loopback address is unavailable", - ); - } - origin = `http://127.0.0.1:${address.port}`; - - let closePromise: Promise | null = null; - - return { - origin, - bootstrapUrl: `${origin}/#bootstrap=${session.bootstrapToken}`, - httpServer: server, - close: () => { - if (closePromise) return closePromise; - closePromise = new Promise((resolve, reject) => { - for (const closeStream of [...closeEventStreams]) closeStream(); - server.close((error) => error ? reject(error) : resolve()); - }); - return closePromise; - }, - }; -} diff --git a/runtime/theme-studio-service/src/session.ts b/runtime/theme-studio-service/src/session.ts deleted file mode 100644 index 52ba0e1..0000000 --- a/runtime/theme-studio-service/src/session.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { randomBytes, timingSafeEqual } from "node:crypto"; -import { StudioError } from "@open-chatgpt-skin/theme-studio-core"; - -export const STUDIO_COOKIE_NAME = "ocs_studio_session"; - -function defaultToken(): string { - return randomBytes(32).toString("hex"); -} - -function validToken(value: string): boolean { - return /^[0-9a-f]{64}$/.test(value); -} - -function sameToken(left: string, right: string): boolean { - return validToken(left) && validToken(right) && - timingSafeEqual(Buffer.from(left, "hex"), Buffer.from(right, "hex")); -} - -function cookieValue(header: string | undefined): string | undefined { - for (const part of header?.split(";") ?? []) { - const separator = part.indexOf("="); - if (separator <= 0) continue; - if (part.slice(0, separator).trim() === STUDIO_COOKIE_NAME) { - return part.slice(separator + 1).trim(); - } - } - return undefined; -} - -export interface StudioSession { - readonly bootstrapToken: string; - exchange(token: string): string; - verifyCookie(header: string | undefined): boolean; -} - -export function createStudioSession( - newToken: () => string = defaultToken, -): StudioSession { - const bootstrapToken = newToken(); - let available = true; - let sessionToken: string | null = null; - - return { - bootstrapToken, - exchange(token) { - if (!available || !sameToken(token, bootstrapToken)) { - throw new StudioError( - "STUDIO_SESSION_INVALID", - "Bootstrap token is invalid or already used", - ); - } - available = false; - sessionToken = newToken(); - return sessionToken; - }, - verifyCookie(header) { - const candidate = cookieValue(header); - return sessionToken !== null && candidate !== undefined && - sameToken(candidate, sessionToken); - }, - }; -} diff --git a/runtime/theme-studio-service/src/vite-host.ts b/runtime/theme-studio-service/src/vite-host.ts deleted file mode 100644 index cb032dc..0000000 --- a/runtime/theme-studio-service/src/vite-host.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { randomBytes } from "node:crypto"; -import type { - IncomingMessage, - Server, - ServerResponse, -} from "node:http"; -import { fileURLToPath } from "node:url"; -import { THEME_CORE_VERSION } from "@open-chatgpt-skin/theme-core"; -import type { StudioRuntimeStatus } from "@open-chatgpt-skin/theme-studio-core"; -import { OPEN_CHATGPT_SKIN_REPOSITORY_URL } from "@open-chatgpt-skin/theme-studio-core"; -import { createServer as createViteServer } from "vite"; -import { startThemeStudioServer } from "./server.js"; -import type { ThemeStudioWorkspace } from "./workspace.js"; - -const STUDIO_APP_ROOT = fileURLToPath( - new URL("../../../apps/theme-studio/", import.meta.url), -); - -export interface StudioMiddleware { - handle( - request: IncomingMessage, - response: ServerResponse, - ): Promise; - close(): Promise; -} - -export interface RunningThemeStudioDevHost { - readonly origin: string; - readonly bootstrapUrl: string; - close(): Promise; -} - -export interface ThemeStudioDevHostDependencies { - readonly createMiddleware?: ( - server: Server, - cspNonce: string, - ) => Promise; - readonly runtimeStatus: () => Promise; - readonly workspace?: ThemeStudioWorkspace; -} - -async function createDefaultMiddleware( - server: Server, - cspNonce: string, -): Promise { - const vite = await createViteServer({ - root: STUDIO_APP_ROOT, - appType: "spa", - html: { cspNonce }, - server: { middlewareMode: true, hmr: { server } }, - }); - - return { - handle(request, response) { - return new Promise((resolveHandled, rejectHandled) => { - let settled = false; - const settle = (callback: () => void) => { - if (settled) return; - settled = true; - response.off("finish", onFinish); - response.off("close", onClose); - callback(); - }; - const onFinish = () => settle(() => resolveHandled(true)); - const onClose = () => settle(() => resolveHandled(true)); - - response.once("finish", onFinish); - response.once("close", onClose); - vite.middlewares(request, response, (error?: unknown) => { - if (error) { - settle(() => rejectHandled(error)); - return; - } - settle(() => resolveHandled(false)); - }); - }); - }, - close: () => vite.close(), - }; -} - -export async function startThemeStudioDevHost( - dependencies: ThemeStudioDevHostDependencies, -): Promise { - const cspNonce = randomBytes(18).toString("base64"); - let middleware: StudioMiddleware | null = null; - const server = await startThemeStudioServer({ - studioVersion: THEME_CORE_VERSION, - repositoryUrl: process.env.OPEN_CHATGPT_SKIN_REPOSITORY_URL ?? - OPEN_CHATGPT_SKIN_REPOSITORY_URL, - runtimeStatus: dependencies.runtimeStatus, - ...(dependencies.workspace ? { workspace: dependencies.workspace } : {}), - indexHtml: "Theme Studio", - cspNonce, - fallback: (request, response) => middleware - ? middleware.handle(request, response) - : Promise.resolve(false), - }); - - try { - middleware = await ( - dependencies.createMiddleware ?? createDefaultMiddleware - )(server.httpServer, cspNonce); - const runningMiddleware = middleware; - let closePromise: Promise | null = null; - return { - origin: server.origin, - bootstrapUrl: server.bootstrapUrl, - close: () => { - if (closePromise) return closePromise; - closePromise = (async () => { - try { - await runningMiddleware.close(); - } finally { - await server.close(); - } - })(); - return closePromise; - }, - }; - } catch (error) { - await server.close(); - throw error; - } -} diff --git a/runtime/theme-studio-service/src/workspace.ts b/runtime/theme-studio-service/src/workspace.ts deleted file mode 100644 index f9fffe1..0000000 --- a/runtime/theme-studio-service/src/workspace.ts +++ /dev/null @@ -1,1157 +0,0 @@ -import { createHash, randomUUID } from "node:crypto"; -import { - mkdir, - readFile, - readdir, - rename, - rm, - rmdir, - writeFile, -} from "node:fs/promises"; -import { dirname, join } from "node:path"; -import { isDeepStrictEqual } from "node:util"; -import { - loadThemeCatalog, - loadThemeDirectory, - packTheme, - ThemeValidationError, - ThemeStore, - unpackTheme, - validateThemeBundle, - type ThemeRef, - type ValidatedThemeBundle, -} from "@open-chatgpt-skin/theme-core"; -import { - isSafeThemePath, - parseThemeDraftDocument, - parseThemeDocument, - THEME_MAX_COMPOSITION_LAYERS, - THEME_SCHEMA_VERSION, - themeAssetPaths, - ThemeDraftDocumentSchema, - type SuggestionIconSlot, - type ThemeDraftDocument, -} from "@open-chatgpt-skin/theme-schema"; -import { - StudioApplyResultSchema, - StudioCreateDraftInputSchema, - StudioDraftSchema, - StudioError, - StudioExportedThemeSchema, - StudioSaveResultSchema, - StudioThemeLibrarySchema, - personalThemeGroupKey, - validateStudioDraft, - type StudioApplyResult, - type StudioCreateDraftInput, - type StudioDeleteThemeInput, - type StudioDraft, - type StudioDraftCommandInput, - type StudioExportedTheme, - type StudioImportThemeInput, - type StudioRuntimeStatus, - type StudioSaveResult, - type StudioThemeLibrary, - type StudioThemeRef, - type StudioUpdateDraftInput, - type StudioUploadAssetInput, -} from "@open-chatgpt-skin/theme-studio-core"; -import { - compileTheme, - RuntimeThemeError, -} from "@open-chatgpt-skin/cdp-adapter"; -import type { RuntimePaths } from "@open-chatgpt-skin/windows-runtime"; -import sharp from "sharp"; -import { z } from "zod"; - -const HISTORY_LIMIT = 50; -const SOURCE_IMAGE_LIMIT_BYTES = 50 * 1024 * 1024; -const PROCESSED_IMAGE_LIMIT_BYTES = 16 * 1024 * 1024; -const SOURCE_FONT_LIMIT_BYTES = 5 * 1024 * 1024; -const THEME_ARCHIVE_MIME = "application/vnd.open-chatgpt-skin+zip" as const; -const IMAGE_MIME_TYPES = new Set(["image/png", "image/jpeg", "image/webp"]); - -function studioThemeError( - code: "STUDIO_IMPORT_INVALID" | "STUDIO_EXPORT_INVALID" | "STUDIO_SAVE_FAILED", - error: unknown, - fallback: string, -): StudioError { - return new StudioError( - code, - error instanceof ThemeValidationError - ? `${error.message} (${error.code})` - : error instanceof Error - ? error.message - : fallback, - ); -} - -export const DraftRecordSchema = z.object({ - schemaVersion: z.literal(1), - draftId: z.string().uuid(), - theme: ThemeDraftDocumentSchema, - revision: z.number().int().nonnegative(), - updatedAt: z.string().datetime(), - savedRef: z.object({ - id: z.string(), - version: z.string(), - }).strict().nullable(), - dirty: z.boolean(), - past: z.array(ThemeDraftDocumentSchema).max(HISTORY_LIMIT), - future: z.array(ThemeDraftDocumentSchema).max(HISTORY_LIMIT), -}).strict(); - -type DraftRecord = z.infer; - -export const PersistedDraftRecordSchema = DraftRecordSchema.omit({ - theme: true, - past: true, - future: true, -}).extend({ - theme: z.unknown(), - past: z.array(z.unknown()).max(HISTORY_LIMIT), - future: z.array(z.unknown()).max(HISTORY_LIMIT), -}).strict(); - -export interface ThemeStudioWorkspaceDependencies { - readonly paths: RuntimePaths; - readonly runtimeStatus: () => Promise; - readonly applyRuntimeTheme: (ref: StudioThemeRef) => Promise; - readonly restoreRuntimeTheme: () => Promise; - readonly now?: () => string; - readonly newId?: () => string; -} - -export interface StudioBinaryAsset { - readonly bytes: Uint8Array; - readonly mimeType: string; -} - -function suggestionIconSlot( - slot: StudioUploadAssetInput["slot"], -): SuggestionIconSlot | undefined { - if (slot === "suggestion-card1") return "card1"; - if (slot === "suggestion-card2") return "card2"; - if (slot === "suggestion-card3") return "card3"; - if (slot === "suggestion-card4") return "card4"; - return undefined; -} - -function projectIconIndex(slot: StudioUploadAssetInput["slot"]): number | undefined { - if (slot === "project-icon1") return 0; - if (slot === "project-icon2") return 1; - if (slot === "project-icon3") return 2; - if (slot === "project-icon4") return 3; - return undefined; -} - -function parseDraftRecord(value: unknown): DraftRecord { - const persisted = PersistedDraftRecordSchema.parse(value); - return DraftRecordSchema.parse({ - ...persisted, - theme: parseThemeDraftDocument(persisted.theme), - past: persisted.past.map(parseThemeDraftDocument), - future: persisted.future.map(parseThemeDraftDocument), - }); -} - -function recordAssetPaths(record: DraftRecord): ReadonlySet { - return new Set([ - record.theme, - ...record.past, - ...record.future, - ].flatMap((theme) => themeAssetPaths(theme))); -} - -function sourceExtension(fileName: string): string { - const dot = fileName.lastIndexOf("."); - return dot < 0 ? "" : fileName.slice(dot).toLowerCase(); -} - -function mimeForPath(path: string): string { - const lower = path.toLowerCase(); - if (lower.endsWith(".webp")) return "image/webp"; - if (lower.endsWith(".png")) return "image/png"; - if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg"; - if (lower.endsWith(".woff2")) return "font/woff2"; - throw new StudioError("STUDIO_ASSET_INVALID", "Unsupported draft asset type"); -} - -function nextPatchVersion(refs: readonly ThemeRef[], id: string): string { - const versions = refs - .filter((ref) => ref.id === id) - .map((ref) => ref.version.split(".").map(Number) as [number, number, number]) - .sort((left, right) => left[0] - right[0] || left[1] - right[1] || left[2] - right[2]); - const latest = versions.at(-1); - return latest ? `${latest[0]}.${latest[1]}.${latest[2] + 1}` : "1.0.0"; -} - -function cloneTheme(theme: ThemeDraftDocument): ThemeDraftDocument { - return structuredClone(theme); -} - -function bytesEqual(left: Uint8Array | undefined, right: Uint8Array | undefined): boolean { - if (!left || !right || left.length !== right.length) return false; - return left.every((value, index) => value === right[index]); -} - -function withHistory(record: DraftRecord, theme: ThemeDraftDocument, now: string): DraftRecord { - return DraftRecordSchema.parse({ - ...record, - theme, - revision: record.revision + 1, - updatedAt: now, - dirty: true, - past: [...record.past, record.theme].slice(-HISTORY_LIMIT), - future: [], - }); -} - -export class ThemeStudioWorkspace { - private readonly store: ThemeStore; - private readonly now: () => string; - private readonly newId: () => string; - private readonly queues = new Map>(); - private readonly themeQueues = new Map>(); - private reservedThemeIds = new Set(); - private builtinThemeIds: readonly string[] = []; - - constructor(private readonly dependencies: ThemeStudioWorkspaceDependencies) { - this.store = new ThemeStore(dependencies.paths.themeStoreDirectory); - this.now = dependencies.now ?? (() => new Date().toISOString()); - this.newId = dependencies.newId ?? randomUUID; - } - - async initialize(): Promise { - const [, , catalog] = await Promise.all([ - mkdir(this.dependencies.paths.themeStoreDirectory, { recursive: true }), - mkdir(this.dependencies.paths.themeStudioDraftDirectory, { recursive: true }), - loadThemeCatalog(this.dependencies.paths.themesRoot), - ]); - this.reservedThemeIds = new Set([ - ...catalog.builtins.map((entry) => entry.id), - ...catalog.recipes.map((entry) => entry.id), - ]); - this.builtinThemeIds = catalog.builtins.map((entry) => entry.id); - await this.removeDuplicateDrafts(); - } - - async listThemes(): Promise { - const catalog = await loadThemeCatalog(this.dependencies.paths.themesRoot); - const builtins = await Promise.all(catalog.builtins.map(async (entry) => { - const bundle = await loadThemeDirectory(join(this.dependencies.paths.themesRoot, entry.path)); - return this.libraryItem( - bundle.theme, - "builtin", - true, - false, - this.previewUrl("builtin", entry.id, entry.version), - ); - })); - const recipes = await Promise.all(catalog.recipes.map(async (entry) => { - const theme = parseThemeDocument(JSON.parse(await readFile(join( - this.dependencies.paths.themesRoot, - entry.path, - "recipe.json", - ), "utf8"))); - return this.libraryItem(theme, "recipe", false, true, null); - })); - const personal = await Promise.all((await this.store.list()).map(async (ref) => { - const bundle = await this.store.readTheme(ref); - return this.libraryItem( - bundle.theme, - "personal", - true, - bundle.theme.rights.localOnly, - bundle.files.has("preview.webp") - ? this.previewUrl("personal", ref.id, ref.version) - : null, - ); - })); - return StudioThemeLibrarySchema.parse({ themes: [...builtins, ...personal, ...recipes] }); - } - - async applySavedTheme(ref: StudioThemeRef): Promise { - const library = await this.listThemes(); - const theme = library.themes.find((entry) => - entry.ready && entry.ref.id === ref.id && entry.ref.version === ref.version - ); - if (!theme) { - throw new StudioError( - "STUDIO_APPLY_FAILED", - `Theme is not ready to apply: ${ref.id}@${ref.version}`, - ); - } - return this.dependencies.applyRuntimeTheme(ref); - } - - async deletePersonalTheme(input: StudioDeleteThemeInput): Promise { - return this.serializeTheme( - personalThemeGroupKey(input.id, this.builtinThemeIds), - async () => { - const catalog = await loadThemeCatalog(this.dependencies.paths.themesRoot); - const builtinIds = catalog.builtins.map((entry) => entry.id); - const targetGroup = personalThemeGroupKey(input.id, builtinIds); - const refs = (await this.store.list()).filter((ref) => input.version === undefined - ? personalThemeGroupKey(ref.id, builtinIds) === targetGroup - : ref.id === input.id && ref.version === input.version - ); - if (refs.length === 0) { - throw new StudioError( - "STUDIO_DELETE_FAILED", - input.version - ? `个人主题版本不存在:${input.id}@${input.version}` - : `个人主题不存在:${input.id}`, - ); - } - const runtime = await this.dependencies.runtimeStatus(); - const inUse = refs.some((ref) => - (runtime.selectedTheme?.id === ref.id && runtime.selectedTheme.version === ref.version) || - (runtime.appliedTheme?.id === ref.id && runtime.appliedTheme.version === ref.version) - ); - if (inUse) { - throw new StudioError( - "STUDIO_DELETE_FAILED", - "正在使用的个人主题不能删除,请先恢复原始皮肤或应用其他主题。", - ); - } - for (const ref of refs) await this.store.remove(ref); - if (input.version === undefined) { - await this.removeDraftGroup(targetGroup, builtinIds); - } else { - await this.invalidateDeletedThemeRefs(refs); - } - return this.listThemes(); - }, - ); - } - - async createDraft(input: StudioCreateDraftInput): Promise { - const bundle = await this.loadSource(input); - const theme = this.buildDraftTheme(input, bundle); - const group = personalThemeGroupKey(theme.id, this.builtinThemeIds); - return this.serializeTheme(group, () => this.createDraftRecord(input, bundle, theme)); - } - - private buildDraftTheme( - input: StudioCreateDraftInput, - bundle: ValidatedThemeBundle, - ): ThemeDraftDocument { - const defaultId = (input.source.source === "personal" - ? input.source.ref.id - : `${input.source.ref.id}-custom`).slice(0, 80) - .replace(/-+$/g, ""); - const sourceSavedRef = input.source.source === "personal" ? input.source.ref : null; - return ThemeDraftDocumentSchema.parse({ - ...bundle.theme, - schemaVersion: THEME_SCHEMA_VERSION, - kind: "theme", - id: input.themeId ?? defaultId, - name: input.name ?? `${bundle.theme.name} 自定义`, - version: sourceSavedRef?.version ?? "0.0.0", - assets: bundle.theme.kind === "recipe" ? {} : bundle.theme.assets, - rights: { - ...bundle.theme.rights, - localOnly: bundle.theme.kind === "recipe" || bundle.theme.rights.localOnly, - }, - }); - } - - private async createDraftRecord( - input: StudioCreateDraftInput, - bundle: ValidatedThemeBundle, - theme: ThemeDraftDocument, - ): Promise { - const sourceSavedRef = input.source.source === "personal" ? input.source.ref : null; - const group = personalThemeGroupKey(theme.id, this.builtinThemeIds); - const existing = (await this.listDraftRecords()) - .filter((record) => personalThemeGroupKey( - record.theme.id, - this.builtinThemeIds, - ) === group) - .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt) || - right.draftId.localeCompare(left.draftId))[0]; - if (existing && input.conflictResolution === undefined) { - throw new StudioError( - "STUDIO_DRAFT_CONFLICT", - `主题“${theme.name}”已有草稿`, - "请选择加载已有草稿或覆盖现有草稿。", - ); - } - if (existing && input.conflictResolution === "load-existing") { - return this.view(existing); - } - - const draftId = existing?.draftId ?? this.newId(); - const record = DraftRecordSchema.parse({ - schemaVersion: 1, - draftId, - theme, - revision: existing ? existing.revision + 1 : 0, - updatedAt: this.now(), - savedRef: sourceSavedRef, - dirty: sourceSavedRef === null, - past: [], - future: [], - }); - await this.writeBundleFiles(draftId, bundle.files); - await this.writeRecord(record); - return this.view(record); - } - - async openDraft(draftId: string): Promise { - return this.view(await this.readRecord(draftId)); - } - - async openLatestDraft(): Promise { - const records = await this.listDraftRecords(); - const latest = [...records].sort((left, right) => - right.updatedAt.localeCompare(left.updatedAt) || - right.draftId.localeCompare(left.draftId) - )[0]; - return latest ? this.view(latest) : null; - } - - async updateDraft(input: StudioUpdateDraftInput): Promise { - return this.mutate(input.draftId, async (record) => { - this.assertRevision(record, input.expectedRevision); - this.assertPersonalThemeId(input.theme.id); - if (record.theme.id !== input.theme.id) { - const conflict = (await this.store.list()).some((ref) => ref.id === input.theme.id); - if (conflict) { - throw new StudioError( - "STUDIO_DRAFT_INVALID", - "Theme ID already belongs to an installed personal theme", - ); - } - } - return withHistory(record, ThemeDraftDocumentSchema.parse(input.theme), this.now()); - }); - } - - async undo(input: StudioDraftCommandInput): Promise { - return this.mutate(input.draftId, async (record) => { - this.assertRevision(record, input.expectedRevision); - const previous = record.past.at(-1); - if (!previous) return record; - return DraftRecordSchema.parse({ - ...record, - theme: previous, - revision: record.revision + 1, - updatedAt: this.now(), - dirty: true, - past: record.past.slice(0, -1), - future: [record.theme, ...record.future].slice(0, HISTORY_LIMIT), - }); - }); - } - - async redo(input: StudioDraftCommandInput): Promise { - return this.mutate(input.draftId, async (record) => { - this.assertRevision(record, input.expectedRevision); - const next = record.future[0]; - if (!next) return record; - return DraftRecordSchema.parse({ - ...record, - theme: next, - revision: record.revision + 1, - updatedAt: this.now(), - dirty: true, - past: [...record.past, record.theme].slice(-HISTORY_LIMIT), - future: record.future.slice(1), - }); - }); - } - - async uploadAsset(input: StudioUploadAssetInput): Promise { - return this.mutate(input.draftId, async (record) => { - this.assertRevision(record, input.expectedRevision); - const theme = cloneTheme(record.theme); - if (input.slot === "composition-layer") { - const key = this.requiredAssetKey(input); - const existing = theme.composition.layers.some((layer) => layer.id === key); - if (!existing && - theme.composition.layers.length >= THEME_MAX_COMPOSITION_LAYERS) { - throw new StudioError( - "STUDIO_ASSET_INVALID", - `A theme can contain at most ${THEME_MAX_COMPOSITION_LAYERS} composition layers`, - ); - } - } - const { path, bytes } = await this.normalizeAsset(input); - await this.writeAsset(record.draftId, path, bytes); - - if (input.slot === "background") theme.assets.background = path; - if (input.slot === "portrait") theme.assets.portrait = path; - if (input.slot === "profile-avatar") theme.assets.profileAvatar = path; - const suggestionSlot = suggestionIconSlot(input.slot); - if (suggestionSlot) { - theme.assets.suggestionIcons = { - ...theme.assets.suggestionIcons, - [suggestionSlot]: path, - }; - } - const projectIndex = projectIconIndex(input.slot); - if (projectIndex !== undefined) { - const projectIcons = [...(theme.assets.projectIcons ?? [])]; - while (projectIcons.length < projectIndex) projectIcons.push(path); - projectIcons[projectIndex] = path; - theme.assets.projectIcons = projectIcons; - } - if (input.slot === "decoration") { - const key = this.requiredAssetKey(input); - theme.assets.decorations = { ...theme.assets.decorations, [key]: path }; - const existing = theme.decorations.findIndex((item) => item.assetKey === key); - const decoration = { - type: "image" as const, - enabled: true, - intensity: 0.6, - assetKey: key, - placement: "corners" as const, - opacity: 0.75, - scale: 1, - }; - if (existing < 0) theme.decorations = [...theme.decorations, decoration].slice(0, 16); - else theme.decorations[existing] = decoration; - } - if (input.slot === "composition-layer") { - const key = this.requiredAssetKey(input); - theme.assets.decorations = { ...theme.assets.decorations, [key]: path }; - const nextLayer = { - id: key, - asset: { kind: "decoration" as const, assetKey: key }, - surface: "home-hero" as const, - anchor: "top-left" as const, - positionX: 0.1, - positionY: 0.1, - width: 0.2, - opacity: 1, - rotation: 0, - required: false, - }; - const layerIndex = theme.composition.layers.findIndex((layer) => layer.id === key); - if (layerIndex < 0) { - theme.composition.layers = [...theme.composition.layers, nextLayer]; - } else { - theme.composition.layers[layerIndex] = nextLayer; - } - } - if (input.slot === "ui-font" || input.slot === "code-font" || - input.slot === "display-font") { - const key = this.requiredAssetKey(input); - theme.assets.fonts = { ...theme.assets.fonts, [key]: path }; - if (input.slot === "ui-font") { - theme.typography.uiFontAssetKey = key; - theme.typography.uiFamily = `ocs-${key}`; - } else if (input.slot === "code-font") { - theme.typography.codeFontAssetKey = key; - theme.typography.codeFamily = `ocs-${key}`; - } else { - theme.typography.displayFontAssetKey = key; - theme.typography.displayFamily = `ocs-${key}`; - } - } - return withHistory(record, ThemeDraftDocumentSchema.parse(theme), this.now()); - }); - } - - async validateDraft(draftId: string): Promise { - return this.view(await this.readRecord(draftId)); - } - - async saveVersion(input: StudioDraftCommandInput): Promise { - const initial = await this.readRecord(input.draftId); - this.assertRevision(initial, input.expectedRevision); - return this.serializeTheme(initial.theme.id, async () => { - let savedRef: StudioThemeRef | null = null; - const draft = await this.mutate(input.draftId, async (record) => { - this.assertRevision(record, input.expectedRevision); - const saved = await this.saveRecord(record); - savedRef = saved.savedRef; - return saved; - }); - if (!savedRef) throw new StudioError("STUDIO_SAVE_FAILED", "Theme version was not saved"); - return StudioSaveResultSchema.parse({ draft, ref: savedRef }); - }); - } - - async importTheme(input: StudioImportThemeInput): Promise { - let bundle: ValidatedThemeBundle; - try { - bundle = await unpackTheme(input.bytes); - } catch (error) { - throw studioThemeError("STUDIO_IMPORT_INVALID", error, "Theme archive is invalid"); - } - this.assertPersonalThemeId(bundle.theme.id); - this.assertRuntimeReady(bundle); - return this.serializeTheme( - personalThemeGroupKey(bundle.theme.id, this.builtinThemeIds), - async () => { - let ref: ThemeRef; - try { - ref = await this.store.install(bundle); - } catch (error) { - throw studioThemeError("STUDIO_IMPORT_INVALID", error, "Theme import failed"); - } - const draftInput = StudioCreateDraftInputSchema.parse({ - source: { source: "personal", ref }, - themeId: ref.id, - name: bundle.theme.name, - conflictResolution: "overwrite-existing", - }); - return this.createDraftRecord( - draftInput, - bundle, - this.buildDraftTheme(draftInput, bundle), - ); - }, - ); - } - - async exportTheme(ref: StudioThemeRef): Promise { - try { - const installed = (await this.store.list()).some((candidate) => - candidate.id === ref.id && candidate.version === ref.version - ); - if (!installed) { - throw new StudioError( - "STUDIO_EXPORT_INVALID", - "只有个人主题可以导出", - "请先保存为个人主题版本,或选择已导入的个人主题。", - ); - } - const bytes = packTheme(await this.store.readTheme(ref)); - return StudioExportedThemeSchema.parse({ - fileName: `${ref.id}-${ref.version}.ocskin`, - mimeType: THEME_ARCHIVE_MIME, - bytes, - }); - } catch (error) { - if (error instanceof StudioError) throw error; - throw studioThemeError("STUDIO_EXPORT_INVALID", error, "Theme export failed"); - } - } - - async applyTheme(input: StudioDraftCommandInput): Promise { - const record = await this.readRecord(input.draftId); - this.assertRevision(record, input.expectedRevision); - if (record.dirty || !record.savedRef) { - throw new StudioError( - "STUDIO_APPLY_FAILED", - "主题存在未保存修改", - "请先点击“保存版本”,再应用到 Codex。", - ); - } - const draft = this.view(record); - const ref = record.savedRef; - try { - const runtime = await this.dependencies.applyRuntimeTheme(ref); - return StudioApplyResultSchema.parse({ draft, ref, runtime }); - } catch (error) { - if (error instanceof StudioError) throw error; - throw new StudioError( - "STUDIO_APPLY_FAILED", - "Theme could not be applied to Codex", - error instanceof Error ? error.message : undefined, - ); - } - } - - getRuntimeStatus(): Promise { - return this.dependencies.runtimeStatus(); - } - - restoreRuntime(): Promise { - return this.dependencies.restoreRuntimeTheme(); - } - - async readDraftAsset(draftId: string, path: string): Promise { - const record = await this.readRecord(draftId); - if (!themeAssetPaths(record.theme).includes(path) || !isSafeThemePath(path)) { - throw new StudioError("STUDIO_ASSET_INVALID", "Draft asset is not declared"); - } - return { - bytes: await readFile(this.assetPath(draftId, path)), - mimeType: mimeForPath(path), - }; - } - - async readThemePreview( - source: "builtin" | "personal", - ref: StudioThemeRef, - ): Promise { - if (source === "personal") { - const preview = (await this.store.readTheme(ref)).files.get("preview.webp"); - if (!preview) throw new StudioError("STUDIO_ASSET_INVALID", "Theme preview is unavailable"); - return { bytes: preview, mimeType: "image/webp" }; - } - const catalog = await loadThemeCatalog(this.dependencies.paths.themesRoot); - const entry = catalog.builtins.find((candidate) => - candidate.id === ref.id && candidate.version === ref.version - ); - if (!entry?.preview) { - throw new StudioError("STUDIO_ASSET_INVALID", "Theme preview is unavailable"); - } - return { - bytes: await readFile(join( - this.dependencies.paths.themesRoot, - ...entry.preview.split("/"), - )), - mimeType: "image/webp", - }; - } - - private async loadSource(input: StudioCreateDraftInput): Promise { - if (input.source.source === "personal") { - return this.store.readTheme(input.source.ref); - } - const catalog = await loadThemeCatalog(this.dependencies.paths.themesRoot); - const collection = input.source.source === "builtin" ? catalog.builtins : catalog.recipes; - const entry = collection.find((candidate) => - candidate.id === input.source.ref.id && candidate.version === input.source.ref.version - ); - if (!entry) throw new StudioError("STUDIO_REQUEST_INVALID", "Theme source is unavailable"); - const directory = join(this.dependencies.paths.themesRoot, ...entry.path.split("/")); - if (entry.kind === "theme") return loadThemeDirectory(directory); - const recipe = parseThemeDocument(JSON.parse(await readFile(join(directory, "recipe.json"), "utf8"))); - return validateThemeBundle(recipe, new Map()); - } - - private async saveRecord(record: DraftRecord): Promise { - if (!record.dirty && record.savedRef) return record; - this.assertPersonalThemeId(record.theme.id); - const issues = validateStudioDraft(record.theme); - const errors = issues.filter((issue) => issue.severity === "error"); - if (errors.length > 0) { - throw new StudioError( - "STUDIO_DRAFT_INVALID", - errors.map((issue) => `${issue.path}: ${issue.message}`).join("; "), - ); - } - const refs = await this.store.list(); - const files = await this.readDraftFiles(record.draftId, record.theme); - for (const ref of refs.filter((candidate) => candidate.id === record.theme.id).reverse()) { - const theme = parseThemeDocument({ ...record.theme, version: ref.version }); - const existing = await this.store.readTheme(ref); - if (isDeepStrictEqual(existing.theme, theme) && themeAssetPaths(theme).every((path) => - bytesEqual(files.get(path), existing.files.get(path)))) { - return DraftRecordSchema.parse({ - ...record, - theme, - revision: record.revision + 1, - updatedAt: this.now(), - savedRef: ref, - dirty: false, - }); - } - } - - const version = nextPatchVersion(refs, record.theme.id); - const theme = parseThemeDocument({ - ...record.theme, - schemaVersion: THEME_SCHEMA_VERSION, - kind: "theme", - version, - }); - const backgroundPath = theme.assets.background; - if (!backgroundPath) throw new StudioError("STUDIO_DRAFT_INVALID", "Background image is required"); - const background = files.get(backgroundPath); - if (!background) throw new StudioError("STUDIO_DRAFT_INVALID", "Background image is missing"); - files.set("preview.webp", await sharp(background) - .resize({ width: 640, height: 400, fit: "cover" }) - .webp({ quality: 84 }) - .toBuffer()); - let bundle: ValidatedThemeBundle; - let ref: ThemeRef; - try { - bundle = validateThemeBundle(theme, files); - this.assertRuntimeReady(bundle); - ref = await this.store.install(bundle); - } catch (error) { - if (error instanceof StudioError) throw error; - throw studioThemeError("STUDIO_SAVE_FAILED", error, "Theme version was not saved"); - } - return DraftRecordSchema.parse({ - ...record, - theme, - revision: record.revision + 1, - updatedAt: this.now(), - savedRef: ref, - dirty: false, - }); - } - - private async normalizeAsset(input: StudioUploadAssetInput): Promise<{ - readonly path: string; - readonly bytes: Uint8Array; - }> { - if (input.slot === "ui-font" || input.slot === "code-font" || - input.slot === "display-font") { - if (input.bytes.length > SOURCE_FONT_LIMIT_BYTES || sourceExtension(input.fileName) !== ".woff2" || - Buffer.from(input.bytes.subarray(0, 4)).toString("ascii") !== "wOF2") { - throw new StudioError("STUDIO_ASSET_INVALID", "Font must be a valid WOFF2 file up to 5 MB"); - } - const key = this.requiredAssetKey(input); - const digest = createHash("sha256").update(input.bytes).digest("hex").slice(0, 12); - return { path: `fonts/${key}-${digest}.woff2`, bytes: input.bytes }; - } - if (!IMAGE_MIME_TYPES.has(input.mimeType) || - ![".png", ".jpg", ".jpeg", ".webp"].includes(sourceExtension(input.fileName))) { - throw new StudioError( - "STUDIO_ASSET_INVALID", - `Asset slot ${input.slot} requires a PNG, JPEG, or WebP image`, - ); - } - if (input.bytes.length > SOURCE_IMAGE_LIMIT_BYTES) { - throw new StudioError("STUDIO_ASSET_INVALID", "Source image exceeds 50 MB"); - } - let output: Buffer; - try { - const suggestionSlot = suggestionIconSlot(input.slot); - const projectIndex = projectIconIndex(input.slot); - const interfaceImage = input.slot === "profile-avatar" || suggestionSlot !== undefined || - projectIndex !== undefined; - const width = input.slot === "background" - ? 2400 - : input.slot === "profile-avatar" - ? 256 - : suggestionSlot || projectIndex !== undefined - ? 192 - : 1400; - const height = input.slot === "background" - ? 1350 - : input.slot === "profile-avatar" - ? 256 - : suggestionSlot || projectIndex !== undefined - ? 192 - : 1400; - output = await sharp(input.bytes, { limitInputPixels: 80_000_000 }) - .rotate() - .resize({ - width, - height, - fit: interfaceImage ? "cover" : "inside", - withoutEnlargement: !interfaceImage, - }) - .webp({ quality: 80, effort: 4 }) - .toBuffer(); - } catch (error) { - throw new StudioError( - "STUDIO_ASSET_INVALID", - error instanceof Error ? error.message : "Image processing failed", - ); - } - if (output.length > PROCESSED_IMAGE_LIMIT_BYTES) { - throw new StudioError("STUDIO_ASSET_INVALID", "Processed image exceeds 16 MB"); - } - const digest = createHash("sha256").update(output).digest("hex").slice(0, 12); - const path = input.slot === "background" - ? `assets/background-${digest}.webp` - : input.slot === "portrait" - ? `assets/portrait-${digest}.webp` - : input.slot === "profile-avatar" - ? `assets/profile-avatar-${digest}.webp` - : suggestionIconSlot(input.slot) - ? `assets/${input.slot}-${digest}.webp` - : projectIconIndex(input.slot) !== undefined - ? `assets/${input.slot}-${digest}.webp` - : `assets/decoration-${this.requiredAssetKey(input)}-${digest}.webp`; - return { path, bytes: output }; - } - - private requiredAssetKey(input: StudioUploadAssetInput): string { - if (input.assetKey) return input.assetKey; - if (input.slot === "ui-font") return "ui-font"; - if (input.slot === "code-font") return "code-font"; - if (input.slot === "display-font") return "display-font"; - throw new StudioError("STUDIO_ASSET_INVALID", "Asset key is required"); - } - - private assertRuntimeReady(bundle: ValidatedThemeBundle): void { - try { - compileTheme(bundle); - } catch (error) { - throw new StudioError( - "STUDIO_DRAFT_INVALID", - error instanceof RuntimeThemeError - ? error.message - : "Theme cannot be compiled for the Runtime", - ); - } - } - - private assertPersonalThemeId(id: string): void { - if (this.reservedThemeIds.has(id)) { - throw new StudioError( - "STUDIO_DRAFT_INVALID", - "Theme ID is reserved by the built-in catalog", - ); - } - } - - private async readDraftFiles( - draftId: string, - theme: ThemeDraftDocument, - ): Promise> { - const files = new Map(); - for (const path of themeAssetPaths(theme)) { - files.set(path, await readFile(this.assetPath(draftId, path))); - } - return files; - } - - private async writeBundleFiles( - draftId: string, - files: ReadonlyMap, - ): Promise { - for (const [path, bytes] of files) { - if (path === "preview.webp") continue; - if (!isSafeThemePath(path)) { - throw new StudioError("STUDIO_ASSET_INVALID", "Theme source contains an unsafe asset path"); - } - await this.writeAsset(draftId, path, bytes); - } - } - - private async listDraftRecords(): Promise { - let entries; - try { - entries = await readdir(this.dependencies.paths.themeStudioDraftDirectory, { - withFileTypes: true, - }); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") return []; - throw error; - } - return Promise.all(entries - .filter((entry) => entry.isDirectory() && z.string().uuid().safeParse(entry.name).success) - .map((entry) => this.readRecord(entry.name))); - } - - private async removeDraftGroup( - targetGroup: string, - builtinIds: readonly string[], - ): Promise { - const records = await this.listDraftRecords(); - for (const record of records) { - if (personalThemeGroupKey(record.theme.id, builtinIds) !== targetGroup) continue; - await rm(this.draftDirectory(record.draftId), { recursive: true, force: true }); - } - } - - private async removeDuplicateDrafts(): Promise { - const records = [...await this.listDraftRecords()].sort((left, right) => - right.updatedAt.localeCompare(left.updatedAt) || - right.draftId.localeCompare(left.draftId) - ); - const retainedGroups = new Set(); - for (const record of records) { - const group = personalThemeGroupKey(record.theme.id, this.builtinThemeIds); - if (!retainedGroups.has(group)) { - retainedGroups.add(group); - continue; - } - await rm(this.draftDirectory(record.draftId), { recursive: true, force: true }); - } - } - - private async invalidateDeletedThemeRefs(refs: readonly ThemeRef[]): Promise { - const deleted = new Set(refs.map((ref) => `${ref.id}@${ref.version}`)); - let entries; - try { - entries = await readdir(this.dependencies.paths.themeStudioDraftDirectory, { - withFileTypes: true, - }); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") return; - throw error; - } - for (const entry of entries) { - if (!entry.isDirectory() || !z.string().uuid().safeParse(entry.name).success) continue; - await this.mutate(entry.name, async (record) => { - const savedRef = record.savedRef; - if (!savedRef || !deleted.has(`${savedRef.id}@${savedRef.version}`)) return record; - return DraftRecordSchema.parse({ - ...record, - revision: record.revision + 1, - updatedAt: this.now(), - savedRef: null, - dirty: true, - }); - }); - } - } - - private async writeAsset(draftId: string, path: string, bytes: Uint8Array): Promise { - const target = this.assetPath(draftId, path); - await mkdir(dirname(target), { recursive: true }); - const temporary = `${target}.${process.pid}-${this.newId()}.tmp`; - await writeFile(temporary, bytes); - await rename(temporary, target); - } - - private view(record: DraftRecord): StudioDraft { - const assetUrls = Object.fromEntries(themeAssetPaths(record.theme).map((path) => [ - path, - `/api/draft-asset?draftId=${encodeURIComponent(record.draftId)}&path=${encodeURIComponent(path)}`, - ])); - return StudioDraftSchema.parse({ - draftId: record.draftId, - theme: record.theme, - revision: record.revision, - updatedAt: record.updatedAt, - savedRef: record.savedRef, - dirty: record.dirty, - undoAvailable: record.past.length > 0, - redoAvailable: record.future.length > 0, - issues: validateStudioDraft(record.theme), - assetUrls, - }); - } - - private async mutate( - draftId: string, - operation: (record: DraftRecord) => Promise, - ): Promise { - const previous = this.queues.get(draftId) ?? Promise.resolve(); - let release!: () => void; - const current = new Promise((resolve) => { release = resolve; }); - this.queues.set(draftId, current); - await previous; - try { - const next = await operation(await this.readRecord(draftId)); - await this.writeRecord(next); - await this.cleanupDraftAssets(next); - return this.view(next); - } finally { - release(); - if (this.queues.get(draftId) === current) this.queues.delete(draftId); - } - } - - private async serializeTheme(themeId: string, operation: () => Promise): Promise { - const previous = this.themeQueues.get(themeId) ?? Promise.resolve(); - let release!: () => void; - const current = new Promise((resolve) => { release = resolve; }); - this.themeQueues.set(themeId, current); - await previous; - try { - return await operation(); - } finally { - release(); - if (this.themeQueues.get(themeId) === current) this.themeQueues.delete(themeId); - } - } - - private assertRevision(record: DraftRecord, expectedRevision: number): void { - if (record.revision !== expectedRevision) { - throw new StudioError( - "STUDIO_DRAFT_CONFLICT", - `Draft revision changed from ${expectedRevision} to ${record.revision}`, - ); - } - } - - private draftDirectory(draftId: string): string { - const parsed = z.string().uuid().parse(draftId); - return join(this.dependencies.paths.themeStudioDraftDirectory, parsed); - } - - private recordPath(draftId: string): string { - return join(this.draftDirectory(draftId), "draft.json"); - } - - private assetPath(draftId: string, path: string): string { - if (!isSafeThemePath(path)) { - throw new StudioError("STUDIO_ASSET_INVALID", "Draft asset path is unsafe"); - } - return join(this.draftDirectory(draftId), ...path.split("/")); - } - - private async readRecord(draftId: string): Promise { - try { - return parseDraftRecord(JSON.parse(await readFile(this.recordPath(draftId), "utf8"))); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") { - throw new StudioError("STUDIO_DRAFT_NOT_FOUND", "Theme draft does not exist"); - } - if (error instanceof StudioError) throw error; - throw new StudioError( - "STUDIO_DRAFT_INVALID", - error instanceof Error ? error.message : "Theme draft is invalid", - ); - } - } - - private async writeRecord(record: DraftRecord): Promise { - const target = this.recordPath(record.draftId); - await mkdir(dirname(target), { recursive: true }); - const temporary = `${target}.${process.pid}-${this.newId()}.tmp`; - await writeFile(temporary, `${JSON.stringify(DraftRecordSchema.parse(record), null, 2)}\n`, "utf8"); - await rename(temporary, target); - } - - private async cleanupDraftAssets(record: DraftRecord): Promise { - const referenced = recordAssetPaths(record); - await Promise.all([ - this.cleanupDraftAssetDirectory(record.draftId, "assets", referenced), - this.cleanupDraftAssetDirectory(record.draftId, "fonts", referenced), - ]); - } - - private async cleanupDraftAssetDirectory( - draftId: string, - relativeDirectory: string, - referenced: ReadonlySet, - ): Promise { - const directory = join(this.draftDirectory(draftId), ...relativeDirectory.split("/")); - let entries; - try { - entries = await readdir(directory, { withFileTypes: true }); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") return; - throw error; - } - - for (const entry of entries) { - const relativePath = `${relativeDirectory}/${entry.name}`; - if (entry.isDirectory()) { - await this.cleanupDraftAssetDirectory(draftId, relativePath, referenced); - } else if (entry.isFile() && !referenced.has(relativePath)) { - await rm(join(directory, entry.name)); - } - } - - try { - await rmdir(directory); - } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - if (code !== "ENOENT" && code !== "ENOTEMPTY") throw error; - } - } - - private previewUrl(source: "builtin" | "personal", id: string, version: string): string { - return `/api/theme-preview?source=${source}&id=${encodeURIComponent(id)}&version=${encodeURIComponent(version)}`; - } - - private libraryItem( - theme: ThemeDraftDocument, - source: "builtin" | "personal" | "recipe", - ready: boolean, - localOnly: boolean, - previewUrl: string | null, - ) { - return { - ref: { id: theme.id, version: theme.version }, - name: theme.name, - ...(theme.description ? { description: theme.description } : {}), - author: theme.author, - homepage: theme.metadata?.homepage ?? null, - ...(theme.metadata?.localized ? { localized: theme.metadata.localized } : {}), - source, - ready, - localOnly, - previewUrl, - }; - } -} diff --git a/runtime/theme-studio-service/tsconfig.json b/runtime/theme-studio-service/tsconfig.json deleted file mode 100644 index 98ce41a..0000000 --- a/runtime/theme-studio-service/tsconfig.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "rootDir": "src", - "outDir": "dist" - }, - "references": [ - { "path": "../../packages/cdp-adapter" }, - { "path": "../../packages/theme-schema" }, - { "path": "../../packages/theme-core" }, - { "path": "../../packages/theme-studio-core" }, - { "path": "../windows" } - ], - "include": ["src/**/*.ts"] -} diff --git a/runtime/windows/package.json b/runtime/windows/package.json deleted file mode 100644 index da2c3d1..0000000 --- a/runtime/windows/package.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "name": "@open-chatgpt-skin/windows-runtime", - "version": "0.2.0", - "type": "module", - "exports": "./dist/index.js", - "types": "./dist/index.d.ts", - "bin": { - "open-chatgpt-skin-runtime": "./dist/cli.js" - }, - "files": [ - "dist" - ], - "scripts": { - "build": "tsc -b" - }, - "dependencies": { - "@open-chatgpt-skin/cdp-adapter": "0.2.0", - "@open-chatgpt-skin/theme-core": "0.2.0", - "@open-chatgpt-skin/theme-schema": "0.2.0", - "zod": "^3.25.76" - } -} diff --git a/runtime/windows/src/acceptance-cli.ts b/runtime/windows/src/acceptance-cli.ts deleted file mode 100644 index 2342566..0000000 --- a/runtime/windows/src/acceptance-cli.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { randomUUID } from "node:crypto"; -import { performance } from "node:perf_hooks"; -import { - runRuntimeAcceptance, - type RuntimeAcceptanceCommand, - type RuntimeAcceptanceControlCommand, - type RuntimeAcceptanceDependencies, -} from "./acceptance-runner.js"; -import { recordRuntimeAcceptanceEvidence } from "./acceptance-evidence.js"; -import { PendingAcceptanceStore } from "./acceptance-session.js"; -import { - createProductionRuntimeCliDependencies, - RUNTIME_VERSION, -} from "./controller/production.js"; -import { executeRuntimeControlCommand, type RuntimeControlCliCommand } from "./cli/run.js"; -import { TrustedInstallStore } from "./discovery/trusted-cache.js"; -import { discoverCodexInstall } from "./discovery/discover.js"; -import { RuntimeError, runtimeErrorCode } from "./errors.js"; -import { createProductionRuntimePaths, prepareProductionRuntimePaths } from "./paths.js"; -import { RuntimeStateStore } from "./state.js"; -import { PowerShellWindowsProvider } from "./windows/powershell-provider.js"; -import type { RuntimeStatusView } from "./control/protocol.js"; - -function parseAcceptanceArguments(args: readonly string[]): RuntimeAcceptanceCommand { - if (args.length === 1 && args[0] === "--begin") return { kind: "begin" }; - if (args.length === 1 && args[0] === "--finalize") return { kind: "finalize" }; - throw new RuntimeError( - "RUNTIME_ENVIRONMENT_INVALID", - "runtime:acceptance accepts only --begin or --finalize", - ); -} - -function cliCommand(command: RuntimeAcceptanceControlCommand): RuntimeControlCliCommand { - switch (command.command) { - case "launch": - return { kind: "launch", themeId: command.themeId }; - case "switch": - return { kind: "switch", themeId: command.themeId }; - case "pause": - return { kind: "pause" }; - case "resume": - return { kind: "resume" }; - case "restore": - return { kind: "restore" }; - case "status": - return { kind: "status" }; - } -} - -function statusFromControlResult(value: Awaited>): RuntimeStatusView { - if (!("protocolVersion" in value)) return value; - if (!value.ok) { - throw new RuntimeError(value.error.code, "Runtime acceptance control command failed"); - } - return value.result; -} - -function productionDependencies(): RuntimeAcceptanceDependencies { - const paths = createProductionRuntimePaths(); - const provider = new PowerShellWindowsProvider(undefined, paths.dataRoot); - const runtimeState = new RuntimeStateStore(paths.sessionFile); - const acceptanceStore = new PendingAcceptanceStore(paths.acceptanceSessionFile); - const cache = new TrustedInstallStore(paths.installCache); - const runtimeCli = createProductionRuntimeCliDependencies(); - return { - provider, - sessionStore: acceptanceStore, - readManagedSession: async () => { - const state = await runtimeState.read(); - if (!state?.runtime || !state.codex || !state.cdp) return null; - return { - runtime: state.runtime, - root: { pid: state.codex.rootPid, startedAt: state.codex.startedAt }, - cdp: state.cdp, - packageVersion: state.codex.packageVersion, - }; - }, - securePendingDirectory: () => provider.secureDirectory(paths.runtimeDirectory), - executeControl: async (command) => statusFromControlResult( - await executeRuntimeControlCommand(cliCommand(command), runtimeCli), - ), - discoverNormalCodex: async () => { - const discovery = await discoverCodexInstall(provider, cache); - return { install: discovery.install, root: discovery.runningRoot }; - }, - recordEvidence: (evidence) => recordRuntimeAcceptanceEvidence( - paths.acceptanceEvidenceDirectory, - evidence, - ), - now: () => new Date().toISOString(), - performanceNow: () => performance.now(), - newSessionId: randomUUID, - runtimeVersion: RUNTIME_VERSION, - }; -} - -try { - if (process.platform !== "win32") { - throw new RuntimeError( - "RUNTIME_ENVIRONMENT_INVALID", - "Automated Runtime acceptance is currently available on Windows only", - "Complete the macOS manual acceptance checklist on a real Mac.", - ); - } - const command = parseAcceptanceArguments(process.argv.slice(2)); - await prepareProductionRuntimePaths(); - const result = await runRuntimeAcceptance(command, productionDependencies()); - process.stdout.write(`${JSON.stringify(result)}\n`); -} catch (error) { - process.exitCode = 1; - process.stderr.write(`${JSON.stringify({ - compatible: false, - error: { code: runtimeErrorCode(error) }, - })}\n`); -} diff --git a/runtime/windows/src/acceptance-evidence.ts b/runtime/windows/src/acceptance-evidence.ts deleted file mode 100644 index 58ec942..0000000 --- a/runtime/windows/src/acceptance-evidence.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { randomUUID } from "node:crypto"; -import { mkdir, open, rename, unlink } from "node:fs/promises"; -import { join } from "node:path"; -import { z } from "zod"; -import { ACCEPTANCE_SWITCH_EDGES } from "./acceptance-sequence.js"; -import { RUNTIME_BUILTIN_THEME_IDS, RuntimeBuiltinThemeIdSchema } from "./themes/ids.js"; - -const packageVersionSchema = z.string().regex(/^\d+\.\d+\.\d+\.\d+$/); - -export const RuntimeAcceptanceThemeSchema = z.object({ - id: RuntimeBuiltinThemeIdSchema, - applied: z.literal(true), - verified: z.literal(true), -}).strict(); - -export const RuntimeAcceptanceSwitchSchema = z.object({ - from: RuntimeBuiltinThemeIdSchema, - to: RuntimeBuiltinThemeIdSchema, - verified: z.literal(true), -}).strict(); - -function edgeKey(from: string, to: string): string { - return `${from}\u0000${to}`; -} - -export const RuntimeAcceptanceEvidenceSchema = z.object({ - schemaVersion: z.literal(1), - packageIdentity: z.literal("OpenAI.Codex"), - packageVersion: packageVersionSchema, - runtimeVersion: z.string().min(1).max(40), - themes: z.array(RuntimeAcceptanceThemeSchema).length(RUNTIME_BUILTIN_THEME_IDS.length), - switches: z.array(RuntimeAcceptanceSwitchSchema).length(ACCEPTANCE_SWITCH_EDGES.length), - pauseVerified: z.literal(true), - pausedSwitchVerified: z.literal(true), - resumeVerified: z.literal(true), - restoreVerified: z.literal(true), - maxThemeOperationDurationMs: z.number().nonnegative().max(2_000), - idleCpuPercent: z.number().nonnegative().lt(1), - managedExitVerified: z.literal(true), - cdpClosedVerified: z.literal(true), - normalLaunchNoDebugArguments: z.literal(true), -}).strict().superRefine((value, context) => { - const expectedThemes = new Set(RUNTIME_BUILTIN_THEME_IDS); - const actualThemes = new Set(value.themes.map((theme) => theme.id)); - if (actualThemes.size !== expectedThemes.size || - [...expectedThemes].some((id) => !actualThemes.has(id))) { - context.addIssue({ - code: z.ZodIssueCode.custom, - path: ["themes"], - message: "acceptance evidence must verify every built-in theme exactly once", - }); - } - - const expectedEdges = new Set(ACCEPTANCE_SWITCH_EDGES.map(([from, to]) => edgeKey(from, to))); - const actualEdges = new Set(value.switches.map((edge) => edgeKey(edge.from, edge.to))); - if (actualEdges.size !== expectedEdges.size || - [...expectedEdges].some((edge) => !actualEdges.has(edge))) { - context.addIssue({ - code: z.ZodIssueCode.custom, - path: ["switches"], - message: "acceptance evidence must verify every fixed switch edge exactly once", - }); - } -}); - -export type RuntimeAcceptanceEvidence = z.infer; - -export async function recordRuntimeAcceptanceEvidence( - directory: string, - evidence: RuntimeAcceptanceEvidence, -): Promise { - const validated = RuntimeAcceptanceEvidenceSchema.parse(evidence); - await mkdir(directory, { recursive: true }); - const path = join(directory, `codex-${validated.packageVersion}.json`); - const temporary = `${path}.${process.pid}-${randomUUID()}.tmp`; - const handle = await open(temporary, "wx", 0o600); - try { - await handle.writeFile(`${JSON.stringify(validated, null, 2)}\n`, "utf8"); - await handle.sync(); - } catch (error) { - await handle.close(); - await unlink(temporary).catch((cleanupError: unknown) => { - if ((cleanupError as NodeJS.ErrnoException).code !== "ENOENT") throw cleanupError; - }); - throw error; - } - await handle.close(); - await rename(temporary, path); - return path; -} diff --git a/runtime/windows/src/acceptance-runner.ts b/runtime/windows/src/acceptance-runner.ts deleted file mode 100644 index 061ee72..0000000 --- a/runtime/windows/src/acceptance-runner.ts +++ /dev/null @@ -1,237 +0,0 @@ -import { - ACCEPTANCE_SWITCH_EDGES, -} from "./acceptance-sequence.js"; -import { - RuntimeAcceptanceEvidenceSchema, - type RuntimeAcceptanceEvidence, -} from "./acceptance-evidence.js"; -import { - PendingAcceptanceSessionSchema, - type AcceptanceSessionStore, -} from "./acceptance-session.js"; -import type { RuntimeStatusView } from "./control/protocol.js"; -import { RuntimeError } from "./errors.js"; -import { RUNTIME_BUILTIN_THEME_IDS, type RuntimeBuiltinThemeId } from "./themes/ids.js"; -import type { ProcessIdentity, WindowsRuntimeProvider } from "./types.js"; - -export type RuntimeAcceptanceCommand = - | { readonly kind: "begin" } - | { readonly kind: "finalize" }; - -export type RuntimeAcceptanceControlCommand = - | { readonly command: "launch" | "switch"; readonly themeId: RuntimeBuiltinThemeId } - | { readonly command: "pause" | "resume" | "restore" | "status" }; - -export interface ManagedAcceptanceSession { - readonly runtime: { readonly pid: number; readonly startedAt: string }; - readonly root: { readonly pid: number; readonly startedAt: string }; - readonly cdp: { readonly host: "127.0.0.1"; readonly port: number }; - readonly packageVersion: string; -} - -export interface RuntimeAcceptanceDependencies { - readonly provider: Pick; - readonly sessionStore: AcceptanceSessionStore; - readonly readManagedSession: () => Promise; - readonly securePendingDirectory: () => Promise; - readonly executeControl: ( - command: RuntimeAcceptanceControlCommand, - ) => Promise; - readonly discoverNormalCodex: () => Promise<{ - readonly install: { readonly packageVersion: string }; - readonly root: ProcessIdentity | null; - }>; - readonly recordEvidence: (evidence: RuntimeAcceptanceEvidence) => Promise; - readonly now: () => string; - readonly performanceNow: () => number; - readonly newSessionId: () => string; - readonly runtimeVersion: string; -} - -export type RuntimeAcceptanceResult = - | { - readonly compatible: null; - readonly phase: "awaiting-exit"; - readonly nextAction: string; - } - | { - readonly compatible: true; - readonly phase: "complete"; - readonly evidence: RuntimeAcceptanceEvidence; - }; - -function assertedStatus( - result: RuntimeStatusView, - expectedStatus: RuntimeStatusView["status"], - selectedTheme: RuntimeBuiltinThemeId | null, - appliedTheme: RuntimeBuiltinThemeId | null, -): RuntimeStatusView { - if (result.status !== expectedStatus || - (result.selectedTheme?.id ?? null) !== selectedTheme || - (result.appliedTheme?.id ?? null) !== appliedTheme || - result.skinApplied !== (appliedTheme !== null)) { - throw new RuntimeError("RUNTIME_INVALID_STATE", "Acceptance control result was not verified"); - } - return result; -} - -function assertFiniteDuration(value: number): number { - if (!Number.isFinite(value) || value < 0 || value > 2_000) { - throw new RuntimeError("RUNTIME_INVALID_STATE", "Theme operation exceeded acceptance bounds"); - } - return value; -} - -async function runBegin( - dependencies: RuntimeAcceptanceDependencies, -): Promise { - if (await dependencies.sessionStore.read()) { - throw new RuntimeError("RUNTIME_INVALID_STATE", "Acceptance finalization is still required"); - } - if ((await dependencies.provider.listCodexRoots()).length !== 0) { - throw new RuntimeError("CODEX_ALREADY_RUNNING_UNMANAGED", "Codex must be closed before acceptance"); - } - await dependencies.securePendingDirectory(); - - let maxThemeOperationDurationMs = 0; - const timed = async (command: RuntimeAcceptanceControlCommand): Promise => { - const started = dependencies.performanceNow(); - const result = await dependencies.executeControl(command); - const duration = assertFiniteDuration(dependencies.performanceNow() - started); - maxThemeOperationDurationMs = Math.max(maxThemeOperationDurationMs, duration); - return result; - }; - - assertedStatus( - await dependencies.executeControl({ command: "launch", themeId: "future-idol-cyan" }), - "active", - "future-idol-cyan", - "future-idol-cyan", - ); - - const appliedThemeIds = new Set(["future-idol-cyan"]); - const switches: RuntimeAcceptanceEvidence["switches"] = []; - for (const [source, target] of ACCEPTANCE_SWITCH_EDGES) { - const status = await dependencies.executeControl({ command: "status" }); - assertedStatus(status, "active", source, source); - assertedStatus(await timed({ command: "switch", themeId: target }), "active", target, target); - appliedThemeIds.add(target); - switches.push({ from: source, to: target, verified: true }); - } - - if (appliedThemeIds.size !== RUNTIME_BUILTIN_THEME_IDS.length || - RUNTIME_BUILTIN_THEME_IDS.some((id) => !appliedThemeIds.has(id))) { - throw new RuntimeError("RUNTIME_INVALID_STATE", "Acceptance did not apply every built-in theme"); - } - - const activeBeforePause = ACCEPTANCE_SWITCH_EDGES.at(-1)![1]; - const pausedTheme = "glacier-aurora" as const; - assertedStatus(await timed({ command: "pause" }), "paused", activeBeforePause, null); - assertedStatus(await timed({ command: "switch", themeId: pausedTheme }), "paused", pausedTheme, null); - assertedStatus(await timed({ command: "resume" }), "active", pausedTheme, pausedTheme); - assertedStatus(await dependencies.executeControl({ command: "restore" }), "restored-awaiting-exit", pausedTheme, null); - - const managed = await dependencies.readManagedSession(); - if (!managed) { - throw new RuntimeError("RUNTIME_SESSION_STALE", "Managed Runtime session is unavailable"); - } - const idleCpuPercent = await dependencies.provider.measureProcessCpuPercent( - managed.runtime.pid, - managed.runtime.startedAt, - 2_000, - ); - if (!Number.isFinite(idleCpuPercent) || idleCpuPercent < 0 || idleCpuPercent >= 1) { - throw new RuntimeError("RUNTIME_INVALID_STATE", "Idle CPU exceeded acceptance bounds"); - } - - const now = dependencies.now(); - await dependencies.sessionStore.write(PendingAcceptanceSessionSchema.parse({ - schemaVersion: 1, - sessionId: dependencies.newSessionId(), - status: "awaiting-exit", - runtime: managed.runtime, - root: managed.root, - cdp: managed.cdp, - packageVersion: managed.packageVersion, - runtimeVersion: dependencies.runtimeVersion, - themes: RUNTIME_BUILTIN_THEME_IDS.map((id) => ({ id, applied: true, verified: true })), - switches, - pauseVerified: true, - pausedSwitchVerified: true, - resumeVerified: true, - restoreVerified: true, - maxThemeOperationDurationMs, - idleCpuPercent, - createdAt: now, - updatedAt: now, - })); - return { - compatible: null, - phase: "awaiting-exit", - nextAction: "Quit the managed Codex completely, start Codex normally, then run runtime:acceptance -- --finalize.", - }; -} - -async function runFinalize( - dependencies: RuntimeAcceptanceDependencies, -): Promise { - const pending = await dependencies.sessionStore.read(); - if (!pending) { - throw new RuntimeError("RUNTIME_INVALID_STATE", "No acceptance session is awaiting finalization"); - } - const exited = await dependencies.provider.waitForExit( - pending.root.pid, - pending.root.startedAt, - 100, - ); - if (!exited || await dependencies.provider.inspectPort(pending.cdp.port)) { - throw new RuntimeError("RUNTIME_INVALID_STATE", "Managed Codex cleanup is still pending"); - } - const normal = await dependencies.discoverNormalCodex(); - if (!normal.root || normal.install.packageVersion !== pending.packageVersion || - (normal.root.pid === pending.root.pid && normal.root.startedAt === pending.root.startedAt)) { - throw new RuntimeError("CODEX_IDENTITY_INVALID", "Normal Codex identity could not be verified"); - } - const debug = await dependencies.provider.inspectRemoteDebuggingArguments( - normal.root.pid, - normal.root.startedAt, - ); - if (debug.hasRemoteDebuggingAddress || debug.hasRemoteDebuggingPort) { - throw new RuntimeError("RUNTIME_INVALID_STATE", "Normal Codex still has remote debugging enabled"); - } - const evidence = RuntimeAcceptanceEvidenceSchema.parse({ - schemaVersion: 1, - packageIdentity: "OpenAI.Codex", - packageVersion: pending.packageVersion, - runtimeVersion: pending.runtimeVersion, - themes: pending.themes, - switches: pending.switches, - pauseVerified: pending.pauseVerified, - pausedSwitchVerified: pending.pausedSwitchVerified, - resumeVerified: pending.resumeVerified, - restoreVerified: pending.restoreVerified, - maxThemeOperationDurationMs: pending.maxThemeOperationDurationMs, - idleCpuPercent: pending.idleCpuPercent, - managedExitVerified: true, - cdpClosedVerified: true, - normalLaunchNoDebugArguments: true, - }); - await dependencies.recordEvidence(evidence); - await dependencies.sessionStore.clear(); - return { compatible: true, phase: "complete", evidence }; -} - -export async function runRuntimeAcceptance( - command: RuntimeAcceptanceCommand, - dependencies: RuntimeAcceptanceDependencies, -): Promise { - return command.kind === "begin" - ? runBegin(dependencies) - : runFinalize(dependencies); -} diff --git a/runtime/windows/src/acceptance-sequence.ts b/runtime/windows/src/acceptance-sequence.ts deleted file mode 100644 index db57b14..0000000 --- a/runtime/windows/src/acceptance-sequence.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { - RUNTIME_BUILTIN_THEME_IDS, - type RuntimeBuiltinThemeId, -} from "./themes/ids.js"; - -function buildDirectedSwitchCycle( - themeIds: readonly RuntimeBuiltinThemeId[], -): readonly (readonly [RuntimeBuiltinThemeId, RuntimeBuiltinThemeId])[] { - const remaining = new Map(themeIds.map((source) => [ - source, - themeIds.filter((target) => target !== source), - ])); - const stack: RuntimeBuiltinThemeId[] = [themeIds[0]!]; - const circuit: RuntimeBuiltinThemeId[] = []; - while (stack.length > 0) { - const source = stack.at(-1)!; - const target = remaining.get(source)!.shift(); - if (target) stack.push(target); - else circuit.push(stack.pop()!); - } - const path = circuit.reverse(); - return path.slice(0, -1).map((source, index) => [ - source, - path[index + 1]!, - ] as const); -} - -export const ACCEPTANCE_SWITCH_EDGES = buildDirectedSwitchCycle( - RUNTIME_BUILTIN_THEME_IDS, -); diff --git a/runtime/windows/src/acceptance-session.ts b/runtime/windows/src/acceptance-session.ts deleted file mode 100644 index ed46d17..0000000 --- a/runtime/windows/src/acceptance-session.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { randomUUID } from "node:crypto"; -import { mkdir, open, readFile, rename, unlink } from "node:fs/promises"; -import { dirname } from "node:path"; -import { z } from "zod"; -import { - RuntimeAcceptanceSwitchSchema, - RuntimeAcceptanceThemeSchema, -} from "./acceptance-evidence.js"; -import { ACCEPTANCE_SWITCH_EDGES } from "./acceptance-sequence.js"; -import { RuntimeError } from "./errors.js"; -import { RUNTIME_BUILTIN_THEME_IDS } from "./themes/ids.js"; - -const processIdentitySchema = z.object({ - pid: z.number().int().positive(), - startedAt: z.string().datetime(), -}).strict(); - -const cdpSchema = z.object({ - host: z.literal("127.0.0.1"), - port: z.number().int().min(1).max(65_535), -}).strict(); - -export const PendingAcceptanceSessionSchema = z.object({ - schemaVersion: z.literal(1), - sessionId: z.string().uuid(), - status: z.literal("awaiting-exit"), - runtime: processIdentitySchema, - root: processIdentitySchema, - cdp: cdpSchema, - packageVersion: z.string().regex(/^\d+\.\d+\.\d+\.\d+$/), - runtimeVersion: z.string().min(1).max(40), - themes: z.array(RuntimeAcceptanceThemeSchema).length(RUNTIME_BUILTIN_THEME_IDS.length), - switches: z.array(RuntimeAcceptanceSwitchSchema).length(ACCEPTANCE_SWITCH_EDGES.length), - pauseVerified: z.literal(true), - pausedSwitchVerified: z.literal(true), - resumeVerified: z.literal(true), - restoreVerified: z.literal(true), - maxThemeOperationDurationMs: z.number().nonnegative().max(2_000), - idleCpuPercent: z.number().nonnegative().lt(1), - createdAt: z.string().datetime(), - updatedAt: z.string().datetime(), -}).strict(); - -export type PendingAcceptanceSession = z.infer; - -export interface AcceptanceSessionStore { - read(): Promise; - write(value: PendingAcceptanceSession): Promise; - clear(): Promise; -} - -export class PendingAcceptanceStore implements AcceptanceSessionStore { - constructor(private readonly path: string) {} - - async read(): Promise { - let text: string; - try { - text = await readFile(this.path, "utf8"); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; - throw error; - } - try { - return PendingAcceptanceSessionSchema.parse(JSON.parse(text)); - } catch (error) { - throw new RuntimeError( - "RUNTIME_SESSION_STALE", - error instanceof Error ? error.message : String(error), - ); - } - } - - async write(value: PendingAcceptanceSession): Promise { - const session = PendingAcceptanceSessionSchema.parse(value); - await mkdir(dirname(this.path), { recursive: true }); - const temporary = `${this.path}.${process.pid}-${randomUUID()}.tmp`; - const handle = await open(temporary, "wx", 0o600); - try { - await handle.writeFile(`${JSON.stringify(session, null, 2)}\n`, "utf8"); - await handle.sync(); - } catch (error) { - await handle.close(); - await unlink(temporary).catch((cleanupError: unknown) => { - if ((cleanupError as NodeJS.ErrnoException).code !== "ENOENT") throw cleanupError; - }); - throw error; - } - await handle.close(); - await rename(temporary, this.path); - } - - async clear(): Promise { - try { - await unlink(this.path); - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; - } - } -} diff --git a/runtime/windows/src/cli.ts b/runtime/windows/src/cli.ts deleted file mode 100644 index 055c279..0000000 --- a/runtime/windows/src/cli.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { runtimeErrorCode } from "./errors.js"; -import { parseInternalRuntimeArguments } from "./cli/arguments.js"; -import { runRuntimeCli } from "./cli/run.js"; -import { - createProductionRuntimeCliDependencies, - serveProductionRuntimeController, - startupFailureResponse, - startupReadyResponse, -} from "./controller/production.js"; -import { prepareProductionRuntimePaths } from "./paths.js"; - -function writeJson(stream: NodeJS.WriteStream, value: unknown): void { - stream.write(`${JSON.stringify(value)}\n`); -} - -async function main(): Promise { - let command; - try { - command = parseInternalRuntimeArguments(process.argv.slice(2)); - } catch (error) { - process.exitCode = 64; - writeJson(process.stderr, { error: { code: runtimeErrorCode(error) } }); - return; - } - - if (command.kind === "serve") { - try { - await prepareProductionRuntimePaths(); - await serveProductionRuntimeController(command.mode); - writeJson(process.stdout, startupReadyResponse(command.startupId)); - } catch (error) { - process.exitCode = 69; - writeJson(process.stdout, startupFailureResponse(command.startupId, error)); - } - return; - } - - try { - await prepareProductionRuntimePaths(); - const dependencies = createProductionRuntimeCliDependencies(); - process.exitCode = await runRuntimeCli(command.kind === "list-themes" - ? ["list-themes"] - : process.argv.slice(2), dependencies, { - stdout: (value) => process.stdout.write(value), - stderr: (value) => process.stderr.write(value), - }); - } catch (error) { - process.exitCode = 69; - writeJson(process.stderr, { error: { code: runtimeErrorCode(error) } }); - } -} - -void main(); diff --git a/runtime/windows/src/cli/arguments.ts b/runtime/windows/src/cli/arguments.ts deleted file mode 100644 index 884d71e..0000000 --- a/runtime/windows/src/cli/arguments.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { RuntimeError } from "../errors.js"; -import { - ThemeIdSchema, - ThemeVersionSchema, -} from "@open-chatgpt-skin/theme-schema"; -import { - RuntimeBuiltinThemeIdSchema, -} from "../themes/ids.js"; - -export type RuntimeCliCommand = - | { readonly kind: "list-themes" } - | { readonly kind: "import"; readonly themeFile: string } - | { readonly kind: "launch"; readonly themeId: string; readonly themeVersion?: string } - | { readonly kind: "status" } - | { readonly kind: "switch"; readonly themeId: string; readonly themeVersion?: string } - | { readonly kind: "pause" } - | { readonly kind: "resume" } - | { readonly kind: "restore" } - | { readonly kind: "serve"; readonly mode: "new" | "recover"; readonly startupId: string }; - -type PublicRuntimeCliCommand = Exclude; - -function usageError(): RuntimeError { - return new RuntimeError( - "RUNTIME_ENVIRONMENT_INVALID", - "Runtime accepts only fixed public command forms", - ); -} - -function parseThemeCommand( - kind: "launch" | "switch", - args: readonly string[], -): PublicRuntimeCliCommand { - if (args.length === 3 && args[1] === "--theme") { - const themeId = RuntimeBuiltinThemeIdSchema.safeParse(args[2]); - if (!themeId.success) throw usageError(); - return { kind, themeId: themeId.data }; - } - if (args.length === 5 && args[1] === "--theme" && args[3] === "--version") { - const themeId = ThemeIdSchema.safeParse(args[2]); - const themeVersion = ThemeVersionSchema.safeParse(args[4]); - if (!themeId.success || !themeVersion.success) throw usageError(); - return { kind, themeId: themeId.data, themeVersion: themeVersion.data }; - } - throw usageError(); -} - -function parsePublicArguments(args: readonly string[]): PublicRuntimeCliCommand { - if (args.length === 1 && args[0] === "list-themes") return { kind: "list-themes" }; - if (args.length === 3 && args[0] === "import" && args[1] === "--theme-file" && - args[2] && args[2].length <= 4096 && !args[2].includes("\0")) { - return { kind: "import", themeFile: args[2] }; - } - if (args.length === 1 && args[0] === "status") return { kind: "status" }; - if (args.length === 1 && args[0] === "pause") return { kind: "pause" }; - if (args.length === 1 && args[0] === "resume") return { kind: "resume" }; - if (args.length === 1 && args[0] === "restore") return { kind: "restore" }; - if (args[0] === "launch") return parseThemeCommand("launch", args); - if (args[0] === "switch") return parseThemeCommand("switch", args); - throw usageError(); -} - -export function parseRuntimeArguments(args: readonly string[]): PublicRuntimeCliCommand { - return parsePublicArguments(args); -} - -export function parseInternalRuntimeArguments(args: readonly string[]): RuntimeCliCommand { - if (args[0] !== "serve") return parsePublicArguments(args); - if (args.length !== 5 || args[1] !== "--mode" || - (args[2] !== "new" && args[2] !== "recover") || args[3] !== "--startup-id" || - !/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(args[4]!)) { - throw usageError(); - } - return { kind: "serve", mode: args[2], startupId: args[4]! }; -} diff --git a/runtime/windows/src/cli/run.ts b/runtime/windows/src/cli/run.ts deleted file mode 100644 index add4313..0000000 --- a/runtime/windows/src/cli/run.ts +++ /dev/null @@ -1,278 +0,0 @@ -import { - isRuntimeThemeV4ErrorCode, - runtimeErrorCode, - RuntimeError, - type RuntimeErrorCode, -} from "../errors.js"; -import { - CONTROL_PROTOCOL_VERSION, - type ControlRequest, - type ControlResponse, - type RuntimeStatusView, -} from "../control/protocol.js"; -import type { RuntimeSessionState, RuntimeStateStore } from "../state.js"; -import type { RuntimeThemeRepository } from "../themes/runtime-theme-repository.js"; -import { parseRuntimeArguments, type RuntimeCliCommand } from "./arguments.js"; - -export const RUNTIME_CLI_EXIT = { - ok: 0, - usage: 64, - validation: 65, - unavailable: 69, - internal: 70, - filesystem: 73, -} as const; - -export interface RuntimeCliDependencies { - readonly themes: Pick; - readonly state: Pick; - readonly currentUserSid: () => Promise; - readonly send: ( - sid: string, - request: ControlRequest, - responseTimeoutMs?: number, - ) => Promise; - readonly startController: (mode: "new" | "recover") => Promise; - readonly newRequestId: () => string; -} - -export interface RuntimeCliIo { - readonly stdout: (value: string) => void; - readonly stderr: (value: string) => void; -} - -export interface RunRuntimeCliOptions { - readonly startupTimeoutMs?: number; -} - -export type RuntimeControlCliCommand = Exclude; - -const DEFAULT_STARTUP_TIMEOUT_MS = 20_000; -const MUTATION_RESPONSE_TIMEOUT_MS = 60_000; - -function stoppedStatus(): RuntimeStatusView { - return { - status: "stopped", - controllerAvailable: false, - selectedTheme: null, - appliedTheme: null, - skinApplied: false, - packageVersion: null, - operation: null, - nextAction: "Launch one of the built-in themes.", - }; -} - -function writeJson(write: (value: string) => void, value: unknown): void { - write(`${JSON.stringify(value, null, 2)}\n`); -} - -function isRuntimeCode(error: unknown, code: RuntimeErrorCode): boolean { - return runtimeErrorCode(error) === code; -} - -function exitForError(error: unknown): number { - if (error instanceof RuntimeError) { - if (isRuntimeThemeV4ErrorCode(error.code)) return RUNTIME_CLI_EXIT.validation; - switch (error.code) { - case "THEME_NOT_FOUND": - case "THEME_NOT_READY": - case "THEME_RUNTIME_TOO_LARGE": - case "RUNTIME_SESSION_STALE": - case "RUNTIME_INVALID_STATE": - return RUNTIME_CLI_EXIT.validation; - case "RUNTIME_ENVIRONMENT_INVALID": - case "CODEX_NOT_INSTALLED": - case "CODEX_DISCOVERY_REQUIRES_BOOTSTRAP": - case "CODEX_IDENTITY_INVALID": - case "CODEX_ALREADY_RUNNING_UNMANAGED": - case "CODEX_LAUNCH_FAILED": - case "CODEX_WINDOW_ACTIVATION_FAILED": - case "PROCESS_INSPECTION_DENIED": - case "CDP_NOT_READY": - case "CDP_ENDPOINT_UNSAFE": - case "CDP_PROCESS_MISMATCH": - case "CDP_TARGET_NOT_FOUND": - case "CDP_TARGET_AMBIGUOUS": - case "ADAPTER_INCOMPATIBLE": - case "RUNTIME_BUSY": - case "RUNTIME_CONTROL_UNAVAILABLE": - case "RESTORE_AWAITING_EXIT": - return RUNTIME_CLI_EXIT.unavailable; - default: - return RUNTIME_CLI_EXIT.internal; - } - } - - const code = error instanceof Error ? (error as NodeJS.ErrnoException).code : undefined; - if (typeof code === "string" && ["EACCES", "EEXIST", "EIO", "ENOENT", "ENOSPC", "EPERM"] - .includes(code)) { - return RUNTIME_CLI_EXIT.filesystem; - } - return RUNTIME_CLI_EXIT.internal; -} - -function errorOutput(error: unknown): { readonly error: { readonly code: RuntimeErrorCode } } { - return { error: { code: runtimeErrorCode(error) } }; -} - -function requestFor(command: RuntimeControlCliCommand, requestId: string): ControlRequest { - const base = { protocolVersion: CONTROL_PROTOCOL_VERSION, requestId } as const; - switch (command.kind) { - case "launch": - return { - ...base, - command: "launch", - params: command.themeVersion - ? { themeId: command.themeId, themeVersion: command.themeVersion } - : { themeId: command.themeId }, - }; - case "status": - return { ...base, command: "status", params: {} }; - case "switch": - return { - ...base, - command: "switch", - params: command.themeVersion - ? { themeId: command.themeId, themeVersion: command.themeVersion } - : { themeId: command.themeId }, - }; - case "pause": - return { ...base, command: "pause", params: {} }; - case "resume": - return { ...base, command: "resume", params: {} }; - case "restore": - return { ...base, command: "restore", params: {} }; - } -} - -async function sendCommand( - dependencies: RuntimeCliDependencies, - request: ControlRequest, -): Promise { - const sid = await dependencies.currentUserSid(); - if (request.command === "status") return dependencies.send(sid, request); - return dependencies.send(sid, request, MUTATION_RESPONSE_TIMEOUT_MS); -} - -async function waitForControllerStart( - startController: RuntimeCliDependencies["startController"], - mode: "new" | "recover", - timeoutMs: number, -): Promise { - if (!Number.isInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 60_000) { - throw new RuntimeError("RUNTIME_ENVIRONMENT_INVALID", "Runtime startup timeout is invalid"); - } - let timeout: ReturnType | null = null; - try { - await Promise.race([ - startController(mode), - new Promise((_, reject) => { - timeout = setTimeout(() => reject(new RuntimeError( - "RUNTIME_CONTROL_UNAVAILABLE", - "Runtime controller did not become ready in time", - )), timeoutMs); - }), - ]); - } finally { - if (timeout) clearTimeout(timeout); - } -} - -async function executeControlCommand( - command: RuntimeControlCliCommand, - dependencies: RuntimeCliDependencies, - options: Required, -): Promise { - const request = requestFor(command, dependencies.newRequestId()); - try { - return await sendCommand(dependencies, request); - } catch (error) { - if (!isRuntimeCode(error, "RUNTIME_CONTROL_UNAVAILABLE")) throw error; - } - - const state: RuntimeSessionState | null = await dependencies.state.read(); - if (command.kind === "status" && state === null) return stoppedStatus(); - - let mode: "new" | "recover"; - if (state !== null) { - mode = "recover"; - } else if (command.kind === "launch") { - mode = "new"; - } else { - throw new RuntimeError( - "RUNTIME_CONTROL_UNAVAILABLE", - "Runtime controller is not running", - ); - } - - try { - await waitForControllerStart(dependencies.startController, mode, options.startupTimeoutMs); - } catch (error) { - if (!isRuntimeCode(error, "RUNTIME_CONTROL_UNAVAILABLE") && - !isRuntimeCode(error, "RUNTIME_BUSY")) throw error; - } - return sendCommand(dependencies, request); -} - -export async function executeRuntimeControlCommand( - command: RuntimeControlCliCommand, - dependencies: RuntimeCliDependencies, - options: RunRuntimeCliOptions = {}, -): Promise { - return executeControlCommand(command, dependencies, { - startupTimeoutMs: options.startupTimeoutMs ?? DEFAULT_STARTUP_TIMEOUT_MS, - }); -} - -function outputResponse(response: ControlResponse | RuntimeStatusView): { - readonly ok: boolean; - readonly body: unknown; - readonly error?: RuntimeErrorCode; -} { - if (!("protocolVersion" in response)) return { ok: true, body: response }; - if (response.ok) return { ok: true, body: response.result }; - return { ok: false, body: { error: response.error }, error: response.error.code }; -} - -export async function runRuntimeCli( - args: readonly string[], - dependencies: RuntimeCliDependencies, - io: RuntimeCliIo, - options: RunRuntimeCliOptions = {}, -): Promise { - let command: Exclude; - try { - command = parseRuntimeArguments(args); - } catch (error) { - writeJson(io.stderr, errorOutput(error)); - return RUNTIME_CLI_EXIT.usage; - } - - const resolvedOptions: Required = { - startupTimeoutMs: options.startupTimeoutMs ?? DEFAULT_STARTUP_TIMEOUT_MS, - }; - try { - if (command.kind === "list-themes") { - writeJson(io.stdout, { themes: await dependencies.themes.list() }); - return RUNTIME_CLI_EXIT.ok; - } - if (command.kind === "import") { - writeJson(io.stdout, { theme: await dependencies.themes.importFile(command.themeFile) }); - return RUNTIME_CLI_EXIT.ok; - } - - const response = outputResponse(await executeControlCommand(command, dependencies, resolvedOptions)); - if (response.ok) { - writeJson(io.stdout, response.body); - return RUNTIME_CLI_EXIT.ok; - } - writeJson(io.stderr, response.body); - return exitForError(new RuntimeError(response.error!, "Runtime command failed")); - } catch (error) { - writeJson(io.stderr, errorOutput(error)); - return exitForError(error); - } -} diff --git a/runtime/windows/src/control/controller-lock.ts b/runtime/windows/src/control/controller-lock.ts deleted file mode 100644 index c9d7e4f..0000000 --- a/runtime/windows/src/control/controller-lock.ts +++ /dev/null @@ -1,140 +0,0 @@ -import { randomUUID } from "node:crypto"; -import { open, readFile, rename, unlink } from "node:fs/promises"; -import { z } from "zod"; -import { RuntimeError } from "../errors.js"; - -export const ControllerLockRecordSchema = z.object({ - schemaVersion: z.literal(1), - pid: z.number().int().positive(), - startedAt: z.string().datetime(), -}).strict(); - -export type ControllerLockIdentity = z.infer; -export type InspectProcessStartedAt = (pid: number) => Promise; - -function isErrno(error: unknown, code: string): boolean { - return error instanceof Error && (error as NodeJS.ErrnoException).code === code; -} - -function staleLockError(): RuntimeError { - return new RuntimeError( - "RUNTIME_SESSION_STALE", - "The Runtime controller lock record is invalid", - "Inspect the Runtime data directory before retrying.", - ); -} - -function busyError(): RuntimeError { - return new RuntimeError( - "RUNTIME_BUSY", - "Another Runtime controller owns the session", - "Wait for the existing Runtime controller to become ready.", - ); -} - -async function writeExclusiveLock(path: string, identity: ControllerLockIdentity): Promise { - const handle = await open(path, "wx", 0o600); - try { - await handle.writeFile(`${JSON.stringify(identity)}\n`, "utf8"); - await handle.sync(); - } finally { - await handle.close(); - } -} - -async function readLock(path: string): Promise { - let text: string; - try { - text = await readFile(path, "utf8"); - } catch (error) { - if (isErrno(error, "ENOENT")) throw busyError(); - throw error; - } - try { - return ControllerLockRecordSchema.parse(JSON.parse(text)); - } catch { - throw staleLockError(); - } -} - -export class ControllerLock { - private released = false; - - private constructor( - private readonly path: string, - private readonly identity: ControllerLockIdentity, - ) {} - - static async acquire( - path: string, - identity: Omit, - inspectProcessStartedAt: InspectProcessStartedAt, - ): Promise { - const record = ControllerLockRecordSchema.parse({ schemaVersion: 1, ...identity }); - try { - await writeExclusiveLock(path, record); - return new ControllerLock(path, record); - } catch (error) { - if (!isErrno(error, "EEXIST")) throw error; - } - - const existing = await readLock(path); - const observedStartedAt = await inspectProcessStartedAt(existing.pid); - if (observedStartedAt === existing.startedAt) throw busyError(); - - const stalePath = `${path}.stale-${randomUUID()}`; - try { - await rename(path, stalePath); - } catch (error) { - if (isErrno(error, "ENOENT") || isErrno(error, "EEXIST")) throw busyError(); - throw error; - } - - let lock: ControllerLock | null = null; - try { - await writeExclusiveLock(path, record); - lock = new ControllerLock(path, record); - await unlink(stalePath); - return lock; - } catch (error) { - if (lock) await lock.release(); - if (isErrno(error, "EEXIST")) throw busyError(); - throw error; - } - } - - async release(): Promise { - if (this.released) return; - - let text: string; - try { - text = await readFile(this.path, "utf8"); - } catch (error) { - if (isErrno(error, "ENOENT")) { - this.released = true; - return; - } - throw error; - } - - let current: ControllerLockIdentity; - try { - current = ControllerLockRecordSchema.parse(JSON.parse(text)); - } catch { - // An altered lock is preserved as evidence and must never be removed by this owner. - this.released = true; - return; - } - if (current.pid !== this.identity.pid || current.startedAt !== this.identity.startedAt) { - this.released = true; - return; - } - - try { - await unlink(this.path); - } catch (error) { - if (!isErrno(error, "ENOENT")) throw error; - } - this.released = true; - } -} diff --git a/runtime/windows/src/control/dispatcher.ts b/runtime/windows/src/control/dispatcher.ts deleted file mode 100644 index ca2e96c..0000000 --- a/runtime/windows/src/control/dispatcher.ts +++ /dev/null @@ -1,265 +0,0 @@ -import { - isRuntimeThemePackageErrorCode, - isRuntimeThemeSurfaceErrorCode, - runtimeErrorCode, - type RuntimeErrorCode, -} from "../errors.js"; -import type { RecentRequest, RuntimeStateStore } from "../state.js"; -import { RuntimeController } from "../controller/runtime-controller.js"; -import { - CONTROL_PROTOCOL_VERSION, - ControlResponseSchema, - type ControlDispatchResult, - type ControlResponse, -} from "./result.js"; -import type { ControlRequest } from "./protocol.js"; - -interface CachedResponse { - readonly command: ControlRequest["command"]; - readonly response: ControlResponse; -} - -interface InFlightRequest { - readonly command: ControlRequest["command"]; - readonly result: Promise; -} - -type MutationRequest = Exclude; - -function errorMessage(code: RuntimeErrorCode): string { - if (code === "RUNTIME_BUSY") return "Another Runtime command is in progress."; - if (code === "RUNTIME_CONTROL_UNAVAILABLE") return "Runtime control is unavailable."; - if (code === "RESTORE_AWAITING_EXIT") return "Codex must exit normally before changing themes."; - if (code === "CODEX_WINDOW_ACTIVATION_FAILED") { - return "Codex window activation could not be verified safely."; - } - if (code === "PROCESS_INSPECTION_DENIED") { - return "Codex process identity could not be inspected safely."; - } - if (code === "ADAPTER_INCOMPATIBLE") { - return "The installed Codex UI is not compatible with this OpenChatGPTSkin adapter."; - } - if (code === "INTERNAL") return "The Runtime command could not be completed."; - return "The Runtime command was rejected safely."; -} - -function errorNextAction(code: RuntimeErrorCode): string { - if (isRuntimeThemeSurfaceErrorCode(code)) { - return "Return to a supported ChatGPT/Codex home surface or restore the previous theme."; - } - if (isRuntimeThemePackageErrorCode(code)) { - return "Repair or replace the theme package."; - } - if (code === "CODEX_WINDOW_ACTIVATION_FAILED") { - return "Quit Codex completely and retry once. If it repeats, report " + - "CODEX_WINDOW_ACTIVATION_FAILED with the installed Codex version."; - } - if (code === "PROCESS_INSPECTION_DENIED") { - return "Quit Codex completely and retry once. If it repeats, report " + - "PROCESS_INSPECTION_DENIED with the installed Codex version."; - } - if (code === "ADAPTER_INCOMPATIBLE") { - return "Update OpenChatGPTSkin before retrying. If no update is available, report " + - "ADAPTER_INCOMPATIBLE with the installed Codex version."; - } - return "Review Runtime status and retry with a new request ID."; -} - -function errorResponse( - request: ControlRequest, - code: RuntimeErrorCode, -): ControlResponse { - return ControlResponseSchema.parse({ - protocolVersion: CONTROL_PROTOCOL_VERSION, - requestId: request.requestId, - ok: false, - error: { - code, - message: errorMessage(code), - nextAction: errorNextAction(code), - }, - }); -} - -function successResponse( - request: ControlRequest, - result: Awaited>, -): ControlResponse { - return ControlResponseSchema.parse({ - protocolVersion: CONTROL_PROTOCOL_VERSION, - requestId: request.requestId, - ok: true, - result, - }); -} - -export class RuntimeControlDispatcher { - private mutation: Promise | null = null; - private readonly cache = new Map(); - private readonly inFlight = new Map(); - - constructor( - private readonly controller: RuntimeController, - private readonly state: RuntimeStateStore, - ) {} - - async dispatch(request: ControlRequest): Promise { - try { - const cached = await this.lookup(request); - if (cached) return { response: cached }; - - const inFlight = this.inFlight.get(request.requestId); - if (inFlight) { - if (inFlight.command !== request.command) { - return this.complete(request, errorResponse(request, "RUNTIME_CONTROL_UNAVAILABLE")); - } - return inFlight.result.then(({ response }) => ({ response })); - } - - if (request.command === "status") { - return this.executeStatus(request); - } - - if (this.mutation) { - return this.complete(request, errorResponse(request, "RUNTIME_BUSY")); - } - - const result = this.executeMutation(request); - this.inFlight.set(request.requestId, { command: request.command, result }); - try { - return await result; - } finally { - this.inFlight.delete(request.requestId); - } - } catch (error) { - return this.complete(request, errorResponse(request, runtimeErrorCode(error))); - } - } - - private async lookup(request: ControlRequest): Promise { - const persistent = await this.state.read(); - const stored = persistent?.recentRequests.find((entry) => entry.requestId === request.requestId); - if (stored) return this.matchStoredRequest(request, stored); - - const cached = this.cache.get(request.requestId); - if (!cached) return null; - if (cached.command !== request.command) { - return errorResponse(request, "RUNTIME_CONTROL_UNAVAILABLE"); - } - return cached.response; - } - - private matchStoredRequest( - request: ControlRequest, - stored: RecentRequest, - ): ControlResponse { - if (stored.command !== request.command || stored.response.requestId !== request.requestId) { - return errorResponse(request, "RUNTIME_CONTROL_UNAVAILABLE"); - } - this.remember(stored.requestId, { command: stored.command, response: stored.response }); - return stored.response; - } - - private async executeStatus(request: ControlRequest): Promise { - try { - return await this.complete(request, successResponse(request, await this.controller.status())); - } catch (error) { - return this.complete(request, errorResponse(request, runtimeErrorCode(error))); - } - } - - private async executeMutation(request: MutationRequest): Promise { - let release!: () => void; - this.mutation = new Promise((resolve) => { release = resolve; }); - try { - const result = await this.executeControllerCommand(request); - const response = successResponse(request, result); - const afterResponse = request.command === "restore" - ? await this.terminalExitMonitorCallback() - : undefined; - return this.complete(request, response, afterResponse); - } catch (error) { - const afterResponse = request.command === "launch" - ? await this.terminalExitMonitorCallback() - : undefined; - return this.complete( - request, - errorResponse(request, runtimeErrorCode(error)), - afterResponse, - ); - } finally { - release(); - this.mutation = null; - } - } - - private async executeControllerCommand( - request: MutationRequest, - ): Promise>> { - switch (request.command) { - case "launch": - return this.controller.launch( - request.params.themeId, - request.requestId, - request.params.themeVersion, - ); - case "switch": - return this.controller.switchTheme( - request.params.themeId, - request.requestId, - request.params.themeVersion, - ); - case "pause": - return this.controller.pause(request.requestId); - case "resume": - return this.controller.resume(request.requestId); - case "restore": - return this.controller.restore(request.requestId); - } - } - - private async terminalExitMonitorCallback(): Promise { - const session = await this.state.read(); - if (session?.status !== "restored-awaiting-exit" && - session?.status !== "restored-cleanup-required") { - return undefined; - } - return () => this.controller.startExitMonitoring(); - } - - private async complete( - request: ControlRequest, - response: ControlResponse, - afterResponse?: () => Promise | void, - ): Promise { - const parsed = ControlResponseSchema.parse(response); - const record: RecentRequest = { - requestId: request.requestId, - command: request.command, - response: parsed, - completedAt: new Date().toISOString(), - }; - try { - await this.state.appendRecentRequest(record); - } catch (error) { - const persistenceFailure = errorResponse(request, runtimeErrorCode(error)); - this.remember(request.requestId, { - command: request.command, - response: persistenceFailure, - }); - return { response: persistenceFailure }; - } - this.remember(request.requestId, { command: request.command, response: parsed }); - return afterResponse ? { response: parsed, afterResponse } : { response: parsed }; - } - - private remember(requestId: string, value: CachedResponse): void { - this.cache.delete(requestId); - this.cache.set(requestId, value); - while (this.cache.size > 32) { - const oldest = this.cache.keys().next().value; - if (!oldest) return; - this.cache.delete(oldest); - } - } -} diff --git a/runtime/windows/src/control/framing.ts b/runtime/windows/src/control/framing.ts deleted file mode 100644 index 292e4d3..0000000 --- a/runtime/windows/src/control/framing.ts +++ /dev/null @@ -1,41 +0,0 @@ -export const CONTROL_MAX_FRAME_BYTES = 64 * 1024; - -function isControlObject(value: unknown): value is Record { - return value !== null && typeof value === "object" && !Array.isArray(value); -} - -export function encodeControlFrame(value: unknown): Buffer { - if (!isControlObject(value)) { - throw new Error("control frame JSON must be an object"); - } - - const payload = Buffer.from(JSON.stringify(value), "utf8"); - if (payload.length < 1 || payload.length > CONTROL_MAX_FRAME_BYTES) { - throw new Error("control frame exceeds 64 KiB"); - } - - const header = Buffer.allocUnsafe(4); - header.writeUInt32LE(payload.length, 0); - return Buffer.concat([header, payload]); -} - -export function decodeControlFrame(frame: Uint8Array): Record { - const bytes = Buffer.from(frame); - if (bytes.length < 4) { - throw new Error("control frame header is truncated"); - } - - const length = bytes.readUInt32LE(0); - if (length < 1 || length > CONTROL_MAX_FRAME_BYTES) { - throw new Error("control frame length is invalid"); - } - if (bytes.length !== length + 4) { - throw new Error("control frame is truncated or has trailing data"); - } - - const parsed = JSON.parse(bytes.subarray(4).toString("utf8")) as unknown; - if (!isControlObject(parsed)) { - throw new Error("control frame JSON must be an object"); - } - return parsed; -} diff --git a/runtime/windows/src/control/pipe-client.ts b/runtime/windows/src/control/pipe-client.ts deleted file mode 100644 index 920acce..0000000 --- a/runtime/windows/src/control/pipe-client.ts +++ /dev/null @@ -1,166 +0,0 @@ -import { createConnection, type Socket } from "node:net"; -import { RuntimeError } from "../errors.js"; -import { CONTROL_MAX_FRAME_BYTES, decodeControlFrame, encodeControlFrame } from "./framing.js"; -import { - ControlRequestSchema, - ControlResponseSchema, - controlEndpointForIdentity, - type ControlRequest, - type ControlResponse, -} from "./protocol.js"; - -const DEFAULT_CONTROL_TIMEOUT_MS = 5_000; -const CONNECTION_RETRY_DELAY_MS = 10; - -export interface SendControlRequestOptions { - readonly sid: string; - readonly request: ControlRequest; - readonly timeoutMs?: number; - readonly responseTimeoutMs?: number; - readonly endpoint?: string; -} - -function unavailableError(): RuntimeError { - return new RuntimeError( - "RUNTIME_CONTROL_UNAVAILABLE", - "Runtime control pipe is unavailable", - "Retry the OpenChatGPTSkin command.", - ); -} - -function isTransientConnectError(error: Error): boolean { - const code = (error as NodeJS.ErrnoException).code; - return code === "ENOENT" || code === "ECONNREFUSED"; -} - -export async function sendControlRequest( - options: SendControlRequestOptions, -): Promise { - const request = ControlRequestSchema.parse(options.request); - const connectionTimeoutMs = options.timeoutMs ?? DEFAULT_CONTROL_TIMEOUT_MS; - const responseTimeoutMs = options.responseTimeoutMs ?? connectionTimeoutMs; - if (!Number.isInteger(connectionTimeoutMs) || connectionTimeoutMs < 1 || - connectionTimeoutMs > 60_000 || !Number.isInteger(responseTimeoutMs) || - responseTimeoutMs < 1 || responseTimeoutMs > 60_000) { - throw new RuntimeError("RUNTIME_ENVIRONMENT_INVALID", "Control timeout is invalid"); - } - - return new Promise((resolveResponse, rejectResponse) => { - const chunks: Buffer[] = []; - let receivedBytes = 0; - let expectedFrameBytes: number | null = null; - let completeResponse: ControlResponse | null = null; - let socket: Socket | null = null; - let retryTimer: ReturnType | null = null; - let settled = false; - const connectionDeadline = Date.now() + connectionTimeoutMs; - let responseDeadline: number | null = null; - const settle = (callback: () => void) => { - if (settled) return; - settled = true; - clearTimeout(timeout); - if (retryTimer) clearTimeout(retryTimer); - callback(); - }; - const fail = () => { - settle(() => { - socket?.destroy(); - rejectResponse(unavailableError()); - }); - }; - let timeout = setTimeout(fail, connectionTimeoutMs); - - const beginResponseWindow = () => { - if (responseDeadline !== null) return; - responseDeadline = Date.now() + responseTimeoutMs; - clearTimeout(timeout); - timeout = setTimeout(fail, responseTimeoutMs); - }; - - const resolveIfComplete = () => { - if (!completeResponse) return false; - settle(() => resolveResponse(completeResponse!)); - return true; - }; - - const inspectResponse = () => { - const bytes = Buffer.concat(chunks); - if (expectedFrameBytes === null && bytes.length >= 4) { - const payloadLength = bytes.readUInt32LE(0); - if (payloadLength < 1 || payloadLength > CONTROL_MAX_FRAME_BYTES) { - fail(); - return; - } - expectedFrameBytes = payloadLength + 4; - } - if (expectedFrameBytes === null || bytes.length < expectedFrameBytes) return; - if (bytes.length !== expectedFrameBytes) { - fail(); - return; - } - try { - const response = ControlResponseSchema.parse(decodeControlFrame(bytes)); - if (response.requestId !== request.requestId) { - fail(); - return; - } - completeResponse = response; - } catch { - fail(); - } - }; - - const startConnection = () => { - if (settled) return; - const current = createConnection( - options.endpoint ?? controlEndpointForIdentity(options.sid), - ); - socket = current; - let connected = false; - let attemptFinished = false; - let responseBytes = 0; - const finishAttempt = (error?: Error) => { - if (attemptFinished || settled) return; - attemptFinished = true; - const retryableHandoff = responseBytes === 0 && - (connected || (error !== undefined && isTransientConnectError(error))); - const deadline = responseDeadline ?? connectionDeadline; - if (retryableHandoff && Date.now() + CONNECTION_RETRY_DELAY_MS < deadline) { - if (socket === current) socket = null; - current.destroy(); - retryTimer = setTimeout(startConnection, CONNECTION_RETRY_DELAY_MS); - return; - } - if (!resolveIfComplete()) fail(); - }; - - current.once("error", finishAttempt); - current.once("connect", () => { - if (settled) { - current.destroy(); - return; - } - connected = true; - beginResponseWindow(); - current.write(encodeControlFrame(request), (error) => { - if (error) finishAttempt(error); - }); - }); - current.on("data", (chunk: Buffer) => { - if (settled) return; - responseBytes += chunk.length; - receivedBytes += chunk.length; - if (receivedBytes > CONTROL_MAX_FRAME_BYTES + 4) { - fail(); - return; - } - chunks.push(chunk); - inspectResponse(); - }); - current.once("end", () => finishAttempt()); - current.once("close", () => finishAttempt()); - }; - - startConnection(); - }); -} diff --git a/runtime/windows/src/control/pipe-host-script.ts b/runtime/windows/src/control/pipe-host-script.ts deleted file mode 100644 index 41d4bf7..0000000 --- a/runtime/windows/src/control/pipe-host-script.ts +++ /dev/null @@ -1,121 +0,0 @@ -export const PIPE_HOST_SCRIPT = String.raw` -$ErrorActionPreference = "Stop" -$sid = [Security.Principal.WindowsIdentity]::GetCurrent().User -$sha = [Security.Cryptography.SHA256]::Create() -try { - $digest = [BitConverter]::ToString( - $sha.ComputeHash([Text.Encoding]::UTF8.GetBytes($sid.Value)) - ).Replace("-", "").ToLowerInvariant().Substring(0, 24) -} finally { - $sha.Dispose() -} -$pipeLeaf = "OpenChatGPTSkin-$digest" -$systemSid = [Security.Principal.SecurityIdentifier]::new("S-1-5-18") - -function New-SecuredServer { - $security = [IO.Pipes.PipeSecurity]::new() - $security.SetAccessRuleProtection($true, $false) - $security.AddAccessRule([IO.Pipes.PipeAccessRule]::new( - $sid, - [IO.Pipes.PipeAccessRights]::FullControl, - [Security.AccessControl.AccessControlType]::Allow - )) - $security.AddAccessRule([IO.Pipes.PipeAccessRule]::new( - $systemSid, - [IO.Pipes.PipeAccessRights]::FullControl, - [Security.AccessControl.AccessControlType]::Allow - )) - return [IO.Pipes.NamedPipeServerStream]::new( - $pipeLeaf, - [IO.Pipes.PipeDirection]::InOut, - 2, - [IO.Pipes.PipeTransmissionMode]::Byte, - [IO.Pipes.PipeOptions]::Asynchronous, - 65536, - 65536, - $security - ) -} - -function Assert-ExactAcl([IO.Pipes.NamedPipeServerStream]$server) { - $expected = @($sid.Value, $systemSid.Value) | Sort-Object -Unique - $acl = $server.GetAccessControl() - $actual = @($acl.Access | ForEach-Object { - $_.IdentityReference.Translate([Security.Principal.SecurityIdentifier]).Value - } | Sort-Object -Unique) - if (($actual -join "|") -ne ($expected -join "|")) { - throw "Named Pipe ACL verification failed" - } -} - -function Read-Exactly([IO.Stream]$stream, [int]$count) { - $buffer = New-Object byte[] $count - $offset = 0 - while ($offset -lt $count) { - $read = $stream.Read($buffer, $offset, $count - $offset) - if ($read -le 0) { return $null } - $offset += $read - } - return ,$buffer -} - -$ready = $false -$sequence = 0 -while ($true) { - $server = New-SecuredServer - try { - Assert-ExactAcl $server - if (-not $ready) { - [Console]::Out.WriteLine("READY $pipeLeaf") - [Console]::Out.Flush() - $ready = $true - } - - $server.WaitForConnection() - $header = Read-Exactly $server 4 - if ($null -eq $header) { continue } - $length = [BitConverter]::ToUInt32($header, 0) - if ($length -lt 1 -or $length -gt 65536) { continue } - $payload = Read-Exactly $server ([int]$length) - if ($null -eq $payload) { continue } - - $sequence += 1 - $encodedRequest = [Convert]::ToBase64String($payload) - [Console]::Out.WriteLine("REQUEST $sequence $encodedRequest") - [Console]::Out.Flush() - - $line = [Console]::In.ReadLine() - if ($null -eq $line) { throw "Pipe host stdin closed" } - $match = [regex]::Match($line, "^RESPONSE ([0-9]+) ([A-Za-z0-9+/=]+)$") - if (-not $match.Success -or $match.Groups[1].Value -ne [string]$sequence) { - throw "Pipe host response sequence is invalid" - } - $response = [Convert]::FromBase64String($match.Groups[2].Value) - if ($response.Length -lt 1 -or $response.Length -gt 65536) { - throw "Pipe host response payload is invalid" - } - - $responseHeader = [BitConverter]::GetBytes([uint32]$response.Length) - $server.Write($responseHeader, 0, $responseHeader.Length) - $server.Write($response, 0, $response.Length) - $server.Flush() - $server.WaitForPipeDrain() - $server.Disconnect() - [Console]::Out.WriteLine("FLUSHED $sequence") - [Console]::Out.Flush() - $continue = [Console]::In.ReadLine() - if ($null -eq $continue) { throw "Pipe host flush acknowledgement is missing" } - $continueMatch = [regex]::Match($continue, "^CONTINUE ([0-9]+)$") - if (-not $continueMatch.Success -or $continueMatch.Groups[1].Value -ne [string]$sequence) { - throw "Pipe host flush acknowledgement is invalid" - } - } finally { - if ($server.IsConnected) { $server.Disconnect() } - $server.Dispose() - } -} -`; - -export const ENCODED_PIPE_HOST_SCRIPT = Buffer - .from(PIPE_HOST_SCRIPT, "utf16le") - .toString("base64"); diff --git a/runtime/windows/src/control/pipe-server.ts b/runtime/windows/src/control/pipe-server.ts deleted file mode 100644 index 199217f..0000000 --- a/runtime/windows/src/control/pipe-server.ts +++ /dev/null @@ -1,401 +0,0 @@ -import { - spawn, - type ChildProcessWithoutNullStreams, -} from "node:child_process"; -import { z } from "zod"; -import { - RuntimeError, - runtimeErrorCode, - type RuntimeErrorCode, -} from "../errors.js"; -import { WINDOWS_POWERSHELL } from "../windows/powershell-path.js"; -import { - CONTROL_MAX_FRAME_BYTES, - decodeControlFrame, - encodeControlFrame, -} from "./framing.js"; -import { - CONTROL_PROTOCOL_VERSION, - ControlRequestSchema, - ControlResponseSchema, - NIL_REQUEST_ID, - pipeNameForSid, - type ControlRequest, - type ControlResponse, -} from "./protocol.js"; -import type { ControlDispatchResult } from "./result.js"; -export type { ControlDispatchResult } from "./result.js"; -import { ENCODED_PIPE_HOST_SCRIPT } from "./pipe-host-script.js"; - -const DEFAULT_STARTUP_TIMEOUT_MS = 5_000; -const MAX_BROKER_LINE_BYTES = Math.ceil(CONTROL_MAX_FRAME_BYTES / 3) * 4 + 128; -const REQUEST_ID_SCHEMA = z.string().uuid(); - -export interface SecurePipeServerOptions { - readonly sid: string; - readonly dispatch: (request: ControlRequest) => Promise; - readonly powershellPath?: string; - readonly startupTimeoutMs?: number; -} - -interface ActiveRequest { - readonly sequence: string; - afterResponse?: () => Promise | void; -} - -interface Broker { - readonly index: number; - readonly restartAttempt: number; - readonly child: ChildProcessWithoutNullStreams; - resolveReady: () => void; - rejectReady: (error: Error) => void; - lineBuffer: string; - isReady: boolean; - stopped: boolean; - active: ActiveRequest | null; -} - -function unavailableError(): RuntimeError { - return new RuntimeError( - "RUNTIME_CONTROL_UNAVAILABLE", - "Runtime control pipe is unavailable", - "Retry the OpenChatGPTSkin command.", - ); -} - -function requestIdFrom(value: unknown): string { - if (!value || typeof value !== "object" || Array.isArray(value)) { - return NIL_REQUEST_ID; - } - const parsed = REQUEST_ID_SCHEMA.safeParse( - (value as Readonly>).requestId, - ); - return parsed.success ? parsed.data : NIL_REQUEST_ID; -} - -function errorResponse( - requestId: string, - code: RuntimeErrorCode = "RUNTIME_CONTROL_UNAVAILABLE", -): ControlResponse { - return ControlResponseSchema.parse({ - protocolVersion: CONTROL_PROTOCOL_VERSION, - requestId, - ok: false, - error: { - code, - message: "The Runtime command could not be completed.", - nextAction: "Review Runtime status and retry.", - }, - }); -} - -function decodeBase64(value: string): Buffer | null { - if (value.length === 0 || value.length > MAX_BROKER_LINE_BYTES || - !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)) { - return null; - } - const bytes = Buffer.from(value, "base64"); - return bytes.toString("base64") === value ? bytes : null; -} - -function frameForPayload(payload: Buffer): Buffer { - const header = Buffer.allocUnsafe(4); - header.writeUInt32LE(payload.length, 0); - return Buffer.concat([header, payload]); -} - -export class SecurePipeServer { - private readonly expectedPipeLeaf: string; - private readonly startupTimeoutMs: number; - private readonly brokers = new Map(); - private closed = false; - private permanentlyUnavailable = false; - - aclVerified = false; - - private constructor(private readonly options: SecurePipeServerOptions) { - const timeout = options.startupTimeoutMs ?? DEFAULT_STARTUP_TIMEOUT_MS; - if (!Number.isInteger(timeout) || timeout < 1 || timeout > 60_000) { - throw new RuntimeError( - "RUNTIME_ENVIRONMENT_INVALID", - "Pipe startup timeout is invalid", - ); - } - this.startupTimeoutMs = timeout; - this.expectedPipeLeaf = pipeNameForSid(options.sid).split("\\").at(-1) ?? ""; - } - - static async start(options: SecurePipeServerOptions): Promise { - if (process.platform !== "win32") { - throw new RuntimeError( - "RUNTIME_ENVIRONMENT_INVALID", - "Secured Runtime control requires Windows", - ); - } - - const server = new SecurePipeServer(options); - try { - await Promise.all([server.startBroker(0, 0), server.startBroker(1, 0)]); - } catch { - await server.close(); - throw unavailableError(); - } - server.aclVerified = true; - return server; - } - - async close(): Promise { - if (this.closed) return; - this.closed = true; - await Promise.all([...this.brokers.values()].map((broker) => this.stopBroker(broker))); - this.brokers.clear(); - } - - private startBroker(index: number, restartAttempt: number): Promise { - const child = spawn( - this.options.powershellPath ?? WINDOWS_POWERSHELL, - [ - "-NoLogo", - "-NoProfile", - "-NonInteractive", - "-EncodedCommand", - ENCODED_PIPE_HOST_SCRIPT, - ], - { - shell: false, - windowsHide: true, - stdio: ["pipe", "pipe", "pipe"], - }, - ); - let resolveReady!: () => void; - let rejectReady!: (error: Error) => void; - const ready = new Promise((resolveReadyPromise, rejectReadyPromise) => { - resolveReady = resolveReadyPromise; - rejectReady = rejectReadyPromise; - }); - const broker: Broker = { - index, - restartAttempt, - child, - resolveReady, - rejectReady, - lineBuffer: "", - isReady: false, - stopped: false, - active: null, - }; - this.brokers.set(index, broker); - - child.stdout.setEncoding("utf8"); - child.stdout.on("data", (chunk: string) => this.receiveBrokerOutput(broker, chunk)); - child.stderr.resume(); - child.once("error", () => this.handleBrokerStop(broker)); - child.once("close", () => this.handleBrokerStop(broker)); - - return new Promise((resolveBroker, rejectBroker) => { - const timer = setTimeout(() => { - this.failBroker(broker); - rejectBroker(unavailableError()); - }, this.startupTimeoutMs); - ready.then( - () => { - clearTimeout(timer); - resolveBroker(broker); - }, - (error: Error) => { - clearTimeout(timer); - rejectBroker(error); - }, - ); - }); - } - - private receiveBrokerOutput(broker: Broker, chunk: string): void { - if (broker.stopped) return; - broker.lineBuffer += chunk; - while (true) { - const newline = broker.lineBuffer.indexOf("\n"); - if (newline < 0) break; - const line = broker.lineBuffer.slice(0, newline).replace(/\r$/, ""); - broker.lineBuffer = broker.lineBuffer.slice(newline + 1); - if (Buffer.byteLength(line, "utf8") > MAX_BROKER_LINE_BYTES) { - this.failBroker(broker); - return; - } - this.handleBrokerLine(broker, line); - } - if (Buffer.byteLength(broker.lineBuffer, "utf8") > MAX_BROKER_LINE_BYTES) { - this.failBroker(broker); - } - } - - private handleBrokerLine(broker: Broker, line: string): void { - if (line.startsWith("READY ")) { - if (broker.isReady || line !== `READY ${this.expectedPipeLeaf}`) { - this.failBroker(broker); - return; - } - broker.isReady = true; - broker.resolveReady(); - return; - } - - if (!broker.isReady) { - this.failBroker(broker); - return; - } - - const requestMatch = /^REQUEST ([0-9]+) ([A-Za-z0-9+/=]+)$/.exec(line); - if (requestMatch) { - if (broker.active) { - this.failBroker(broker); - return; - } - const payload = decodeBase64(requestMatch[2]!); - if (!payload || payload.length < 1 || payload.length > CONTROL_MAX_FRAME_BYTES) { - this.failBroker(broker); - return; - } - broker.active = { sequence: requestMatch[1]! }; - void this.dispatchBrokerRequest(broker, requestMatch[1]!, payload) - .catch(() => this.failBroker(broker)); - return; - } - - const flushedMatch = /^FLUSHED ([0-9]+)$/.exec(line); - if (flushedMatch) { - this.handleBrokerFlushed(broker, flushedMatch[1]!); - return; - } - - this.failBroker(broker); - } - - private async dispatchBrokerRequest( - broker: Broker, - sequence: string, - payload: Buffer, - ): Promise { - let raw: unknown; - try { - raw = decodeControlFrame(frameForPayload(payload)); - } catch { - await this.sendBrokerResponse(broker, sequence, errorResponse(NIL_REQUEST_ID)); - return; - } - - const request = ControlRequestSchema.safeParse(raw); - if (!request.success) { - await this.sendBrokerResponse(broker, sequence, errorResponse(requestIdFrom(raw))); - return; - } - - let result: ControlDispatchResult; - try { - result = await this.options.dispatch(request.data); - } catch (error) { - await this.sendBrokerResponse( - broker, - sequence, - errorResponse(request.data.requestId, runtimeErrorCode(error)), - ); - return; - } - const response = ControlResponseSchema.safeParse(result.response); - if (!response.success || response.data.requestId !== request.data.requestId) { - await this.sendBrokerResponse( - broker, - sequence, - errorResponse(request.data.requestId), - ); - return; - } - await this.sendBrokerResponse(broker, sequence, response.data, result.afterResponse); - } - - private async sendBrokerResponse( - broker: Broker, - sequence: string, - response: ControlResponse, - afterResponse?: () => Promise | void, - ): Promise { - if (broker.stopped || broker.active?.sequence !== sequence) { - throw unavailableError(); - } - const frame = encodeControlFrame(response); - const payload = frame.subarray(4); - if (afterResponse) broker.active.afterResponse = afterResponse; - await new Promise((resolveWrite, rejectWrite) => { - broker.child.stdin.write( - `RESPONSE ${sequence} ${payload.toString("base64")}\n`, - "utf8", - (error) => error ? rejectWrite(error) : resolveWrite(), - ); - }); - } - - private handleBrokerFlushed(broker: Broker, sequence: string): void { - if (!broker.active || broker.active.sequence !== sequence) { - this.failBroker(broker); - return; - } - const afterResponse = broker.active.afterResponse; - broker.active = null; - void this.acknowledgeBrokerFlush(broker, sequence, afterResponse) - .catch(() => this.failBroker(broker)); - } - - private async acknowledgeBrokerFlush( - broker: Broker, - sequence: string, - afterResponse?: () => Promise | void, - ): Promise { - if (broker.stopped) throw unavailableError(); - await new Promise((resolveWrite, rejectWrite) => { - broker.child.stdin.write(`CONTINUE ${sequence}\n`, "utf8", (error) => - error ? rejectWrite(error) : resolveWrite(), - ); - }); - if (afterResponse) await afterResponse(); - } - - private failBroker(broker: Broker): void { - if (broker.stopped) return; - if (!broker.isReady) broker.rejectReady(unavailableError()); - void this.stopBroker(broker); - } - - private handleBrokerStop(broker: Broker): void { - if (broker.stopped) return; - broker.stopped = true; - if (!broker.isReady) broker.rejectReady(unavailableError()); - if (this.closed || this.brokers.get(broker.index) !== broker) return; - if (broker.restartAttempt >= 1) { - this.permanentlyUnavailable = true; - void this.close(); - return; - } - void this.restartBroker(broker.index, broker.restartAttempt + 1); - } - - private async restartBroker(index: number, restartAttempt: number): Promise { - if (this.closed || this.permanentlyUnavailable) return; - try { - await this.startBroker(index, restartAttempt); - } catch { - this.permanentlyUnavailable = true; - } - } - - private async stopBroker(broker: Broker): Promise { - if (broker.child.exitCode !== null || broker.child.killed) return; - await new Promise((resolveStop) => { - const timeout = setTimeout(resolveStop, 1_000); - broker.child.once("close", () => { - clearTimeout(timeout); - resolveStop(); - }); - broker.child.kill(); - }); - } -} diff --git a/runtime/windows/src/control/protocol.ts b/runtime/windows/src/control/protocol.ts deleted file mode 100644 index 67c1d56..0000000 --- a/runtime/windows/src/control/protocol.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { createHash } from "node:crypto"; -import { z } from "zod"; -import { - ThemeIdSchema, - ThemeVersionSchema, -} from "@open-chatgpt-skin/theme-schema"; -import { RuntimeBuiltinThemeIdSchema } from "../themes/ids.js"; -import { CONTROL_COMMANDS, CONTROL_PROTOCOL_VERSION } from "./result.js"; -import { CONTROL_MAX_FRAME_BYTES } from "./framing.js"; -import { RUNTIME_ERROR_CODES } from "../errors.js"; - -export { - CONTROL_PROTOCOL_VERSION, - ControlCommandSchema, - ControlErrorSchema, - ControlResponseSchema, - RuntimeStatusViewSchema, -} from "./result.js"; -export type { - ControlError, - ControlResponse, - RuntimeStatusView, -} from "./result.js"; - -export const NIL_REQUEST_ID = "00000000-0000-0000-0000-000000000000"; - -const emptyParams = z.object({}).strict(); -const themeParams = z.object({ - themeId: ThemeIdSchema, - themeVersion: ThemeVersionSchema.optional(), -}).strict().superRefine((value, context) => { - if (value.themeVersion === undefined && - !RuntimeBuiltinThemeIdSchema.safeParse(value.themeId).success) { - context.addIssue({ - code: z.ZodIssueCode.custom, - path: ["themeVersion"], - message: "personal themes require an exact version", - }); - } -}); - -export const ControlRequestSchema = z.discriminatedUnion("command", [ - z.object({ - protocolVersion: z.literal(CONTROL_PROTOCOL_VERSION), - requestId: z.string().uuid(), - command: z.literal("launch"), - params: themeParams, - }).strict(), - z.object({ - protocolVersion: z.literal(CONTROL_PROTOCOL_VERSION), - requestId: z.string().uuid(), - command: z.literal("status"), - params: emptyParams, - }).strict(), - z.object({ - protocolVersion: z.literal(CONTROL_PROTOCOL_VERSION), - requestId: z.string().uuid(), - command: z.literal("switch"), - params: themeParams, - }).strict(), - z.object({ - protocolVersion: z.literal(CONTROL_PROTOCOL_VERSION), - requestId: z.string().uuid(), - command: z.literal("pause"), - params: emptyParams, - }).strict(), - z.object({ - protocolVersion: z.literal(CONTROL_PROTOCOL_VERSION), - requestId: z.string().uuid(), - command: z.literal("resume"), - params: emptyParams, - }).strict(), - z.object({ - protocolVersion: z.literal(CONTROL_PROTOCOL_VERSION), - requestId: z.string().uuid(), - command: z.literal("restore"), - params: emptyParams, - }).strict(), -]); - -export type ControlRequest = z.infer; -export type ControlCommand = ControlRequest["command"]; - -export const RUNTIME_CONTROL_CONTRACT = { - protocolVersion: CONTROL_PROTOCOL_VERSION, - frame: { - encoding: "json" as const, - byteOrder: "little-endian" as const, - lengthPrefixBytes: 4, - maxPayloadBytes: CONTROL_MAX_FRAME_BYTES, - }, - commands: CONTROL_COMMANDS, - readOnlyCommands: ["status"], - mutationConcurrency: "serialized" as const, - statusConcurrency: "parallel-with-mutation" as const, - requestIdSemantics: "same-id-same-command-replays; same-id-different-command-rejected" as const, - afterResponseCommands: ["launch", "restore"], - errors: RUNTIME_ERROR_CODES, -} as const; - -export const RUNTIME_CONTROL_SEMANTIC_CASES = [ - { name: "status during mutation", valid: true, command: "status", expectedConcurrency: "parallel-with-mutation" }, - { name: "duplicate request replay", valid: true, command: "switch", expectedBehavior: "replay-stored-response" }, - { name: "request ID reused for another command", valid: false, command: "pause", expectedErrorCode: "RUNTIME_INVALID_STATE", expectedPath: "/requestId" }, - { name: "oversized frame", valid: false, expectedErrorCode: "RUNTIME_CONTROL_UNAVAILABLE", expectedPath: "/frame/length" }, - { name: "restore callback after response", valid: true, command: "restore", expectedBehavior: "after-response" }, - { name: "personal theme without version", valid: false, command: "launch", expectedErrorCode: "THEME_NOT_FOUND", expectedPath: "/params/themeVersion" }, -] as const; - -export function pipeNameForSid(sid: string): string { - const digest = createHash("sha256").update(sid, "utf8").digest("hex").slice(0, 24); - return `\\\\.\\pipe\\OpenChatGPTSkin-${digest}`; -} - -export function controlEndpointForIdentity( - identity: string, - platform: NodeJS.Platform = process.platform, - socketDirectory = "/tmp", -): string { - if (platform === "win32") return pipeNameForSid(identity); - if (platform === "darwin") { - const digest = createHash("sha256").update(identity, "utf8").digest("hex").slice(0, 24); - return `${socketDirectory.replace(/\/$/, "")}/OpenChatGPTSkin-${digest}.sock`; - } - throw new Error(`Unsupported Runtime control platform: ${platform}`); -} diff --git a/runtime/windows/src/control/result.ts b/runtime/windows/src/control/result.ts deleted file mode 100644 index a6f319f..0000000 --- a/runtime/windows/src/control/result.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { z } from "zod"; -import { RUNTIME_ERROR_CODES } from "../errors.js"; -import { - RuntimeOperationSchema, - RuntimeStatusSchema, - RuntimeThemeRefSchema, -} from "../session/model.js"; - -export const CONTROL_PROTOCOL_VERSION = 1 as const; - -export const CONTROL_COMMANDS = [ - "launch", - "status", - "switch", - "pause", - "resume", - "restore", -] as const; - -export const ControlCommandSchema = z.enum(CONTROL_COMMANDS); - -export const RuntimeStatusViewSchema = z.object({ - status: z.union([RuntimeStatusSchema, z.literal("stopped")]), - controllerAvailable: z.boolean(), - selectedTheme: RuntimeThemeRefSchema.nullable(), - appliedTheme: RuntimeThemeRefSchema.nullable(), - skinApplied: z.boolean().nullable(), - packageVersion: z.string().max(40).nullable(), - operation: RuntimeOperationSchema.nullable(), - nextAction: z.string().max(500), -}).strict(); - -export type RuntimeStatusView = z.infer; - -export const ControlErrorSchema = z.object({ - code: z.enum(RUNTIME_ERROR_CODES), - message: z.string().min(1).max(500), - nextAction: z.string().max(500).optional(), -}).strict(); - -export type ControlError = z.infer; - -export const ControlResponseSchema = z.discriminatedUnion("ok", [ - z.object({ - protocolVersion: z.literal(CONTROL_PROTOCOL_VERSION), - requestId: z.string().uuid(), - ok: z.literal(true), - result: RuntimeStatusViewSchema, - }).strict(), - z.object({ - protocolVersion: z.literal(CONTROL_PROTOCOL_VERSION), - requestId: z.string().uuid(), - ok: z.literal(false), - error: ControlErrorSchema, - }).strict(), -]); - -export type ControlResponse = z.infer; - -export interface ControlDispatchResult { - readonly response: ControlResponse; - readonly afterResponse?: () => Promise | void; -} diff --git a/runtime/windows/src/control/secure-control-server.ts b/runtime/windows/src/control/secure-control-server.ts deleted file mode 100644 index 4422905..0000000 --- a/runtime/windows/src/control/secure-control-server.ts +++ /dev/null @@ -1,47 +0,0 @@ -import type { ControlRequest } from "./protocol.js"; -import type { ControlDispatchResult } from "./result.js"; -import { SecurePipeServer } from "./pipe-server.js"; -import { SecureUnixSocketServer } from "./unix-socket-server.js"; -import { RuntimeError } from "../errors.js"; - -export interface SecureControlServer { - readonly securityVerified: boolean; - close(): Promise; -} - -export interface StartSecureControlServerOptions { - readonly platform?: NodeJS.Platform; - readonly userIdentity: string; - readonly dispatch: (request: ControlRequest) => Promise; -} - -export async function startSecureControlServer( - options: StartSecureControlServerOptions, -): Promise { - const platform = options.platform ?? process.platform; - if (platform === "win32") { - const pipe = await SecurePipeServer.start({ - sid: options.userIdentity, - dispatch: options.dispatch, - }); - return { - securityVerified: pipe.aclVerified, - close: () => pipe.close(), - }; - } - if (platform === "darwin") { - const socket = await SecureUnixSocketServer.start({ - userIdentity: options.userIdentity, - dispatch: options.dispatch, - platform, - }); - return { - securityVerified: socket.permissionsVerified, - close: () => socket.close(), - }; - } - throw new RuntimeError( - "RUNTIME_ENVIRONMENT_INVALID", - `Unsupported Runtime control platform: ${platform}`, - ); -} diff --git a/runtime/windows/src/control/unix-socket-server.ts b/runtime/windows/src/control/unix-socket-server.ts deleted file mode 100644 index 55c596c..0000000 --- a/runtime/windows/src/control/unix-socket-server.ts +++ /dev/null @@ -1,206 +0,0 @@ -import { createServer, type Server, type Socket } from "node:net"; -import { chmod, lstat, unlink } from "node:fs/promises"; -import { RuntimeError } from "../errors.js"; -import { - CONTROL_MAX_FRAME_BYTES, - decodeControlFrame, - encodeControlFrame, -} from "./framing.js"; -import { - CONTROL_PROTOCOL_VERSION, - ControlRequestSchema, - ControlResponseSchema, - NIL_REQUEST_ID, - controlEndpointForIdentity, - type ControlRequest, - type ControlResponse, -} from "./protocol.js"; -import type { ControlDispatchResult } from "./result.js"; - -export interface SecureUnixSocketServerOptions { - readonly userIdentity: string; - readonly dispatch: (request: ControlRequest) => Promise; - readonly endpoint?: string; - readonly platform?: NodeJS.Platform; - readonly currentUid?: number; -} - -function unavailableError(): RuntimeError { - return new RuntimeError( - "RUNTIME_CONTROL_UNAVAILABLE", - "Runtime control socket is unavailable", - "Retry the OpenChatGPTSkin command.", - ); -} - -function requestIdFrom(value: unknown): string { - if (!value || typeof value !== "object" || Array.isArray(value)) return NIL_REQUEST_ID; - const requestId = (value as Readonly>).requestId; - return typeof requestId === "string" && /^[0-9a-f-]{36}$/i.test(requestId) - ? requestId - : NIL_REQUEST_ID; -} - -function failureResponse(requestId: string): ControlResponse { - return ControlResponseSchema.parse({ - protocolVersion: CONTROL_PROTOCOL_VERSION, - requestId, - ok: false, - error: { - code: "RUNTIME_CONTROL_UNAVAILABLE", - message: "The Runtime command could not be completed.", - nextAction: "Review Runtime status and retry.", - }, - }); -} - -async function removeOwnedStaleSocket(endpoint: string, uid: number): Promise { - try { - const info = await lstat(endpoint); - if (!info.isSocket() || info.uid !== uid) throw unavailableError(); - await unlink(endpoint); - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; - } -} - -export class SecureUnixSocketServer { - private server: Server | null = null; - private socketIdentity: { readonly dev: number; readonly ino: number } | null = null; - private readonly endpoint: string; - private readonly uid: number; - - permissionsVerified = false; - - private constructor(private readonly options: SecureUnixSocketServerOptions) { - const platform = options.platform ?? process.platform; - if (platform !== "darwin") { - throw new RuntimeError( - "RUNTIME_ENVIRONMENT_INVALID", - "Unix Runtime control requires macOS", - ); - } - const uid = options.currentUid ?? process.getuid?.(); - if (typeof uid !== "number" || !Number.isInteger(uid) || uid < 0 || - options.userIdentity !== `uid:${uid}`) { - throw new RuntimeError( - "RUNTIME_ENVIRONMENT_INVALID", - "macOS Runtime user identity is invalid", - ); - } - this.uid = uid; - this.endpoint = options.endpoint ?? controlEndpointForIdentity( - options.userIdentity, - "darwin", - ); - } - - static async start(options: SecureUnixSocketServerOptions): Promise { - const control = new SecureUnixSocketServer(options); - await removeOwnedStaleSocket(control.endpoint, control.uid); - const server = createServer((socket) => control.handleConnection(socket)); - control.server = server; - await new Promise((resolveListen, rejectListen) => { - server.once("error", rejectListen); - server.listen(control.endpoint, () => { - server.off("error", rejectListen); - resolveListen(); - }); - }).catch(async (error: unknown) => { - await control.close(); - throw error; - }); - - await chmod(control.endpoint, 0o600); - const info = await lstat(control.endpoint); - const mode = info.mode & 0o777; - if (!info.isSocket() || info.uid !== control.uid || mode !== 0o600) { - await control.close(); - throw unavailableError(); - } - control.socketIdentity = { dev: info.dev, ino: info.ino }; - control.permissionsVerified = true; - return control; - } - - async close(): Promise { - const server = this.server; - this.server = null; - if (server) { - await new Promise((resolveClose) => server.close(() => resolveClose())); - } - try { - const info = await lstat(this.endpoint); - if (this.socketIdentity && info.isSocket() && info.uid === this.uid && - info.dev === this.socketIdentity.dev && info.ino === this.socketIdentity.ino) { - await unlink(this.endpoint); - } - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; - } - } - - private handleConnection(socket: Socket): void { - // Binding creates the filesystem node before chmod completes. Never retain a - // connection accepted during that short startup window. - if (!this.permissionsVerified) { - socket.destroy(); - return; - } - const chunks: Buffer[] = []; - let bytes = 0; - let expected: number | null = null; - let settled = false; - - const fail = (requestId = NIL_REQUEST_ID) => { - if (settled || socket.destroyed) return; - settled = true; - socket.end(encodeControlFrame(failureResponse(requestId))); - }; - - socket.on("data", (chunk: Buffer) => { - if (settled) { - socket.destroy(); - return; - } - bytes += chunk.length; - if (bytes > CONTROL_MAX_FRAME_BYTES + 4) { - fail(); - return; - } - chunks.push(chunk); - const frame = Buffer.concat(chunks); - if (expected === null && frame.length >= 4) { - const payloadBytes = frame.readUInt32LE(0); - if (payloadBytes < 1 || payloadBytes > CONTROL_MAX_FRAME_BYTES) { - fail(); - return; - } - expected = payloadBytes + 4; - } - if (expected === null || frame.length < expected) return; - if (frame.length !== expected) { - fail(); - return; - } - settled = true; - let raw: unknown; - try { - raw = decodeControlFrame(frame); - const request = ControlRequestSchema.parse(raw); - void this.options.dispatch(request).then((result) => { - socket.end(encodeControlFrame(result.response), () => { - void result.afterResponse?.(); - }); - }, () => { - settled = false; - fail(request.requestId); - }); - } catch { - settled = false; - fail(requestIdFrom(raw)); - } - }); - socket.once("error", () => socket.destroy()); - } -} diff --git a/runtime/windows/src/controller/exit-monitor.ts b/runtime/windows/src/controller/exit-monitor.ts deleted file mode 100644 index 3095cc4..0000000 --- a/runtime/windows/src/controller/exit-monitor.ts +++ /dev/null @@ -1,139 +0,0 @@ -import { RuntimeError } from "../errors.js"; -import { transitionRuntimeState } from "../session/state-machine.js"; -import type { RuntimeSessionState, RuntimeStateStore } from "../state.js"; -import type { WindowsRuntimeProvider } from "../types.js"; - -export interface ExitMonitorDependencies { - readonly provider: WindowsRuntimeProvider; - readonly state: RuntimeStateStore; - readonly onStopped: () => Promise | void; - readonly initialPortWaitMs?: number; - readonly initialIntervalMs?: number; - readonly cleanupIntervalMs?: number; -} - -function boundedDelay(value: number | undefined, fallback: number, allowZero = false): number { - const resolved = value ?? fallback; - if (!Number.isInteger(resolved) || resolved < (allowZero ? 0 : 1) || resolved > 60_000) { - throw new RuntimeError("RUNTIME_ENVIRONMENT_INVALID", "Exit monitor delay is invalid"); - } - return resolved; -} - -export class ExitMonitor { - private running = false; - private timer: ReturnType | null = null; - - constructor(private readonly dependencies: ExitMonitorDependencies) {} - - start(session: RuntimeSessionState): void { - if (!session.codex || !session.cdp) { - throw new RuntimeError("RUNTIME_SESSION_STALE", "Exit monitor requires managed identities"); - } - this.stop(); - this.running = true; - void this.observe(session).catch(() => { - // The terminal session remains persisted so a later Controller can retry safely. - this.stop(); - }); - } - - stop(): void { - this.running = false; - if (this.timer) clearTimeout(this.timer); - this.timer = null; - } - - private async observe(session: RuntimeSessionState): Promise { - if (!this.running) return; - if (session.status === "restored-cleanup-required") { - await this.observePort(session, false); - return; - } - - const exited = await this.dependencies.provider.waitForExit( - session.codex!.rootPid, - session.codex!.startedAt, - 500, - ); - if (!this.running) return; - if (!exited) { - this.schedule(session, boundedDelay(this.dependencies.initialIntervalMs, 100)); - return; - } - - await this.observePort(session, true); - } - - private async observePort( - session: RuntimeSessionState, - initial: boolean, - ): Promise { - const initialWaitMs = boundedDelay(this.dependencies.initialPortWaitMs, 10_000, true); - const intervalMs = boundedDelay(this.dependencies.initialIntervalMs, 100); - const deadline = Date.now() + (initial ? initialWaitMs : 0); - - while (this.running) { - const listener = await this.dependencies.provider.inspectPort(session.cdp!.port); - if (!listener) { - await this.clearStoppedSession(session); - return; - } - if (!initial || Date.now() >= deadline) { - const cleanupSession = await this.markCleanupRequired(session); - if (cleanupSession) { - this.schedule(cleanupSession, boundedDelay(this.dependencies.cleanupIntervalMs, 5_000)); - } - return; - } - await new Promise((resolve) => setTimeout(resolve, intervalMs)); - } - } - - private async clearStoppedSession(session: RuntimeSessionState): Promise { - const current = await this.dependencies.state.read(); - if (!current || current.sessionId !== session.sessionId) { - this.stop(); - return; - } - await this.dependencies.state.clear(); - await this.dependencies.onStopped(); - this.stop(); - } - - private async markCleanupRequired( - session: RuntimeSessionState, - ): Promise { - const current = await this.dependencies.state.read(); - if (!current || current.sessionId !== session.sessionId) { - this.stop(); - return null; - } - if (current.status === "restored-cleanup-required") return current; - - const next: RuntimeSessionState = { - ...current, - status: "restored-cleanup-required", - appliedTheme: null, - skinApplied: false, - pendingOperation: null, - updatedAt: new Date().toISOString(), - }; - await this.dependencies.state.write(transitionRuntimeState(current, next)); - return next; - } - - private schedule(session: RuntimeSessionState, delayMs: number): void { - if (!this.running) return; - if (this.timer) clearTimeout(this.timer); - this.timer = setTimeout(async () => { - this.timer = null; - try { - await this.observe(session); - } catch { - // Keep the terminal state intact rather than claiming cleanup succeeded. - this.stop(); - } - }, delayMs); - } -} diff --git a/runtime/windows/src/controller/managed-session.ts b/runtime/windows/src/controller/managed-session.ts deleted file mode 100644 index d2c9e3b..0000000 --- a/runtime/windows/src/controller/managed-session.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { RuntimeError } from "../errors.js"; -import type { RuntimeSessionState } from "../state.js"; -import type { WindowsRuntimeProvider } from "../types.js"; -import { windowsPathsEqual } from "../windows/powershell-provider.js"; - -export async function hasManagedSessionExited( - state: RuntimeSessionState, - provider: WindowsRuntimeProvider, -): Promise { - if (!state.codex || !state.cdp) return false; - - const [roots, listener] = await Promise.all([ - provider.listCodexRoots(), - provider.inspectPort(state.cdp.port), - ]); - const recordedRootExists = roots.some((root) => - root.pid === state.codex!.rootPid && root.startedAt === state.codex!.startedAt - ); - return !recordedRootExists && listener === null; -} - -export async function revalidateManagedSession( - state: RuntimeSessionState, - provider: WindowsRuntimeProvider, -): Promise { - if (!state.codex || !state.cdp) { - throw new RuntimeError("RUNTIME_SESSION_STALE", "Managed identity is incomplete"); - } - - const roots = await provider.listCodexRoots(); - const exact = roots.find((root) => - root.pid === state.codex!.rootPid && root.startedAt === state.codex!.startedAt - ); - if (!exact || !windowsPathsEqual(exact.executablePath, state.codex.executablePath)) { - throw new RuntimeError("RUNTIME_SESSION_STALE", "Managed Codex root changed"); - } - - const port = await provider.inspectPort(state.cdp.port); - if (!port || port.host !== "127.0.0.1" || port.port !== state.cdp.port || - !port.ancestors.includes(state.codex.rootPid)) { - throw new RuntimeError("CDP_PROCESS_MISMATCH", "Managed CDP ownership changed"); - } -} diff --git a/runtime/windows/src/controller/page-session.ts b/runtime/windows/src/controller/page-session.ts deleted file mode 100644 index 7f85b3b..0000000 --- a/runtime/windows/src/controller/page-session.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { - CdpConnection, - CurrentCodexAdapter, - waitForCompatibleAdapter, - waitForCompatibleCodexTarget, - type AdapterProbe, - type CdpEndpoint, - type CdpRuntimeClient, - type CdpTarget, - type RuntimeThemeAdapter, -} from "@open-chatgpt-skin/cdp-adapter"; - -const RUNTIME_PAGE_TARGET_WAIT_OPTIONS = { - timeoutMs: 30_000, - intervalMs: 100, -} as const; - -export interface RuntimePageConnection extends CdpRuntimeClient { - close(): void; - on(method: string, listener: (params: unknown) => void): () => void; - onClose(listener: () => void): () => void; -} - -export interface RuntimePageSession { - readonly endpoint: CdpEndpoint; - readonly target: CdpTarget; - readonly adapterId: string; - readonly connection: RuntimePageConnection; - readonly adapter: RuntimeThemeAdapter; - close(): void; -} - -export interface ConnectRuntimePageDependencies { - readonly waitForTarget: typeof waitForCompatibleCodexTarget; - readonly connect: (url: string, endpoint: CdpEndpoint) => Promise; - readonly createAdapter: (connection: RuntimePageConnection) => RuntimeThemeAdapter; - readonly waitForAdapter: (adapter: RuntimeThemeAdapter) => Promise; -} - -export async function connectRuntimePage( - endpoint: CdpEndpoint, - dependencies: Partial = {}, -): Promise { - const waitForTarget = dependencies.waitForTarget ?? waitForCompatibleCodexTarget; - const connect = dependencies.connect ?? CdpConnection.connect; - const createAdapter = dependencies.createAdapter ?? - ((connection: RuntimePageConnection) => new CurrentCodexAdapter(connection)); - const waitForAdapter = dependencies.waitForAdapter ?? - ((adapter: RuntimeThemeAdapter) => waitForCompatibleAdapter(adapter)); - const target = await waitForTarget(endpoint, RUNTIME_PAGE_TARGET_WAIT_OPTIONS); - const connection = await connect(target.webSocketDebuggerUrl, endpoint); - const adapter = createAdapter(connection); - try { - const probe = await waitForAdapter(adapter); - return { - endpoint, - target, - adapterId: probe.adapterId, - connection, - adapter, - close: () => connection.close(), - }; - } catch (error) { - connection.close(); - throw error; - } -} diff --git a/runtime/windows/src/controller/production.ts b/runtime/windows/src/controller/production.ts deleted file mode 100644 index e50c4e4..0000000 --- a/runtime/windows/src/controller/production.ts +++ /dev/null @@ -1,310 +0,0 @@ -import { randomUUID } from "node:crypto"; -import { spawn, type ChildProcess } from "node:child_process"; -import { join } from "node:path"; -import { fileURLToPath } from "node:url"; -import { z } from "zod"; -import { THEME_CORE_VERSION } from "@open-chatgpt-skin/theme-core"; -import { RuntimeControlDispatcher } from "../control/dispatcher.js"; -import { ControllerLock } from "../control/controller-lock.js"; -import { sendControlRequest } from "../control/pipe-client.js"; -import { - startSecureControlServer, - type SecureControlServer, -} from "../control/secure-control-server.js"; -import { controlEndpointForIdentity } from "../control/protocol.js"; -import { TrustedInstallStore } from "../discovery/trusted-cache.js"; -import { RUNTIME_ERROR_CODES, RuntimeError, runtimeErrorCode } from "../errors.js"; -import { - activateManagedCodexWindow, - launchManagedCodex, - waitForManagedPort, -} from "../launcher/launcher.js"; -import { RuntimeLogger } from "../logging.js"; -import { createProductionRuntimePaths, type RuntimePaths } from "../paths.js"; -import { RuntimeStateStore } from "../state.js"; -import { RuntimeThemeRepository } from "../themes/runtime-theme-repository.js"; -import { createProductionDesktopProvider } from "../platform/provider-factory.js"; -import type { DesktopRuntimeProvider } from "../types.js"; -import type { RuntimeCliDependencies } from "../cli/run.js"; -import { connectRuntimePage } from "./page-session.js"; -import { recoverRuntimeController } from "./recovery.js"; -import { - RuntimeController, - type RuntimeControllerDependencies, -} from "./runtime-controller.js"; -import { ThemeEngine } from "./theme-engine.js"; - -const STARTUP_TIMEOUT_MS = 20_000; -const MAX_STARTUP_LINE_BYTES = 8 * 1024; -export const RUNTIME_VERSION = process.env.OPEN_CHATGPT_SKIN_VERSION ?? - THEME_CORE_VERSION; - -const RuntimeControllerStartupResponseSchema = z.object({ - startupId: z.string().uuid(), - ready: z.boolean(), - errorCode: z.enum(RUNTIME_ERROR_CODES).optional(), -}).strict().superRefine((value, context) => { - if (value.ready && value.errorCode !== undefined) { - context.addIssue({ - code: z.ZodIssueCode.custom, - message: "ready startup responses cannot include an error code", - }); - } -}); - -export type RuntimeControllerStartupResponse = z.infer< - typeof RuntimeControllerStartupResponseSchema ->; - -export interface ProductionRuntimeController { - close(): Promise; -} - -function startupUnavailable(): RuntimeError { - return new RuntimeError( - "RUNTIME_CONTROL_UNAVAILABLE", - "Runtime controller startup acknowledgement is unavailable", - "Retry the OpenChatGPTSkin command.", - ); -} - -function detachedCliPath(): string { - return fileURLToPath(new URL("../cli.js", import.meta.url)); -} - -function stopHelperProcess(child: ChildProcess): void { - if (child.exitCode === null && !child.killed) child.kill(); -} - -async function waitForStartup( - child: ChildProcess, - startupId: string, -): Promise { - const stdout = child.stdout; - const stderr = child.stderr; - if (!stdout || !stderr) throw startupUnavailable(); - stderr.resume(); - - await new Promise((resolveReady, rejectReady) => { - let settled = false; - let received = ""; - const settle = (callback: () => void) => { - if (settled) return; - settled = true; - clearTimeout(timeout); - stdout.off("data", onData); - child.off("error", onError); - child.off("exit", onExit); - callback(); - }; - const fail = (error: Error) => settle(() => rejectReady(error)); - const onError = () => fail(startupUnavailable()); - const onExit = () => fail(startupUnavailable()); - const onData = (chunk: Buffer | string) => { - received += typeof chunk === "string" ? chunk : chunk.toString("utf8"); - if (Buffer.byteLength(received, "utf8") > MAX_STARTUP_LINE_BYTES) { - fail(startupUnavailable()); - return; - } - - const newline = received.indexOf("\n"); - if (newline < 0) return; - if (received.slice(newline + 1) !== "") { - fail(startupUnavailable()); - return; - } - - let parsed: RuntimeControllerStartupResponse; - try { - parsed = RuntimeControllerStartupResponseSchema.parse( - JSON.parse(received.slice(0, newline)), - ); - } catch { - fail(startupUnavailable()); - return; - } - if (parsed.startupId !== startupId) { - fail(startupUnavailable()); - return; - } - if (!parsed.ready) { - fail(new RuntimeError( - parsed.errorCode ?? "RUNTIME_CONTROL_UNAVAILABLE", - "Runtime controller did not become ready", - )); - return; - } - settle(resolveReady); - }; - const timeout = setTimeout(() => fail(startupUnavailable()), STARTUP_TIMEOUT_MS); - - stdout.setEncoding("utf8"); - stdout.on("data", onData); - child.once("error", onError); - child.once("exit", onExit); - }); -} - -export async function startDetachedRuntimeController( - mode: "new" | "recover", -): Promise { - const startupId = randomUUID(); - const child = spawn( - process.execPath, - [detachedCliPath(), "serve", "--mode", mode, "--startup-id", startupId], - { - detached: true, - shell: false, - windowsHide: true, - stdio: ["ignore", "pipe", "pipe"], - }, - ); - - try { - await waitForStartup(child, startupId); - } catch (error) { - stopHelperProcess(child); - throw error; - } - - child.stdout?.destroy(); - child.stderr?.destroy(); - child.unref(); -} - -export function createProductionRuntimeCliDependencies(): RuntimeCliDependencies { - const paths = createProductionRuntimePaths(); - const provider = createProductionDesktopProvider(paths); - const state = new RuntimeStateStore(paths.sessionFile); - return { - themes: new RuntimeThemeRepository(paths.themesRoot, paths.themeStoreDirectory), - state, - currentUserSid: () => provider.currentUserSid(), - send: (sid, request, responseTimeoutMs) => sendControlRequest( - responseTimeoutMs === undefined - ? { sid, request, endpoint: controlEndpointForIdentity(sid, process.platform) } - : { - sid, - request, - responseTimeoutMs, - endpoint: controlEndpointForIdentity(sid, process.platform), - }, - ), - startController: startDetachedRuntimeController, - newRequestId: randomUUID, - }; -} - -async function secureControllerDirectories( - provider: DesktopRuntimeProvider, - paths: RuntimePaths, -): Promise { - await provider.secureDirectory(paths.runtimeDirectory); - await provider.secureDirectory(paths.installDirectory); - await provider.secureDirectory(paths.themeStoreDirectory); -} - -export async function serveProductionRuntimeController( - mode: "new" | "recover", -): Promise { - const paths = createProductionRuntimePaths(); - const provider = createProductionDesktopProvider(paths); - await secureControllerDirectories(provider, paths); - - const startedAt = await provider.inspectProcessStartedAt(process.pid); - if (!startedAt) { - throw new RuntimeError( - "PROCESS_INSPECTION_DENIED", - "Runtime controller process identity could not be verified", - ); - } - const lock = await ControllerLock.acquire( - paths.controllerLockFile, - { pid: process.pid, startedAt }, - (pid) => provider.inspectProcessStartedAt(pid), - ); - - const state = new RuntimeStateStore(paths.sessionFile); - const cache = new TrustedInstallStore(paths.installCache); - const logger = new RuntimeLogger(join(paths.logDirectory, "runtime.jsonl")); - const repository = new RuntimeThemeRepository( - paths.themesRoot, - paths.themeStoreDirectory, - ); - const themes = new ThemeEngine(repository); - let controller: RuntimeController | null = null; - let control: SecureControlServer | null = null; - let shutdownPromise: Promise | null = null; - - const shutdown = (): Promise => { - if (shutdownPromise) return shutdownPromise; - shutdownPromise = (async () => { - try { - await controller?.close(); - } finally { - try { - await control?.close(); - } finally { - await lock.release(); - } - } - })(); - return shutdownPromise; - }; - - const dependencies: RuntimeControllerDependencies = { - paths, - provider, - state, - themes, - launchManaged: () => launchManagedCodex({ provider, cache }), - waitForPort: (receipt) => waitForManagedPort(provider, receipt), - activateWindow: (receipt) => activateManagedCodexWindow(provider, receipt), - connectPage: connectRuntimePage, - secureRuntimeDirectories: () => secureControllerDirectories(provider, paths), - now: () => new Date().toISOString(), - runtimeIdentity: { pid: process.pid, startedAt }, - newSessionId: randomUUID, - onStopped: shutdown, - }; - - try { - controller = mode === "recover" - ? await recoverRuntimeController({ ...dependencies, cache }) - : new RuntimeController(dependencies); - const dispatcher = new RuntimeControlDispatcher(controller, state); - control = await startSecureControlServer({ - platform: provider.platform ?? process.platform, - userIdentity: await provider.currentUserSid(), - dispatch: (request) => dispatcher.dispatch(request), - }); - if (!control.securityVerified) throw startupUnavailable(); - if (mode === "recover") { - await logger.write({ - schemaVersion: 1, - timestamp: dependencies.now(), - event: "runtime-recovered", - runtimeVersion: RUNTIME_VERSION, - }); - } - return { close: shutdown }; - } catch (error) { - await shutdown(); - throw error; - } -} - -export function startupFailureResponse( - startupId: string, - error: unknown, -): RuntimeControllerStartupResponse { - return RuntimeControllerStartupResponseSchema.parse({ - startupId, - ready: false, - errorCode: runtimeErrorCode(error), - }); -} - -export function startupReadyResponse(startupId: string): RuntimeControllerStartupResponse { - return RuntimeControllerStartupResponseSchema.parse({ startupId, ready: true }); -} diff --git a/runtime/windows/src/controller/recovery.ts b/runtime/windows/src/controller/recovery.ts deleted file mode 100644 index 27e3073..0000000 --- a/runtime/windows/src/controller/recovery.ts +++ /dev/null @@ -1,421 +0,0 @@ -import { discoverCodexInstall } from "../discovery/discover.js"; -import type { TrustedInstallStore } from "../discovery/trusted-cache.js"; -import { RuntimeError } from "../errors.js"; -import { - type RuntimeSessionState, - type RuntimeStateStore, -} from "../state.js"; -import type { RuntimeThemeRef } from "../session/model.js"; -import { transitionRuntimeState } from "../session/state-machine.js"; -import { windowsPathsEqual } from "../windows/powershell-provider.js"; -import type { LoadedRuntimeTheme } from "../themes/runtime-theme-repository.js"; -import { - hasManagedSessionExited, - revalidateManagedSession, -} from "./managed-session.js"; -import type { RuntimePageSession } from "./page-session.js"; -import { - RuntimeController, - type RuntimeControllerDependencies, -} from "./runtime-controller.js"; - -export interface RecoverRuntimeControllerDependencies extends RuntimeControllerDependencies { - readonly cache: TrustedInstallStore; - readonly discoverCodexInstall?: typeof discoverCodexInstall; -} - -export const RUNTIME_RECOVERY_SEMANTIC_CASES = [ - { name: "stable active appearance", valid: true, sourceStatus: "active", expectedStatus: "active" }, - { name: "stable paused appearance", valid: true, sourceStatus: "paused", expectedStatus: "paused" }, - { name: "interrupted launch restores official appearance", valid: true, operation: "launch", expectedStatus: "restored-awaiting-exit" }, - { name: "interrupted restore retries official appearance", valid: true, operation: "restore", expectedStatus: "restored-awaiting-exit" }, - { name: "failed cleanup marks appearance unknown", valid: true, cleanupSucceeded: false, expectedStatus: "recovery-required" }, - { name: "managed process identity changed", valid: false, expectedErrorCode: "RUNTIME_SESSION_STALE", expectedPath: "/codex/startedAt" }, -] as const; - -interface RecoveredAppearance { - readonly state: RuntimeSessionState; - readonly keepPage: boolean; -} - -function stale(message: string): RuntimeError { - return new RuntimeError("RUNTIME_SESSION_STALE", message); -} - -function assertThemeVersion( - theme: LoadedRuntimeTheme, - expected: RuntimeThemeRef, -): void { - if (theme.descriptor.id !== expected.id || theme.descriptor.version !== expected.version) { - throw new RuntimeError("THEME_NOT_READY", "Runtime theme version changed"); - } -} - -function withObservedAdapter( - state: RuntimeSessionState, - page: RuntimePageSession, - dependencies: RecoverRuntimeControllerDependencies, -): RuntimeSessionState { - return { - ...state, - adapter: { id: page.adapterId, version: 1 }, - updatedAt: dependencies.now(), - }; -} - -async function commit( - current: RuntimeSessionState, - next: RuntimeSessionState, - state: RuntimeStateStore, -): Promise { - const validated = transitionRuntimeState(current, next); - await state.write(validated); - return validated; -} - -async function markRecoveryRequired( - state: RuntimeSessionState, - dependencies: RecoverRuntimeControllerDependencies, -): Promise { - return commit(state, { - ...state, - status: "recovery-required", - appliedTheme: null, - skinApplied: null, - pendingOperation: null, - updatedAt: dependencies.now(), - }, dependencies.state); -} - -async function markPausedIncompatible( - state: RuntimeSessionState, - page: RuntimePageSession, - selectedTheme: RuntimeThemeRef, - dependencies: RecoverRuntimeControllerDependencies, -): Promise { - const observed = withObservedAdapter(state, page, dependencies); - return commit(state, { - ...observed, - status: "paused-incompatible", - selectedTheme, - appliedTheme: null, - skinApplied: false, - pendingOperation: null, - updatedAt: dependencies.now(), - }, dependencies.state); -} - -async function markPaused( - state: RuntimeSessionState, - page: RuntimePageSession, - selectedTheme: RuntimeThemeRef, - dependencies: RecoverRuntimeControllerDependencies, -): Promise { - const observed = withObservedAdapter(state, page, dependencies); - return commit(state, { - ...observed, - status: "paused", - selectedTheme, - appliedTheme: null, - skinApplied: false, - pendingOperation: null, - updatedAt: dependencies.now(), - }, dependencies.state); -} - -async function markActive( - state: RuntimeSessionState, - page: RuntimePageSession, - selectedTheme: RuntimeThemeRef, - dependencies: RecoverRuntimeControllerDependencies, -): Promise { - const observed = withObservedAdapter(state, page, dependencies); - return commit(state, { - ...observed, - status: "active", - selectedTheme, - appliedTheme: selectedTheme, - skinApplied: true, - pendingOperation: null, - updatedAt: dependencies.now(), - }, dependencies.state); -} - -async function markRestoredAwaitingExit( - state: RuntimeSessionState, - dependencies: RecoverRuntimeControllerDependencies, -): Promise { - return commit(state, { - ...state, - status: "restored-awaiting-exit", - appliedTheme: null, - skinApplied: false, - pendingOperation: null, - updatedAt: dependencies.now(), - }, dependencies.state); -} - -async function cleanupToOfficial( - page: RuntimePageSession, - dependencies: RecoverRuntimeControllerDependencies, -): Promise { - try { - await dependencies.themes.cleanup(page); - return true; - } catch { - return false; - } -} - -async function reapplyVerifiedTheme( - state: RuntimeSessionState, - page: RuntimePageSession, - selectedTheme: RuntimeThemeRef, - dependencies: RecoverRuntimeControllerDependencies, -): Promise { - let theme: LoadedRuntimeTheme; - try { - theme = await dependencies.themes.load(selectedTheme); - assertThemeVersion(theme, selectedTheme); - await dependencies.themes.apply(page, theme); - } catch { - if (await cleanupToOfficial(page, dependencies)) { - return { - state: await markPausedIncompatible(state, page, selectedTheme, dependencies), - keepPage: true, - }; - } - return { - state: await markRecoveryRequired(state, dependencies), - keepPage: false, - }; - } - - return { - state: await markActive(state, page, selectedTheme, dependencies), - keepPage: true, - }; -} - -async function recoverStableAppearance( - state: RuntimeSessionState, - page: RuntimePageSession, - dependencies: RecoverRuntimeControllerDependencies, -): Promise { - if (!await cleanupToOfficial(page, dependencies)) { - return { - state: await markRecoveryRequired(state, dependencies), - keepPage: false, - }; - } - - if (state.status === "active") { - return reapplyVerifiedTheme(state, page, state.selectedTheme, dependencies); - } - - if (state.status === "paused") { - return { - state: await markPaused(state, page, state.selectedTheme, dependencies), - keepPage: true, - }; - } - - if (state.status === "paused-incompatible") { - return { - state: await markPausedIncompatible(state, page, state.selectedTheme, dependencies), - keepPage: true, - }; - } - - if (state.status === "launching") { - return { - state: await markRestoredAwaitingExit(state, dependencies), - keepPage: false, - }; - } - - return { - state: await markRecoveryRequired(state, dependencies), - keepPage: false, - }; -} - -async function recoverInterruptedThemeOperation( - state: RuntimeSessionState, - page: RuntimePageSession, - dependencies: RecoverRuntimeControllerDependencies, -): Promise { - const operation = state.pendingOperation; - if (!operation || !await cleanupToOfficial(page, dependencies)) { - return { - state: await markRecoveryRequired(state, dependencies), - keepPage: false, - }; - } - - if (operation.previousStatus === "active" && operation.previousSelectedTheme && - operation.previousAppliedTheme) { - if (operation.previousSelectedTheme.id !== operation.previousAppliedTheme.id || - operation.previousSelectedTheme.version !== operation.previousAppliedTheme.version) { - return { - state: await markPausedIncompatible( - state, - page, - operation.previousSelectedTheme, - dependencies, - ), - keepPage: true, - }; - } - return reapplyVerifiedTheme( - state, - page, - operation.previousSelectedTheme, - dependencies, - ); - } - - if (operation.previousStatus === "paused") { - return { - state: await markPaused( - state, - page, - operation.previousSelectedTheme ?? state.selectedTheme, - dependencies, - ), - keepPage: true, - }; - } - - if (operation.previousStatus === "paused-incompatible") { - return { - state: await markPausedIncompatible( - state, - page, - operation.previousSelectedTheme ?? state.selectedTheme, - dependencies, - ), - keepPage: true, - }; - } - - return { - state: await markPausedIncompatible(state, page, state.selectedTheme, dependencies), - keepPage: true, - }; -} - -async function recoverInterruptedRestore( - state: RuntimeSessionState, - page: RuntimePageSession, - dependencies: RecoverRuntimeControllerDependencies, -): Promise { - if (!await cleanupToOfficial(page, dependencies)) { - return { - state: await markRecoveryRequired(state, dependencies), - keepPage: false, - }; - } - return { - state: await markRestoredAwaitingExit(state, dependencies), - keepPage: false, - }; -} - -async function recoverInterruptedLaunch( - state: RuntimeSessionState, - page: RuntimePageSession, - dependencies: RecoverRuntimeControllerDependencies, -): Promise { - if (!await cleanupToOfficial(page, dependencies)) { - return { - state: await markRecoveryRequired(state, dependencies), - keepPage: false, - }; - } - return { - state: await markRestoredAwaitingExit(state, dependencies), - keepPage: false, - }; -} - -async function verifyRecordedIdentity( - state: RuntimeSessionState, - dependencies: RecoverRuntimeControllerDependencies, -): Promise { - if (!state.codex || !state.cdp) throw stale("Managed identity is incomplete"); - - const discover = dependencies.discoverCodexInstall ?? discoverCodexInstall; - const discovered = await discover(dependencies.provider, dependencies.cache); - const root = discovered.runningRoot; - if (!root || root.pid !== state.codex.rootPid || root.startedAt !== state.codex.startedAt || - !windowsPathsEqual(root.executablePath, state.codex.executablePath)) { - throw stale("Managed Codex root changed"); - } - if (!windowsPathsEqual(discovered.install.entryPath, state.codex.executablePath) || - !windowsPathsEqual(discovered.install.packageRoot, state.codex.packageRoot) || - discovered.install.packageVersion !== state.codex.packageVersion) { - throw stale("Managed Codex installation changed"); - } - - await revalidateManagedSession(state, dependencies.provider); -} - -export async function recoverRuntimeController( - dependencies: RecoverRuntimeControllerDependencies, -): Promise { - const state = await dependencies.state.read(); - if (!state) { - throw new RuntimeError("RUNTIME_INVALID_STATE", "No Runtime session is available to recover"); - } - - const controller = new RuntimeController(dependencies); - try { - await verifyRecordedIdentity(state, dependencies); - } catch (error) { - if (state.status === "recovery-required" && !state.pendingOperation && - await hasManagedSessionExited(state, dependencies.provider)) { - return controller; - } - throw error; - } - - if (state.status === "restored-awaiting-exit" || - state.status === "restored-cleanup-required") { - await controller.startExitMonitoring(); - return controller; - } - - if (state.status === "recovery-required" && !state.pendingOperation) { - return controller; - } - - let page: RuntimePageSession | null = null; - let recovered: RecoveredAppearance; - try { - page = await dependencies.connectPage(state.cdp!); - recovered = state.pendingOperation?.kind === "launch" - ? await recoverInterruptedLaunch(state, page, dependencies) - : state.pendingOperation?.kind === "restore" - ? await recoverInterruptedRestore(state, page, dependencies) - : state.pendingOperation - ? await recoverInterruptedThemeOperation(state, page, dependencies) - : await recoverStableAppearance(state, page, dependencies); - } catch { - // A page/adapter failure leaves visual state unverified, so classify it explicitly. - page?.close(); - await markRecoveryRequired(state, dependencies); - return controller; - } - - if (recovered.keepPage) { - controller.attachRecoveredPage(page); - return controller; - } - - page.close(); - if (recovered.state.status === "restored-awaiting-exit") { - await controller.startExitMonitoring(); - } - return controller; -} diff --git a/runtime/windows/src/controller/renderer-lifecycle.ts b/runtime/windows/src/controller/renderer-lifecycle.ts deleted file mode 100644 index 932807e..0000000 --- a/runtime/windows/src/controller/renderer-lifecycle.ts +++ /dev/null @@ -1,103 +0,0 @@ -import type { RuntimePageSession } from "./page-session.js"; - -export interface RendererLifecycleDependencies { - readonly reconcile: () => Promise; - readonly reconnect: () => Promise; - readonly debounceMs?: number; -} - -export interface RendererEventSource { - on(method: string, listener: (params: unknown) => void): () => void; - onClose(listener: () => void): () => void; -} - -type ScheduledWork = "reconcile" | "reconnect"; - -export class RendererLifecycleMonitor { - private source: RendererEventSource | null = null; - private unsubscribeContext: (() => void) | null = null; - private unsubscribeClose: (() => void) | null = null; - private timer: ReturnType | null = null; - private scheduled: ScheduledWork | null = null; - private reconnecting = false; - private running = false; - - constructor(private readonly dependencies: RendererLifecycleDependencies) {} - - start(source: RendererEventSource): void { - this.stop(); - this.running = true; - this.subscribe(source); - } - - replace(source: RendererEventSource): void { - if (!this.running) return; - this.unsubscribe(); - this.subscribe(source); - } - - stop(): void { - this.running = false; - this.reconnecting = false; - this.scheduled = null; - if (this.timer) clearTimeout(this.timer); - this.timer = null; - this.unsubscribe(); - } - - private subscribe(source: RendererEventSource): void { - this.source = source; - this.unsubscribeContext = source.on("Runtime.executionContextsCleared", () => { - this.schedule("reconcile"); - }); - this.unsubscribeClose = source.onClose(() => { - if (this.reconnecting || this.scheduled === "reconnect") return; - this.schedule("reconnect"); - }); - } - - private unsubscribe(): void { - this.unsubscribeContext?.(); - this.unsubscribeClose?.(); - this.unsubscribeContext = null; - this.unsubscribeClose = null; - this.source = null; - } - - private schedule(work: ScheduledWork): void { - if (!this.running) return; - if (this.scheduled === "reconnect") work = "reconnect"; - this.scheduled = work; - if (this.timer) clearTimeout(this.timer); - this.timer = setTimeout(async () => { - this.timer = null; - const scheduled = this.scheduled; - this.scheduled = null; - if (scheduled) await this.run(scheduled); - }, this.dependencies.debounceMs ?? 250); - } - - private async run(work: ScheduledWork): Promise { - if (!this.running) return; - if (work === "reconcile") { - try { - await this.dependencies.reconcile(); - } catch { - // The Controller owns state classification for reconciliation failures. - } - return; - } - - this.reconnecting = true; - try { - const page = await this.dependencies.reconnect(); - if (!this.running) return; - this.replace(page.connection); - await this.dependencies.reconcile(); - } catch { - // reconnect() records the one truthful Controller outcome before rejecting. - } finally { - this.reconnecting = false; - } - } -} diff --git a/runtime/windows/src/controller/runtime-controller.ts b/runtime/windows/src/controller/runtime-controller.ts deleted file mode 100644 index cf4a53f..0000000 --- a/runtime/windows/src/controller/runtime-controller.ts +++ /dev/null @@ -1,923 +0,0 @@ -import type { CdpEndpoint } from "@open-chatgpt-skin/cdp-adapter"; -import type { RuntimeStatusView } from "../control/result.js"; -import { RuntimeError, runtimeErrorCode, type RuntimeErrorCode } from "../errors.js"; -import type { LaunchReceipt } from "../launcher/launcher.js"; -import type { RuntimePaths } from "../paths.js"; -import { - type PendingOperation, - type RuntimeThemeRef, -} from "../session/model.js"; -import { transitionRuntimeState } from "../session/state-machine.js"; -import type { RuntimeSessionState, RuntimeStateStore } from "../state.js"; -import type { PortInspection, WindowsRuntimeProvider } from "../types.js"; -import { - hasManagedSessionExited, - revalidateManagedSession, -} from "./managed-session.js"; -import { ExitMonitor } from "./exit-monitor.js"; -import type { RuntimePageSession } from "./page-session.js"; -import { RendererLifecycleMonitor } from "./renderer-lifecycle.js"; -import { ThemeEngine } from "./theme-engine.js"; -import type { LoadedRuntimeTheme } from "../themes/runtime-theme-repository.js"; - -export interface RuntimeExitMonitor { - start(session: RuntimeSessionState): void; - stop(): void; -} - -export interface RuntimeControllerDependencies { - readonly paths: RuntimePaths; - readonly provider: WindowsRuntimeProvider; - readonly state: RuntimeStateStore; - readonly themes: ThemeEngine; - readonly launchManaged: () => Promise; - readonly waitForPort: (receipt: LaunchReceipt) => Promise; - readonly activateWindow: (receipt: LaunchReceipt) => Promise; - readonly connectPage: (endpoint: CdpEndpoint) => Promise; - readonly secureRuntimeDirectories: () => Promise; - readonly now: () => string; - readonly runtimeIdentity: { readonly pid: number; readonly startedAt: string }; - readonly newSessionId: () => string; - readonly onStopped: () => Promise | void; - readonly createExitMonitor?: () => RuntimeExitMonitor; -} - -function nextAction(state: RuntimeSessionState | null): string { - if (!state) return "Launch one of the built-in themes."; - - switch (state.status) { - case "launching": - return "Theme launch is in progress."; - case "active": - return "Theme is active."; - case "paused": - return "Resume the selected built-in theme."; - case "paused-incompatible": - return "Choose a compatible built-in theme or restore Codex."; - case "recovery-required": - return "Restore Codex before changing themes."; - case "restoring": - return "Restore is in progress."; - case "restored-awaiting-exit": - return "Quit Codex normally to finish restoring."; - case "restored-cleanup-required": - return "Close the remaining local debug listener, then Quit Codex normally."; - } -} - -function statusView(state: RuntimeSessionState | null): RuntimeStatusView { - if (!state) { - return { - status: "stopped", - controllerAvailable: true, - selectedTheme: null, - appliedTheme: null, - skinApplied: false, - packageVersion: null, - operation: null, - nextAction: nextAction(null), - }; - } - - return { - status: state.status, - controllerAvailable: true, - selectedTheme: state.selectedTheme, - appliedTheme: state.appliedTheme, - skinApplied: state.skinApplied, - packageVersion: state.codex?.packageVersion ?? null, - operation: state.pendingOperation?.kind ?? null, - nextAction: nextAction(state), - }; -} - -function themeRef(themeId: string, version: string): RuntimeThemeRef { - return { id: themeId, version }; -} - -function themeLookup(themeId: string, themeVersion?: string): { - readonly id: string; - readonly version?: string; -} { - return themeVersion ? { id: themeId, version: themeVersion } : { id: themeId }; -} - -function isUnavailableThemeVersion(error: unknown): boolean { - const code = runtimeErrorCode(error); - return code === "THEME_NOT_FOUND" || code === "THEME_NOT_READY"; -} - -export class RuntimeController { - private page: RuntimePageSession | null = null; - private lifecycle: RendererLifecycleMonitor | null = null; - private exitMonitor: RuntimeExitMonitor | null = null; - private mutation: Promise = Promise.resolve(); - - constructor(private readonly dependencies: RuntimeControllerDependencies) {} - - async launch( - themeId: string, - requestId: string, - themeVersion?: string, - ): Promise { - return this.runMutation(() => this.launchInternal(themeId, requestId, themeVersion)); - } - - private async launchInternal( - themeId: string, - requestId: string, - themeVersion?: string, - ): Promise { - const existing = await this.dependencies.state.read(); - if (existing) { - throw new RuntimeError( - "RUNTIME_INVALID_STATE", - "A Runtime session already exists", - ); - } - - let launching: RuntimeSessionState | null = null; - let receipt: LaunchReceipt | null = null; - - try { - await this.dependencies.secureRuntimeDirectories(); - const theme = await this.dependencies.themes.load(themeLookup(themeId, themeVersion)); - const selectedTheme = themeRef(theme.descriptor.id, theme.descriptor.version); - const startedAt = this.dependencies.now(); - const pendingOperation: PendingOperation = { - kind: "launch", - requestId, - startedAt, - previousStatus: null, - previousSelectedTheme: null, - previousAppliedTheme: null, - candidateTheme: selectedTheme, - }; - - launching = { - schemaVersion: 2, - sessionId: this.dependencies.newSessionId(), - status: "launching", - runtime: this.dependencies.runtimeIdentity, - codex: null, - cdp: null, - adapter: null, - selectedTheme, - appliedTheme: null, - skinApplied: false, - pendingOperation, - recentRequests: [], - createdAt: startedAt, - updatedAt: startedAt, - }; - await this.dependencies.state.write(launching); - - receipt = await this.dependencies.launchManaged(); - launching = this.withLaunchIdentity(launching, receipt); - await this.writeTransition(launching, launching); - - await this.dependencies.waitForPort(receipt); - receipt = await this.dependencies.activateWindow(receipt); - const initialLaunching = launching; - launching = this.withLaunchIdentity(initialLaunching, receipt); - await this.writeTransition(initialLaunching, launching); - await this.dependencies.waitForPort(receipt); - - this.page = await this.dependencies.connectPage(receipt.cdp); - launching = { - ...launching, - adapter: { id: this.page.adapterId, version: 1 }, - updatedAt: this.dependencies.now(), - }; - await this.writeTransition(launching, launching); - - await this.dependencies.themes.apply(this.page, theme); - const active: RuntimeSessionState = { - ...launching, - status: "active", - appliedTheme: launching.selectedTheme, - skinApplied: true, - pendingOperation: null, - updatedAt: this.dependencies.now(), - }; - await this.writeTransition(launching, active); - this.startLifecycle(this.requirePage()); - return statusView(active); - } catch (error) { - if (!receipt) { - if (launching) await this.dependencies.state.clear(); - throw error; - } - return this.handleLaunchFailure(launching!, receipt, error); - } - } - - async status(): Promise { - return statusView(await this.dependencies.state.read()); - } - - async switchTheme( - themeId: string, - requestId: string, - themeVersion?: string, - ): Promise { - return this.runMutation(() => this.switchThemeInternal(themeId, requestId, themeVersion)); - } - - private async switchThemeInternal( - themeId: string, - requestId: string, - themeVersion?: string, - ): Promise { - let state = await this.themeCommandState(); - this.rejectRestoreTerminal(state); - - if (state.status === "paused" || state.status === "paused-incompatible") { - const theme = await this.dependencies.themes.load(themeLookup(themeId, themeVersion)); - const paused: RuntimeSessionState = { - ...state, - status: "paused", - selectedTheme: themeRef(theme.descriptor.id, theme.descriptor.version), - appliedTheme: null, - skinApplied: false, - updatedAt: this.dependencies.now(), - }; - await this.writeTransition(state, paused); - return statusView(paused); - } - - if (state.status !== "active") { - throw new RuntimeError("RUNTIME_INVALID_STATE", "Theme switching requires an active session"); - } - if (state.selectedTheme.id === themeId && state.appliedTheme?.id === themeId && - (themeVersion === undefined || state.selectedTheme.version === themeVersion)) { - return statusView(state); - } - - const candidate = await this.dependencies.themes.load(themeLookup(themeId, themeVersion)); - const candidateTheme = themeRef(candidate.descriptor.id, candidate.descriptor.version); - await revalidateManagedSession(state, this.dependencies.provider); - state = await this.ensurePage(state); - - const previousApplied = state.appliedTheme; - if (!previousApplied) { - throw new RuntimeError("RUNTIME_SESSION_STALE", "Active state is missing its applied theme"); - } - let previousTheme: LoadedRuntimeTheme | null = null; - try { - previousTheme = await this.dependencies.themes.load(previousApplied); - this.assertThemeVersion(previousTheme, previousApplied); - } catch (error) { - if (!isUnavailableThemeVersion(error)) throw error; - } - - await this.dependencies.themes.preflight(this.requirePage(), candidate); - - const pending = this.withPending(state, "switch", requestId, candidateTheme); - await this.writeTransition(state, pending); - - let officialBeforeCandidate = false; - try { - await this.dependencies.themes.cleanup(this.requirePage()); - officialBeforeCandidate = true; - await this.dependencies.themes.apply(this.requirePage(), candidate); - } catch (candidateError) { - return this.rollbackSwitch( - pending, - previousTheme, - candidateError, - officialBeforeCandidate, - ); - } - - const active: RuntimeSessionState = { - ...pending, - status: "active", - selectedTheme: candidateTheme, - appliedTheme: candidateTheme, - skinApplied: true, - pendingOperation: null, - updatedAt: this.dependencies.now(), - }; - await this.writeTransition(pending, active); - this.startLifecycle(this.requirePage()); - return statusView(active); - } - - async pause(requestId: string): Promise { - return this.runMutation(() => this.pauseInternal(requestId)); - } - - private async pauseInternal(requestId: string): Promise { - let state = await this.themeCommandState(); - this.rejectRestoreTerminal(state); - - if (state.status === "paused" || state.status === "paused-incompatible") { - return statusView(state); - } - if (state.status !== "active") { - throw new RuntimeError("RUNTIME_INVALID_STATE", "Pausing requires an active session"); - } - - const pending = this.withPending(state, "pause", requestId, null); - await this.writeTransition(state, pending); - - let observed = pending; - try { - await revalidateManagedSession(observed, this.dependencies.provider); - observed = await this.ensurePage(observed); - await this.dependencies.themes.cleanup(this.requirePage()); - } catch (error) { - const recovery = this.recoveryRequired(observed); - await this.writeTransition(observed, recovery); - this.stopLifecycle(); - throw error; - } - - const paused: RuntimeSessionState = { - ...observed, - status: "paused", - appliedTheme: null, - skinApplied: false, - pendingOperation: null, - updatedAt: this.dependencies.now(), - }; - await this.writeTransition(observed, paused); - this.startLifecycle(this.requirePage()); - return statusView(paused); - } - - async resume(requestId: string): Promise { - return this.runMutation(() => this.resumeInternal(requestId)); - } - - private async resumeInternal(requestId: string): Promise { - let state = await this.themeCommandState(); - this.rejectRestoreTerminal(state); - - if (state.status === "active") return statusView(state); - if (state.status !== "paused" && state.status !== "paused-incompatible") { - throw new RuntimeError("RUNTIME_INVALID_STATE", "Resuming requires a paused session"); - } - - await revalidateManagedSession(state, this.dependencies.provider); - state = await this.ensurePage(state); - const theme = await this.dependencies.themes.load(state.selectedTheme); - this.assertThemeVersion(theme, state.selectedTheme); - const pending = this.withPending(state, "resume", requestId, state.selectedTheme); - await this.writeTransition(state, pending); - - try { - await this.dependencies.themes.apply(this.requirePage(), theme); - } catch (error) { - return this.degradeResume(pending, error); - } - - const active: RuntimeSessionState = { - ...pending, - status: "active", - appliedTheme: pending.selectedTheme, - skinApplied: true, - pendingOperation: null, - updatedAt: this.dependencies.now(), - }; - await this.writeTransition(pending, active); - this.startLifecycle(this.requirePage()); - return statusView(active); - } - - async restore(requestId: string): Promise { - return this.runMutation(() => this.restoreInternal(requestId)); - } - - private async restoreInternal(_requestId: string): Promise { - let state = await this.themeCommandState(); - if (state.status === "restored-awaiting-exit" || - state.status === "restored-cleanup-required") { - return statusView(state); - } - if (![ - "active", - "paused", - "paused-incompatible", - "recovery-required", - ].includes(state.status)) { - throw new RuntimeError("RUNTIME_INVALID_STATE", "Restore requires a managed Runtime session"); - } - - const previous = state; - const pending = this.withPending(state, "restore", _requestId, null); - state = { - ...pending, - status: "restoring", - updatedAt: this.dependencies.now(), - }; - await this.writeTransition(previous, state); - this.stopLifecycle(); - - if (previous.skinApplied !== false) { - try { - if (!await hasManagedSessionExited(state, this.dependencies.provider)) { - await revalidateManagedSession(state, this.dependencies.provider); - state = await this.ensurePage(state); - await this.dependencies.themes.cleanup(this.requirePage()); - } - } catch (error) { - return this.restoreFailure(state, previous, error); - } - } - - const restored = this.restoredAwaitingExit(state); - await this.writeTransition(state, restored); - this.closePage(); - return statusView(restored); - } - - async close(): Promise { - this.exitMonitor?.stop(); - this.exitMonitor = null; - this.closePage(); - } - - attachRecoveredPage(page: RuntimePageSession): void { - this.closePage(); - this.page = page; - this.startLifecycle(page); - } - - async startExitMonitoring(): Promise { - const session = await this.dependencies.state.read(); - if (!session || (session.status !== "restored-awaiting-exit" && - session.status !== "restored-cleanup-required")) { - throw new RuntimeError( - "RUNTIME_INVALID_STATE", - "Exit monitoring requires a restored Runtime session", - ); - } - const monitor = this.exitMonitor ?? this.dependencies.createExitMonitor?.() ?? - new ExitMonitor({ - provider: this.dependencies.provider, - state: this.dependencies.state, - onStopped: this.dependencies.onStopped, - }); - this.exitMonitor = monitor; - monitor.start(session); - } - - private withLaunchIdentity( - state: RuntimeSessionState, - receipt: LaunchReceipt, - ): RuntimeSessionState { - return { - ...state, - codex: { - rootPid: receipt.root.pid, - startedAt: receipt.root.startedAt, - executablePath: receipt.root.executablePath, - packageRoot: receipt.install.packageRoot, - packageVersion: receipt.install.packageVersion, - }, - cdp: receipt.cdp, - updatedAt: this.dependencies.now(), - }; - } - - private async handleLaunchFailure( - launching: RuntimeSessionState, - receipt: LaunchReceipt, - originalError: unknown, - ): Promise { - const identified = launching.codex && launching.cdp - ? launching - : this.withLaunchIdentity(launching, receipt); - - if (!this.page) { - const restored = this.restoredAwaitingExit(identified); - await this.writeTransition(identified, restored); - throw originalError; - } - - try { - await this.dependencies.themes.cleanup(this.page); - } catch (cleanupError) { - const recovery = this.recoveryRequired(identified); - await this.writeTransition(identified, recovery); - this.closePage(); - throw this.cleanupFailure(cleanupError); - } - - const restored = this.restoredAwaitingExit(identified); - await this.writeTransition(identified, restored); - this.closePage(); - throw originalError; - } - - private restoredAwaitingExit(state: RuntimeSessionState): RuntimeSessionState { - return { - ...state, - status: "restored-awaiting-exit", - appliedTheme: null, - skinApplied: false, - pendingOperation: null, - updatedAt: this.dependencies.now(), - }; - } - - private recoveryRequired(state: RuntimeSessionState): RuntimeSessionState { - return { - ...state, - status: "recovery-required", - appliedTheme: null, - skinApplied: null, - pendingOperation: null, - updatedAt: this.dependencies.now(), - }; - } - - private async writeTransition( - current: RuntimeSessionState, - next: RuntimeSessionState, - ): Promise { - await this.dependencies.state.write(transitionRuntimeState(current, next)); - } - - private async restoreFailure( - restoring: RuntimeSessionState, - previous: RuntimeSessionState, - cleanupError: unknown, - ): Promise { - let previousAppearanceVerified = false; - if (previous.status === "active" && previous.skinApplied === true && this.page) { - try { - previousAppearanceVerified = (await this.page.adapter.verify()).valid; - } catch { - // A failed probe cannot prove that the previous themed appearance survived. - previousAppearanceVerified = false; - } - } - - if (previousAppearanceVerified) { - const restored: RuntimeSessionState = { - ...previous, - pendingOperation: null, - updatedAt: this.dependencies.now(), - }; - await this.writeTransition(restoring, restored); - this.startLifecycle(this.requirePage()); - throw cleanupError; - } - - const recovery = this.recoveryRequired(restoring); - await this.writeTransition(restoring, recovery); - this.closePage(); - throw this.cleanupFailure(cleanupError); - } - - private cleanupFailure(cause: unknown): RuntimeError { - return this.operationFailure( - "THEME_CLEANUP_FAILED", - "Official Codex appearance could not be verified", - cause, - ); - } - - private async themeCommandState(): Promise { - const state = await this.dependencies.state.read(); - if (!state) { - throw new RuntimeError("RUNTIME_INVALID_STATE", "No Runtime session exists"); - } - return state; - } - - private rejectRestoreTerminal(state: RuntimeSessionState): void { - if (state.status === "restored-awaiting-exit" || - state.status === "restored-cleanup-required") { - throw new RuntimeError( - "RESTORE_AWAITING_EXIT", - "Codex must exit normally before changing themes", - ); - } - } - - private withPending( - state: RuntimeSessionState, - kind: PendingOperation["kind"], - requestId: string, - candidateTheme: RuntimeThemeRef | null, - ): RuntimeSessionState { - return { - ...state, - pendingOperation: { - kind, - requestId, - startedAt: this.dependencies.now(), - previousStatus: state.status, - previousSelectedTheme: state.selectedTheme, - previousAppliedTheme: state.appliedTheme, - candidateTheme, - }, - updatedAt: this.dependencies.now(), - }; - } - - private async ensurePage(state: RuntimeSessionState): Promise { - if (this.page) return state; - if (!state.cdp) { - throw new RuntimeError("RUNTIME_SESSION_STALE", "Managed CDP identity is incomplete"); - } - - this.page = await this.dependencies.connectPage(state.cdp); - const connected: RuntimeSessionState = { - ...state, - adapter: { id: this.page.adapterId, version: 1 }, - updatedAt: this.dependencies.now(), - }; - await this.writeTransition(state, connected); - return connected; - } - - private requirePage(): RuntimePageSession { - if (!this.page) { - throw new RuntimeError("RUNTIME_SESSION_STALE", "Managed page is unavailable"); - } - return this.page; - } - - private assertThemeVersion( - theme: LoadedRuntimeTheme, - expected: RuntimeThemeRef, - ): void { - if (theme.descriptor.id !== expected.id || theme.descriptor.version !== expected.version) { - throw new RuntimeError("THEME_NOT_READY", "Runtime theme version changed"); - } - } - - private async rollbackSwitch( - pending: RuntimeSessionState, - previousTheme: LoadedRuntimeTheme | null, - candidateError: unknown, - officialBeforeCandidate: boolean, - ): Promise { - const page = this.requirePage(); - const operation = pending.pendingOperation; - if (!operation?.previousSelectedTheme || !operation.previousAppliedTheme) { - throw new RuntimeError("RUNTIME_SESSION_STALE", "Switch rollback is missing its prior theme"); - } - let rollbackError: unknown | null = null; - if (officialBeforeCandidate && previousTheme) { - try { - await this.dependencies.themes.cleanup(page); - await this.dependencies.themes.apply(page, previousTheme); - } catch (error) { - rollbackError = error; - } - } else { - rollbackError = candidateError; - } - - if (!rollbackError) { - const restored: RuntimeSessionState = { - ...pending, - status: "active", - selectedTheme: operation.previousSelectedTheme, - appliedTheme: operation.previousAppliedTheme, - skinApplied: true, - pendingOperation: null, - updatedAt: this.dependencies.now(), - }; - await this.writeTransition(pending, restored); - this.startLifecycle(page); - throw this.operationFailure( - "THEME_SWITCH_FAILED", - "The requested theme could not be applied", - candidateError, - ); - } - - const verificationError = await this.cleanupToOfficial(page); - if (!verificationError) { - const paused: RuntimeSessionState = { - ...pending, - status: "paused-incompatible", - selectedTheme: operation.previousSelectedTheme, - appliedTheme: null, - skinApplied: false, - pendingOperation: null, - updatedAt: this.dependencies.now(), - }; - await this.writeTransition(pending, paused); - this.stopLifecycle(); - } else { - const recovery = this.recoveryRequired(pending); - await this.writeTransition(pending, recovery); - this.stopLifecycle(); - } - throw this.operationFailure( - "THEME_ROLLBACK_FAILED", - "The previous Runtime theme could not be restored", - rollbackError, - ); - } - - private async degradeResume( - pending: RuntimeSessionState, - originalError: unknown, - ): Promise { - const verificationError = runtimeErrorCode(originalError) === "THEME_CLEANUP_FAILED" - ? originalError - : await this.cleanupToOfficial(this.requirePage()); - if (verificationError) { - const recovery = this.recoveryRequired(pending); - await this.writeTransition(pending, recovery); - this.stopLifecycle(); - if (verificationError === originalError) throw originalError; - throw this.cleanupFailure(verificationError); - } - - const paused: RuntimeSessionState = { - ...pending, - status: "paused-incompatible", - appliedTheme: null, - skinApplied: false, - pendingOperation: null, - updatedAt: this.dependencies.now(), - }; - await this.writeTransition(pending, paused); - this.stopLifecycle(); - throw originalError; - } - - private async cleanupToOfficial(page: RuntimePageSession): Promise { - try { - await this.dependencies.themes.cleanup(page); - return null; - } catch (error) { - // Cleanup failure is deliberately retained for state classification, not ignored. - return error; - } - } - - private operationFailure( - code: RuntimeErrorCode, - message: string, - cause: unknown, - ): RuntimeError { - const error = new RuntimeError(code, message, "Restore Codex before changing themes."); - Object.defineProperty(error, "cause", { - configurable: false, - enumerable: false, - value: cause, - }); - return error; - } - - private startLifecycle(page: RuntimePageSession): void { - if (this.lifecycle) { - this.lifecycle.replace(page.connection); - return; - } - this.lifecycle = new RendererLifecycleMonitor({ - reconcile: () => this.reconcileRenderer(), - reconnect: () => this.reconnectRenderer(), - }); - this.lifecycle.start(page.connection); - } - - private stopLifecycle(): void { - this.lifecycle?.stop(); - this.lifecycle = null; - } - - private async reconcileRenderer(): Promise { - await this.runMutation(async () => { - const state = await this.dependencies.state.read(); - if (!state || this.shouldStopLifecycle(state)) { - this.stopLifecycle(); - return; - } - if (state.pendingOperation || state.status === "paused" || - state.status === "paused-incompatible") { - return; - } - if (state.status !== "active") { - this.stopLifecycle(); - return; - } - - let officialAppearance = false; - try { - await revalidateManagedSession(state, this.dependencies.provider); - const observed = await this.ensurePage(state); - const theme = await this.dependencies.themes.load(observed.selectedTheme); - this.assertThemeVersion(theme, observed.selectedTheme); - await this.dependencies.themes.cleanup(this.requirePage()); - officialAppearance = true; - await this.dependencies.themes.apply(this.requirePage(), theme); - const active: RuntimeSessionState = { - ...observed, - status: "active", - appliedTheme: observed.selectedTheme, - skinApplied: true, - pendingOperation: null, - updatedAt: this.dependencies.now(), - }; - await this.writeTransition(observed, active); - } catch (error) { - await this.recordRendererFailure(error, officialAppearance); - } - }); - } - - private reconnectRenderer(): Promise { - return this.runMutation(async () => { - let state: RuntimeSessionState | null = null; - let previousPage: RuntimePageSession | null = null; - try { - state = await this.dependencies.state.read(); - if (!state || this.shouldStopLifecycle(state) || state.pendingOperation) { - this.stopLifecycle(); - throw new RuntimeError("RUNTIME_INVALID_STATE", "Renderer reconnect is not permitted"); - } - if (!state.cdp) { - throw new RuntimeError("RUNTIME_SESSION_STALE", "Managed CDP identity is incomplete"); - } - - await revalidateManagedSession(state, this.dependencies.provider); - previousPage = this.page; - this.page = null; - const page = await this.dependencies.connectPage(state.cdp); - this.page = page; - const observed: RuntimeSessionState = { - ...state, - adapter: { id: page.adapterId, version: 1 }, - updatedAt: this.dependencies.now(), - }; - await this.writeTransition(state, observed); - return page; - } catch (error) { - let wasOfficial = state?.status === "paused" || - state?.status === "paused-incompatible"; - if (!wasOfficial && runtimeErrorCode(error) === "ADAPTER_INCOMPATIBLE" && - previousPage) { - wasOfficial = (await this.cleanupToOfficial(previousPage)) === null; - } - await this.recordRendererFailure(error, wasOfficial); - previousPage?.close(); - throw error; - } - }); - } - - private async recordRendererFailure( - error: unknown, - officialAppearance = false, - ): Promise { - const state = await this.dependencies.state.read(); - if (!state || this.shouldStopLifecycle(state)) { - this.stopLifecycle(); - return; - } - - if (runtimeErrorCode(error) === "ADAPTER_INCOMPATIBLE" && officialAppearance) { - const paused: RuntimeSessionState = { - ...state, - status: "paused-incompatible", - appliedTheme: null, - skinApplied: false, - pendingOperation: null, - updatedAt: this.dependencies.now(), - }; - await this.writeTransition(state, paused); - } else { - const recovery = this.recoveryRequired(state); - await this.writeTransition(state, recovery); - } - this.closePage(); - } - - private shouldStopLifecycle(state: RuntimeSessionState): boolean { - return state.status === "restoring" || state.status === "recovery-required" || - state.status === "restored-awaiting-exit" || - state.status === "restored-cleanup-required"; - } - - private async runMutation(operation: () => Promise): Promise { - const previous = this.mutation; - let release!: () => void; - this.mutation = new Promise((resolve) => { release = resolve; }); - await previous; - try { - return await operation(); - } finally { - release(); - } - } - - private closePage(): void { - this.stopLifecycle(); - const page = this.page; - this.page = null; - page?.close(); - } - -} diff --git a/runtime/windows/src/controller/theme-engine.ts b/runtime/windows/src/controller/theme-engine.ts deleted file mode 100644 index c55825f..0000000 --- a/runtime/windows/src/controller/theme-engine.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { RuntimeThemeError } from "@open-chatgpt-skin/cdp-adapter"; -import { RuntimeError, isRuntimeErrorCode } from "../errors.js"; -import { - RuntimeThemeRepository, - type LoadedRuntimeTheme, - type RuntimeThemeLookup, -} from "../themes/runtime-theme-repository.js"; -import type { RuntimePageSession } from "./page-session.js"; - -function runtimeFailure(error: unknown): RuntimeError { - if (error instanceof RuntimeError) return error; - const code = error instanceof RuntimeThemeError && isRuntimeErrorCode(error.code) - ? error.code - : "INTERNAL"; - const mapped = new RuntimeError(code, "Theme operation failed"); - Object.defineProperty(mapped, "cause", { - configurable: false, - enumerable: false, - value: error, - }); - return mapped; -} - -export class ThemeEngine { - constructor(private readonly repository: RuntimeThemeRepository) {} - - load(theme: RuntimeThemeLookup): Promise { - return this.repository.load(theme); - } - - async preflight(session: RuntimePageSession, theme: LoadedRuntimeTheme): Promise { - try { - const result = await session.adapter.preflight(theme.compiled); - if (!result.welcomeSupported) { - throw new RuntimeThemeError( - "THEME_HOME_WELCOME_UNSUPPORTED", - "Active home welcome surface is unsupported", - ); - } - if (!result.requiredLayersResolved) { - throw new RuntimeThemeError( - "THEME_REQUIRED_LAYER_UNRESOLVED", - "A required visual layer surface is unresolved", - ); - } - if (!result.valid) { - throw new RuntimeThemeError("THEME_VERIFY_FAILED", "Theme preflight failed"); - } - } catch (error) { - throw runtimeFailure(error); - } - } - - async apply(session: RuntimePageSession, theme: LoadedRuntimeTheme): Promise { - try { - await session.adapter.apply(theme.compiled); - const verification = await session.adapter.verify(); - if (!verification.valid) { - throw new RuntimeError("THEME_VERIFY_FAILED", "Theme verification failed"); - } - } catch (error) { - throw runtimeFailure(error); - } - } - - async cleanup(session: RuntimePageSession): Promise { - try { - await session.adapter.remove(); - const official = await session.adapter.verifyOfficialAppearance(); - if (!official.valid) { - throw new RuntimeError("THEME_CLEANUP_FAILED", "Official appearance is unverified"); - } - } catch (error) { - throw runtimeFailure(error); - } - } -} diff --git a/runtime/windows/src/discovery/discover.ts b/runtime/windows/src/discovery/discover.ts deleted file mode 100644 index 3038ebc..0000000 --- a/runtime/windows/src/discovery/discover.ts +++ /dev/null @@ -1,236 +0,0 @@ -import { RuntimeError } from "../errors.js"; -import type { - DiscoveryResult, - InstallInspection, - ProcessIdentity, - VerifiedCodexInstall, - DesktopRuntimeProvider, -} from "../types.js"; -import { - MACOS_CODEX_BUNDLE_ID, - MACOS_CODEX_ENTRY_POINT, - MACOS_CODEX_IDENTITY_NAME, - MACOS_CODEX_NOTARIZATION_AUTHORITY, - MACOS_CODEX_RESOURCE_SIGNER, - MACOS_CODEX_TEAM_ID, -} from "../macos/identity.js"; -import type { TrustedCodexInstall } from "./trusted-cache.js"; -import { TrustedInstallStore } from "./trusted-cache.js"; - -const EXPECTED_IDENTITY = "OpenAI.Codex"; -const EXPECTED_ENTRY = "app/ChatGPT.exe"; -const EXPECTED_ENTRY_POINT = "Windows.FullTrustApplication"; -const APPROVED_PUBLISHERS = new Set([ - "CN=50BDFD77-8903-4850-9FFE-6E8522F64D5B", -]); -const EXPECTED_PACKAGE_SIGNER = "50BDFD77-8903-4850-9FFE-6E8522F64D5B"; -const APPROVED_RESOURCE_SIGNERS = new Set(["OpenAI OpCo, LLC"]); -const ENTRY_SUFFIX = `/${EXPECTED_ENTRY}`; - -interface Candidate { - readonly source: DiscoveryResult["source"]; - readonly install: VerifiedCodexInstall; - readonly runningRoot: ProcessIdentity | null; -} - -function normalizePath(value: string): string { - return value.replaceAll("\\", "/").replace(/\/+$/, ""); -} - -function packageRootFromEntryPath( - entryPath: string, - platform: DesktopRuntimeProvider["platform"], -): string { - const normalized = normalizePath(entryPath); - if (platform === "darwin") { - const match = /^(.*\/Codex\.app)\/Contents\/MacOS\/[^/]+$/.exec(normalized); - if (!match) { - throw new RuntimeError( - "CODEX_IDENTITY_INVALID", - "running Codex path is outside Codex.app/Contents/MacOS", - ); - } - return match[1]!; - } - if (!normalized.toLowerCase().endsWith(ENTRY_SUFFIX.toLowerCase())) { - throw new RuntimeError( - "CODEX_IDENTITY_INVALID", - `running ChatGPT path does not end with ${ENTRY_SUFFIX}`, - ); - } - return normalized.slice(0, -ENTRY_SUFFIX.length); -} - -function verifyWindowsInspection(value: InstallInspection): VerifiedCodexInstall { - const packageRoot = normalizePath(value.packageRoot); - const entryPath = normalizePath(value.entryPath); - const entryRelativePath = normalizePath(value.entryRelativePath).replace(/^\//, ""); - const expectedEntryPath = `${packageRoot}/${EXPECTED_ENTRY}`; - const valid = value.identityName === EXPECTED_IDENTITY && - /^\d+\.\d+\.\d+\.\d+$/.test(value.packageVersion) && - APPROVED_PUBLISHERS.has(value.packagePublisher) && - value.appId === "App" && - entryRelativePath === EXPECTED_ENTRY && - value.entryPoint === EXPECTED_ENTRY_POINT && - value.packageSignatureStatus === "Valid" && - value.packageSignerCommonName === EXPECTED_PACKAGE_SIGNER && - value.catalogSignatureStatus === "Valid" && - value.catalogSignerCommonName === EXPECTED_PACKAGE_SIGNER && - value.entryBlockMapValid && - value.resourceSignatureStatus === "Valid" && - APPROVED_RESOURCE_SIGNERS.has(value.resourceSignerCommonName) && - entryPath.toLowerCase() === expectedEntryPath.toLowerCase(); - if (!valid) { - throw new RuntimeError( - "CODEX_IDENTITY_INVALID", - "Codex Manifest, package signature, block map, or resource signer is invalid", - ); - } - return { - ...value, - packageRoot, - entryPath, - entryRelativePath: EXPECTED_ENTRY, - }; -} - -function verifyMacOsInspection(value: InstallInspection): VerifiedCodexInstall { - const packageRoot = normalizePath(value.packageRoot); - const entryPath = normalizePath(value.entryPath); - const entryRelativePath = normalizePath(value.entryRelativePath).replace(/^\//, ""); - const validEntry = /^Contents\/MacOS\/[^/]+$/.test(entryRelativePath); - const expectedEntryPath = `${packageRoot}/${entryRelativePath}`; - const valid = value.identityName === MACOS_CODEX_IDENTITY_NAME && - /^\d+\.\d+\.\d+\.\d+$/.test(value.packageVersion) && - value.packagePublisher === MACOS_CODEX_TEAM_ID && - value.appId === MACOS_CODEX_BUNDLE_ID && - validEntry && - value.entryPoint === MACOS_CODEX_ENTRY_POINT && - value.packageSignatureStatus === "Valid" && - value.packageSignerCommonName === MACOS_CODEX_TEAM_ID && - value.catalogSignatureStatus === "Valid" && - value.catalogSignerCommonName === MACOS_CODEX_NOTARIZATION_AUTHORITY && - value.entryBlockMapValid && - value.resourceSignatureStatus === "Valid" && - value.resourceSignerCommonName === MACOS_CODEX_RESOURCE_SIGNER && - packageRoot.endsWith("/Codex.app") && - entryPath === expectedEntryPath; - if (!valid) { - throw new RuntimeError( - "CODEX_IDENTITY_INVALID", - "Codex bundle identifier, code signature, notarization, or entry point is invalid", - ); - } - return { ...value, packageRoot, entryPath, entryRelativePath }; -} - -function verifyInspection( - value: InstallInspection, - platform: DesktopRuntimeProvider["platform"], -): VerifiedCodexInstall { - return platform === "darwin" - ? verifyMacOsInspection(value) - : verifyWindowsInspection(value); -} - -function installKey( - value: VerifiedCodexInstall, - platform: DesktopRuntimeProvider["platform"], -): string { - const pathKey = (path: string) => platform === "darwin" ? path : path.toLowerCase(); - return [ - pathKey(value.packageRoot), - pathKey(value.entryPath), - value.identityName, - value.packageVersion, - value.packagePublisher, - value.packageSignerCommonName, - value.catalogSignerCommonName, - String(value.entryBlockMapValid), - value.resourceSignerCommonName, - ].join("|"); -} - -async function inspect( - provider: DesktopRuntimeProvider, - source: DiscoveryResult["source"], - packageRoot: string, - runningRoot: ProcessIdentity | null, -): Promise { - return { - source, - install: verifyInspection(await provider.inspectInstall(packageRoot), provider.platform), - runningRoot, - }; -} - -function cachedRoot(value: TrustedCodexInstall): string { - return value.packageRoot; -} - -export async function discoverCodexInstall( - provider: DesktopRuntimeProvider, - cache: TrustedInstallStore, -): Promise { - const candidates: Candidate[] = []; - const running = await provider.listCodexRoots(); - if (running.length > 1) { - throw new RuntimeError( - "CODEX_IDENTITY_INVALID", - "multiple unmanaged Codex root processes were found", - ); - } - const runningRoot = running[0]; - if (runningRoot) { - candidates.push(await inspect( - provider, - "running", - packageRootFromEntryPath(runningRoot.executablePath, provider.platform), - runningRoot, - )); - } - - const registeredRoots = [...new Set(await provider.currentUserPackageRoots())]; - const registeredSource = provider.platform === "darwin" ? "application" : "appx"; - for (const packageRoot of registeredRoots) { - candidates.push(await inspect(provider, registeredSource, packageRoot, null)); - } - - if (candidates.length === 0) { - const cached = await cache.read(); - if (cached) { - candidates.push(await inspect(provider, "cache", cachedRoot(cached), null)); - } - } - - if (candidates.length === 0) { - throw new RuntimeError( - "CODEX_DISCOVERY_REQUIRES_BOOTSTRAP", - "No readable official Codex registration or trusted install cache exists", - "Open official Codex once, then retry OpenChatGPTSkin Launcher.", - ); - } - - const identities = new Set(candidates.map((candidate) => - installKey(candidate.install, provider.platform) - )); - if (identities.size !== 1) { - throw new RuntimeError( - "CODEX_IDENTITY_INVALID", - "Codex discovery sources disagree about the official installation", - ); - } - - const chosen = candidates.find((candidate) => candidate.source === "running") ?? - candidates.find((candidate) => candidate.source === registeredSource) ?? - candidates[0]; - if (!chosen) { - throw new RuntimeError("CODEX_IDENTITY_INVALID", "verified candidate disappeared"); - } - await cache.write(chosen.install); - return { - install: chosen.install, - source: chosen.source, - runningRoot: chosen.runningRoot, - }; -} diff --git a/runtime/windows/src/discovery/trusted-cache.ts b/runtime/windows/src/discovery/trusted-cache.ts deleted file mode 100644 index aca1da7..0000000 --- a/runtime/windows/src/discovery/trusted-cache.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { randomUUID } from "node:crypto"; -import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; -import { dirname, join } from "node:path"; -import { z } from "zod"; -import type { VerifiedCodexInstall } from "../types.js"; -import { - MACOS_CODEX_BUNDLE_ID, - MACOS_CODEX_ENTRY_POINT, - MACOS_CODEX_IDENTITY_NAME, - MACOS_CODEX_NOTARIZATION_AUTHORITY, - MACOS_CODEX_RESOURCE_SIGNER, - MACOS_CODEX_TEAM_ID, -} from "../macos/identity.js"; - -export const TrustedWindowsCodexInstallSchema = z.object({ - schemaVersion: z.literal(1), - packageRoot: z.string().min(1), - entryPath: z.string().min(1), - identityName: z.literal(MACOS_CODEX_IDENTITY_NAME), - packageVersion: z.string().regex(/^\d+\.\d+\.\d+\.\d+$/), - packagePublisher: z.string().min(1), - appId: z.literal("App"), - entryRelativePath: z.literal("app/ChatGPT.exe"), - entryPoint: z.literal("Windows.FullTrustApplication"), - packageSignatureStatus: z.literal("Valid"), - packageSignerCommonName: z.literal("50BDFD77-8903-4850-9FFE-6E8522F64D5B"), - catalogSignatureStatus: z.literal("Valid"), - catalogSignerCommonName: z.literal("50BDFD77-8903-4850-9FFE-6E8522F64D5B"), - entryBlockMapValid: z.literal(true), - resourceSignatureStatus: z.literal("Valid"), - resourceSignerCommonName: z.literal("OpenAI OpCo, LLC"), - verifiedAt: z.string().datetime(), -}).strict(); - -export const TrustedMacOsCodexInstallSchema = z.object({ - schemaVersion: z.literal(1), - packageRoot: z.string().endsWith("/Codex.app"), - entryPath: z.string().min(1), - identityName: z.literal("OpenAI.Codex"), - packageVersion: z.string().regex(/^\d+\.\d+\.\d+\.\d+$/), - packagePublisher: z.literal(MACOS_CODEX_TEAM_ID), - appId: z.literal(MACOS_CODEX_BUNDLE_ID), - entryRelativePath: z.string().regex(/^Contents\/MacOS\/[^/]+$/), - entryPoint: z.literal(MACOS_CODEX_ENTRY_POINT), - packageSignatureStatus: z.literal("Valid"), - packageSignerCommonName: z.literal(MACOS_CODEX_TEAM_ID), - catalogSignatureStatus: z.literal("Valid"), - catalogSignerCommonName: z.literal(MACOS_CODEX_NOTARIZATION_AUTHORITY), - entryBlockMapValid: z.literal(true), - resourceSignatureStatus: z.literal("Valid"), - resourceSignerCommonName: z.literal(MACOS_CODEX_RESOURCE_SIGNER), - verifiedAt: z.string().datetime(), -}).strict(); - -export const TrustedCodexInstallSchema = z.union([ - TrustedWindowsCodexInstallSchema, - TrustedMacOsCodexInstallSchema, -]); - -export type TrustedCodexInstall = z.infer; - -export class TrustedInstallStore { - constructor(private readonly path: string) {} - - async read(): Promise { - let text: string; - try { - text = await readFile(this.path, "utf8"); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; - throw error; - } - - try { - return TrustedCodexInstallSchema.parse(JSON.parse(text)); - } catch { - const quarantine = join( - dirname(this.path), - `trusted-codex.invalid-${Date.now()}-${randomUUID()}.json`, - ); - await rename(this.path, quarantine); - return null; - } - } - - async write(install: VerifiedCodexInstall): Promise { - const value = TrustedCodexInstallSchema.parse({ - schemaVersion: 1, - ...install, - verifiedAt: new Date().toISOString(), - }); - await mkdir(dirname(this.path), { recursive: true }); - const temporary = `${this.path}.${process.pid}-${randomUUID()}.tmp`; - await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, "utf8"); - await rename(temporary, this.path); - } -} diff --git a/runtime/windows/src/errors.ts b/runtime/windows/src/errors.ts deleted file mode 100644 index 2f764f9..0000000 --- a/runtime/windows/src/errors.ts +++ /dev/null @@ -1,92 +0,0 @@ -export const RUNTIME_THEME_PACKAGE_ERROR_CODES = [ - "THEME_SCHEMA_VERSION_UNSUPPORTED", - "THEME_WELCOME_INVALID", - "THEME_DISPLAY_FONT_MISSING", - "THEME_COMPOSITION_INVALID", -] as const; - -export const RUNTIME_THEME_SURFACE_ERROR_CODES = [ - "THEME_HOME_WELCOME_UNSUPPORTED", - "THEME_REQUIRED_LAYER_UNRESOLVED", -] as const; - -export const RUNTIME_ERROR_CODES = [ - "CODEX_NOT_INSTALLED", - "CODEX_DISCOVERY_REQUIRES_BOOTSTRAP", - "CODEX_IDENTITY_INVALID", - "CODEX_ALREADY_RUNNING_UNMANAGED", - "CODEX_LAUNCH_FAILED", - "CODEX_WINDOW_ACTIVATION_FAILED", - "PROCESS_INSPECTION_DENIED", - "CDP_NOT_READY", - "CDP_ENDPOINT_UNSAFE", - "CDP_PROCESS_MISMATCH", - "CDP_TARGET_NOT_FOUND", - "CDP_TARGET_AMBIGUOUS", - "ADAPTER_INCOMPATIBLE", - "THEME_APPLY_FAILED", - "THEME_VERIFY_FAILED", - "THEME_CLEANUP_FAILED", - "THEME_SWITCH_FAILED", - "THEME_ROLLBACK_FAILED", - "THEME_RUNTIME_TOO_LARGE", - ...RUNTIME_THEME_PACKAGE_ERROR_CODES, - ...RUNTIME_THEME_SURFACE_ERROR_CODES, - "THEME_NOT_FOUND", - "THEME_NOT_READY", - "RUNTIME_SESSION_STALE", - "RUNTIME_INVALID_STATE", - "RUNTIME_BUSY", - "RUNTIME_ENVIRONMENT_INVALID", - "RUNTIME_CONTROL_UNAVAILABLE", - "RESTORE_AWAITING_EXIT", - "PROBE_EXIT_PENDING", - "PROBE_FINALIZE_REQUIRED", - "PROBE_PENDING_SESSION_INVALID", - "INTERNAL", -] as const; - -export type RuntimeErrorCode = typeof RUNTIME_ERROR_CODES[number]; - -function includesRuntimeCode( - codes: readonly string[], - value: RuntimeErrorCode, -): boolean { - return codes.includes(value); -} - -export function isRuntimeThemePackageErrorCode(value: RuntimeErrorCode): boolean { - return includesRuntimeCode(RUNTIME_THEME_PACKAGE_ERROR_CODES, value); -} - -export function isRuntimeThemeSurfaceErrorCode(value: RuntimeErrorCode): boolean { - return includesRuntimeCode(RUNTIME_THEME_SURFACE_ERROR_CODES, value); -} - -export function isRuntimeThemeV4ErrorCode(value: RuntimeErrorCode): boolean { - return isRuntimeThemePackageErrorCode(value) || isRuntimeThemeSurfaceErrorCode(value); -} - -export function isRuntimeErrorCode(value: unknown): value is RuntimeErrorCode { - return typeof value === "string" && - (RUNTIME_ERROR_CODES as readonly string[]).includes(value); -} - -export function runtimeErrorCode(error: unknown): RuntimeErrorCode { - if (error && typeof error === "object" && "code" in error && - isRuntimeErrorCode((error as { readonly code?: unknown }).code)) { - return (error as { readonly code: RuntimeErrorCode }).code; - } - return "INTERNAL"; -} - -export class RuntimeError extends Error { - constructor( - public readonly code: RuntimeErrorCode, - message: string, - public readonly nextAction?: string, - ) { - super(message); - this.name = "RuntimeError"; - } -} diff --git a/runtime/windows/src/index.ts b/runtime/windows/src/index.ts deleted file mode 100644 index 307e723..0000000 --- a/runtime/windows/src/index.ts +++ /dev/null @@ -1,49 +0,0 @@ -export * from "./discovery/discover.js"; -export * from "./discovery/trusted-cache.js"; -export * from "./errors.js"; -export * from "./control/framing.js"; -export * from "./control/pipe-client.js"; -export * from "./control/pipe-host-script.js"; -export * from "./control/pipe-server.js"; -export * from "./control/secure-control-server.js"; -export * from "./control/unix-socket-server.js"; -export * from "./control/protocol.js"; -export * from "./control/result.js"; -export * from "./control/dispatcher.js"; -export * from "./control/controller-lock.js"; -export * from "./cli/arguments.js"; -export * from "./cli/run.js"; -export * from "./controller/managed-session.js"; -export * from "./controller/exit-monitor.js"; -export * from "./controller/page-session.js"; -export * from "./controller/renderer-lifecycle.js"; -export * from "./controller/recovery.js"; -export * from "./controller/production.js"; -export * from "./controller/runtime-controller.js"; -export * from "./controller/theme-engine.js"; -export * from "./acceptance-sequence.js"; -export * from "./acceptance-evidence.js"; -export * from "./acceptance-runner.js"; -export * from "./acceptance-session.js"; -export * from "./launcher/launcher.js"; -export * from "./launcher/port.js"; -export * from "./logging.js"; -export * from "./paths.js"; -export * from "./probe-command.js"; -export * from "./probe-evidence.js"; -export * from "./probe-finalize.js"; -export * from "./probe-phase-one.js"; -export * from "./probe-session.js"; -export * from "./session/model.js"; -export * from "./session/state-machine.js"; -export * from "./state.js"; -export * from "./themes/ids.js"; -export * from "./themes/runtime-theme-repository.js"; -export * from "./types.js"; -export * from "./macos/identity.js"; -export * from "./macos/macos-provider.js"; -export * from "./platform/provider-factory.js"; -export * from "./windows/command-runner.js"; -export * from "./windows/powershell-provider.js"; -export * from "./windows/powershell-path.js"; -export * from "./windows/powershell-script.js"; diff --git a/runtime/windows/src/launcher/launcher.ts b/runtime/windows/src/launcher/launcher.ts deleted file mode 100644 index afb706d..0000000 --- a/runtime/windows/src/launcher/launcher.ts +++ /dev/null @@ -1,261 +0,0 @@ -import { RuntimeError } from "../errors.js"; -import { discoverCodexInstall } from "../discovery/discover.js"; -import type { TrustedInstallStore } from "../discovery/trusted-cache.js"; -import type { - PortInspection, - ProcessIdentity, - VerifiedCodexInstall, - DesktopRuntimeProvider, -} from "../types.js"; -import { pickLoopbackPort } from "./port.js"; - -export interface LaunchReceipt { - readonly install: VerifiedCodexInstall; - readonly root: ProcessIdentity; - readonly cdp: { - readonly host: "127.0.0.1"; - readonly port: number; - }; - readonly launchedAt: string; -} - -export interface LaunchManagedCodexOptions { - readonly provider: DesktopRuntimeProvider; - readonly cache: TrustedInstallStore; - readonly allocatePort?: () => Promise; -} - -export interface LaunchReadinessOptions { - readonly timeoutMs?: number; - readonly intervalMs?: number; -} - -const DEFAULT_PORT_READY_TIMEOUT_MS = 10_000; -const DEFAULT_WINDOW_ACTIVATION_TIMEOUT_MS = 20_000; -const DIRECT_LAUNCH_WINDOW_GRACE_MS = 5_000; - -function readinessBounds( - options: LaunchReadinessOptions, - defaultTimeoutMs = DEFAULT_PORT_READY_TIMEOUT_MS, -): { - readonly timeoutMs: number; - readonly intervalMs: number; -} { - const timeoutMs = options.timeoutMs ?? defaultTimeoutMs; - const intervalMs = options.intervalMs ?? 100; - if (!Number.isInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 30_000 || - !Number.isInteger(intervalMs) || intervalMs < 1 || intervalMs > 1_000) { - throw new RuntimeError("RUNTIME_ENVIRONMENT_INVALID", "readiness bounds are invalid"); - } - return { timeoutMs, intervalMs }; -} - -function delay(delayMs: number): Promise { - return new Promise((resolveDelay) => setTimeout(resolveDelay, delayMs)); -} - -function isTransientProcessInspectionError(error: unknown): error is RuntimeError { - return error instanceof RuntimeError && error.code === "PROCESS_INSPECTION_DENIED"; -} - -function sameRoot( - left: ProcessIdentity, - right: ProcessIdentity, - platform: DesktopRuntimeProvider["platform"], -): boolean { - return left.pid === right.pid && - left.startedAt === right.startedAt && - sameExecutablePath(left.executablePath, right.executablePath, platform); -} - -function sameExecutablePath( - left: string, - right: string, - platform: DesktopRuntimeProvider["platform"], -): boolean { - const normalizePath = platform === "darwin" - ? (value: string) => value.replaceAll("\\", "/") - : (value: string) => value.replaceAll("/", "\\").toLowerCase(); - return normalizePath(left) === normalizePath(right); -} - -function withManagedRoot( - receipt: LaunchReceipt, - root: ProcessIdentity, - platform: DesktopRuntimeProvider["platform"], -): LaunchReceipt { - if (sameRoot(receipt.root, root, platform)) return receipt; - return { ...receipt, root }; -} - -function isTrustedActivationRoot( - receipt: LaunchReceipt, - root: ProcessIdentity, - platform: DesktopRuntimeProvider["platform"], -): boolean { - if (!sameExecutablePath(root.executablePath, receipt.install.entryPath, platform)) return false; - if (sameRoot(root, receipt.root, platform)) return true; - const launchedAt = Date.parse(receipt.launchedAt); - const startedAt = Date.parse(root.startedAt); - return Number.isFinite(launchedAt) && Number.isFinite(startedAt) && - startedAt >= launchedAt - 1_000; -} - -type ActivationInspection = - | { readonly ready: false } - | { readonly ready: true; readonly receipt: LaunchReceipt }; - -async function inspectActivation( - provider: DesktopRuntimeProvider, - receipt: LaunchReceipt, -): Promise { - const roots = await provider.listCodexRoots(); - if (roots.some((root) => !isTrustedActivationRoot(receipt, root, provider.platform))) { - throw new RuntimeError( - "CODEX_WINDOW_ACTIVATION_FAILED", - "Application activation created an untrusted Codex root", - ); - } - if (roots.length !== 1) return { ready: false }; - - const activatedReceipt = withManagedRoot(receipt, roots[0]!, provider.platform); - const windows = await provider.inspectManagedWindows( - activatedReceipt.root.pid, - activatedReceipt.root.startedAt, - ); - if (!windows.rootExists) { - throw new RuntimeError( - "CODEX_WINDOW_ACTIVATION_FAILED", - "Managed Codex root exited during application activation", - ); - } - return windows.activationReady - ? { ready: true, receipt: activatedReceipt } - : { ready: false }; -} - -async function waitForActivationReadiness( - provider: DesktopRuntimeProvider, - receipt: LaunchReceipt, - timeoutMs: number, - intervalMs: number, -): Promise { - const deadline = Date.now() + timeoutMs; - let inspectionError: RuntimeError | null = null; - do { - try { - const activation = await inspectActivation(provider, receipt); - inspectionError = null; - if (activation.ready) return activation.receipt; - } catch (error) { - if (!isTransientProcessInspectionError(error)) throw error; - inspectionError = error; - } - if (Date.now() >= deadline) break; - await delay(intervalMs); - } while (Date.now() < deadline); - if (inspectionError) throw inspectionError; - return null; -} - -export async function launchManagedCodex( - options: LaunchManagedCodexOptions, -): Promise { - const discovery = await discoverCodexInstall(options.provider, options.cache); - if (discovery.runningRoot) { - throw new RuntimeError( - "CODEX_ALREADY_RUNNING_UNMANAGED", - "A normal Codex instance is already running", - "Close Codex normally and run OpenChatGPTSkin Launcher again.", - ); - } - - const port = await (options.allocatePort ?? pickLoopbackPort)(); - if (!Number.isInteger(port) || port < 1 || port > 65535) { - throw new RuntimeError("CODEX_LAUNCH_FAILED", "port allocator returned an invalid port"); - } - const launchedAt = new Date().toISOString(); - const root = await options.provider.launch(discovery.install.entryPath, [ - "--remote-debugging-address=127.0.0.1", - `--remote-debugging-port=${port}`, - ]); - return { - install: discovery.install, - root, - cdp: { host: "127.0.0.1", port }, - launchedAt, - }; -} - -export async function waitForManagedPort( - provider: DesktopRuntimeProvider, - receipt: LaunchReceipt, - options: LaunchReadinessOptions = {}, -): Promise { - const { timeoutMs, intervalMs } = readinessBounds(options); - const deadline = Date.now() + timeoutMs; - let inspectionError: RuntimeError | null = null; - while (Date.now() < deadline) { - let inspection: PortInspection | null; - try { - inspection = await provider.inspectPort(receipt.cdp.port); - } catch (error) { - if (!isTransientProcessInspectionError(error)) throw error; - inspectionError = error; - await delay(intervalMs); - continue; - } - if (inspection) { - if (inspection.host !== "127.0.0.1" || - inspection.port !== receipt.cdp.port || - !inspection.ancestors.includes(receipt.root.pid)) { - throw new RuntimeError( - "CDP_PROCESS_MISMATCH", - "CDP listener does not belong to the managed Codex process tree", - ); - } - return inspection; - } - await delay(intervalMs); - } - if (inspectionError) throw inspectionError; - throw new RuntimeError("CDP_NOT_READY", "Managed Codex CDP listener did not become ready"); -} - -export async function activateManagedCodexWindow( - provider: DesktopRuntimeProvider, - receipt: LaunchReceipt, - options: LaunchReadinessOptions = {}, -): Promise { - const { timeoutMs, intervalMs } = readinessBounds( - options, - DEFAULT_WINDOW_ACTIVATION_TIMEOUT_MS, - ); - const directlyLaunched = await waitForActivationReadiness( - provider, - receipt, - Math.min(timeoutMs, DIRECT_LAUNCH_WINDOW_GRACE_MS), - intervalMs, - ); - if (directlyLaunched) { - await waitForManagedPort(provider, directlyLaunched, { timeoutMs, intervalMs }); - return directlyLaunched; - } - - await provider.activateCodexApplication(); - const activated = await waitForActivationReadiness( - provider, - receipt, - timeoutMs, - intervalMs, - ); - if (activated) { - await waitForManagedPort(provider, activated, { timeoutMs, intervalMs }); - return activated; - } - throw new RuntimeError( - "CODEX_WINDOW_ACTIVATION_FAILED", - "Managed Codex application activation did not become ready", - "Quit Codex completely and retry once. If it repeats, report the installed Codex version.", - ); -} diff --git a/runtime/windows/src/launcher/port.ts b/runtime/windows/src/launcher/port.ts deleted file mode 100644 index b4929bb..0000000 --- a/runtime/windows/src/launcher/port.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { createServer } from "node:net"; - -export function pickLoopbackPort(): Promise { - return new Promise((resolvePort, reject) => { - const server = createServer(); - server.once("error", reject); - server.listen({ host: "127.0.0.1", port: 0, exclusive: true }, () => { - const address = server.address(); - if (!address || typeof address === "string") { - server.close(); - reject(new Error("failed to allocate IPv4 loopback port")); - return; - } - server.close((error) => { - if (error) reject(error); - else resolvePort(address.port); - }); - }); - }); -} diff --git a/runtime/windows/src/logging.ts b/runtime/windows/src/logging.ts deleted file mode 100644 index 51294fd..0000000 --- a/runtime/windows/src/logging.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { appendFile, mkdir, rename, stat, unlink } from "node:fs/promises"; -import { dirname } from "node:path"; -import { z } from "zod"; -import { RUNTIME_ERROR_CODES } from "./errors.js"; -import { RuntimeBuiltinThemeIdSchema } from "./themes/ids.js"; - -const RuntimeLogEntrySchema = z.object({ - schemaVersion: z.literal(1), - timestamp: z.string().datetime(), - event: z.enum([ - "discovery-complete", - "launch-started", - "cdp-attached", - "theme-applied", - "theme-paused", - "theme-resumed", - "theme-switch-started", - "theme-switch-complete", - "runtime-recovered", - "cleanup-waiting", - "restore-started", - "restore-complete", - "runtime-error", - ]), - runtimeVersion: z.string().max(40), - packageVersion: z.string().max(40).optional(), - adapterId: z.string().max(80).optional(), - themeId: RuntimeBuiltinThemeIdSchema.optional(), - durationMs: z.number().int().nonnegative().max(600_000).optional(), - errorCode: z.enum(RUNTIME_ERROR_CODES).optional(), -}).strict(); - -export type RuntimeLogEntry = z.infer; - -export class RuntimeLogger { - constructor(private readonly path: string) {} - - async write(value: RuntimeLogEntry): Promise { - const entry = RuntimeLogEntrySchema.parse(value); - await mkdir(dirname(this.path), { recursive: true }); - try { - if ((await stat(this.path)).size >= 2 * 1024 * 1024) { - try { - await unlink(`${this.path}.1`); - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; - } - await rename(this.path, `${this.path}.1`); - } - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; - } - await appendFile(this.path, `${JSON.stringify(entry)}\n`, "utf8"); - } -} diff --git a/runtime/windows/src/macos/identity.ts b/runtime/windows/src/macos/identity.ts deleted file mode 100644 index 8ae4f9b..0000000 --- a/runtime/windows/src/macos/identity.ts +++ /dev/null @@ -1,10 +0,0 @@ -export const MACOS_CODEX_BUNDLE_ID = "com.openai.codex"; -export const MACOS_CODEX_IDENTITY_NAME = "OpenAI.Codex"; -export const MACOS_CODEX_TEAM_ID = "2DC432GLL2"; -export const MACOS_CODEX_ENTRY_POINT = "macOS.Application"; -export const MACOS_CODEX_NOTARIZATION_AUTHORITY = "Notarized Developer ID"; -export const MACOS_CODEX_RESOURCE_SIGNER = "OpenAI, L.L.C."; - -export function macOsEntryRelativePath(executableName: string): string { - return `Contents/MacOS/${executableName}`; -} diff --git a/runtime/windows/src/macos/macos-provider.ts b/runtime/windows/src/macos/macos-provider.ts deleted file mode 100644 index 413b933..0000000 --- a/runtime/windows/src/macos/macos-provider.ts +++ /dev/null @@ -1,541 +0,0 @@ -import { posix } from "node:path"; -import { - chmod, - lstat, - mkdir, - realpath, -} from "node:fs/promises"; -import { homedir } from "node:os"; -import { RuntimeError } from "../errors.js"; -import type { - DesktopRuntimeProvider, - InstallInspection, - ManagedWindowInspection, - PortInspection, - ProcessIdentity, -} from "../types.js"; -import { - type CommandRequest, - type CommandResult, - type CommandRunner, - nodeCommandRunner, -} from "../windows/command-runner.js"; -import { - MACOS_CODEX_BUNDLE_ID, - MACOS_CODEX_ENTRY_POINT, - MACOS_CODEX_IDENTITY_NAME, - MACOS_CODEX_NOTARIZATION_AUTHORITY, - MACOS_CODEX_RESOURCE_SIGNER, - MACOS_CODEX_TEAM_ID, - macOsEntryRelativePath, -} from "./identity.js"; - -const COMMAND_TIMEOUT_MS = 10_000; -const PROCESS_POLL_INTERVAL_MS = 100; -const PROCESS_LAUNCH_TIMEOUT_MS = 10_000; -const MACOS_SYSTEM_APPLICATION = "/Applications/Codex.app"; - -export interface MacOsRuntimeProviderOptions { - readonly runner?: CommandRunner; - readonly dataRoot?: string; - readonly homeDirectory?: string; - readonly platform?: NodeJS.Platform; - readonly currentUid?: number; - readonly inspectBundlePath?: (path: string) => Promise<{ - readonly isDirectory: () => boolean; - readonly isSymbolicLink: () => boolean; - }>; -} - -interface ProcessRow extends ProcessIdentity { - readonly command: string; -} - -function delay(delayMs: number): Promise { - return new Promise((resolveDelay) => setTimeout(resolveDelay, delayMs)); -} - -function runtimeEnvironmentError(message: string): RuntimeError { - return new RuntimeError("RUNTIME_ENVIRONMENT_INVALID", message); -} - -function inspectionError(message: string): RuntimeError { - return new RuntimeError("PROCESS_INSPECTION_DENIED", message); -} - -function validatePid(pid: number): void { - if (!Number.isInteger(pid) || pid < 1) { - throw runtimeEnvironmentError("Process ID must be a positive integer"); - } -} - -function validateStartedAt(startedAt: string): void { - if (!startedAt.endsWith("Z") || !Number.isFinite(Date.parse(startedAt))) { - throw runtimeEnvironmentError("Process start time is invalid"); - } -} - -function normalizePackageVersion(value: string): string { - const parts = value.trim().split("."); - if (parts.length < 1 || parts.length > 4 || parts.some((part) => !/^\d+$/.test(part))) { - throw new RuntimeError("CODEX_IDENTITY_INVALID", "Codex bundle version is invalid"); - } - return [...parts, ...Array(4 - parts.length).fill("0")].join("."); -} - -function parseProcessRows(value: string): readonly ProcessRow[] { - const rows: ProcessRow[] = []; - for (const line of value.split(/\r?\n/)) { - if (!line.trim()) continue; - const match = /^\s*(\d+)\s+(\d+)\s+([A-Z][a-z]{2}\s+[A-Z][a-z]{2}\s+\d{1,2}\s+\d{2}:\d{2}:\d{2}\s+\d{4})\s+([\s\S]+)$/.exec(line); - if (!match) throw inspectionError("macOS process inspection returned an invalid row"); - const startedAtValue = Date.parse(match[3]!); - if (!Number.isFinite(startedAtValue)) { - throw inspectionError("macOS process start time is invalid"); - } - const startedAt = new Date(startedAtValue).toISOString(); - rows.push({ - pid: Number(match[1]), - parentPid: Number(match[2]), - startedAt, - executablePath: match[4]!.trim(), - command: match[4]!.trim(), - }); - } - return rows; -} - -function parseLsof(value: string, port: number): { - readonly owningPid: number; - readonly host: string; -} | null { - const listeners: Array<{ owningPid: number; host: string }> = []; - let pid: number | null = null; - for (const line of value.split(/\r?\n/)) { - if (line.startsWith("p")) { - const candidate = Number(line.slice(1)); - pid = Number.isInteger(candidate) && candidate > 0 ? candidate : null; - continue; - } - if (!line.startsWith("n") || pid === null) continue; - const endpoint = line.slice(1); - const suffix = `:${port}`; - if (!endpoint.endsWith(suffix)) continue; - listeners.push({ owningPid: pid, host: endpoint.slice(0, -suffix.length) }); - } - if (listeners.length === 0) return null; - if (listeners.length !== 1) throw inspectionError("CDP port has multiple listening owners"); - return listeners[0]!; -} - -function commandHasFlag(command: string, flag: string): boolean { - const escaped = flag.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - return new RegExp(`(?:^|\\s)${escaped}(?:=|\\s|$)`).test(command); -} - -function descendantPids(rows: readonly ProcessRow[], rootPid: number): ReadonlySet { - const result = new Set([rootPid]); - let changed = true; - while (changed) { - changed = false; - for (const row of rows) { - if (!result.has(row.pid) && result.has(row.parentPid)) { - result.add(row.pid); - changed = true; - } - } - } - return result; -} - -function ancestorPids(rows: readonly ProcessRow[], pid: number): readonly number[] { - const byPid = new Map(rows.map((row) => [row.pid, row] as const)); - const result: number[] = []; - const seen = new Set(); - let current: number | undefined = pid; - while (current && !seen.has(current)) { - seen.add(current); - result.push(current); - current = byPid.get(current)?.parentPid; - } - return result; -} - -function bundleRootFromExecutable(executablePath: string): string { - const normalized = executablePath.replaceAll("\\", "/"); - const match = /^(.*\/Codex\.app)\/Contents\/MacOS\/[^/]+$/.exec(normalized); - if (!match) { - throw new RuntimeError("CODEX_IDENTITY_INVALID", "Codex executable is outside Codex.app"); - } - return match[1]!; -} - -function parseSigningIdentity(output: string): { - readonly teamId: string; - readonly signer: string; -} { - const teamId = /^TeamIdentifier=(.+)$/m.exec(output)?.[1]?.trim(); - const signer = /^Authority=Developer ID Application:\s*(.+?)\s*\([A-Z0-9]+\)$/m.exec(output)?.[1]?.trim(); - if (!teamId || !signer) { - throw new RuntimeError("CODEX_IDENTITY_INVALID", "Codex code-signing identity is unavailable"); - } - return { teamId, signer }; -} - -export class MacOsRuntimeProvider implements DesktopRuntimeProvider { - readonly platform = "darwin" as const; - private readonly runner: CommandRunner; - private readonly dataRoot: string | undefined; - private readonly homeDirectory: string; - private readonly uid: number; - private readonly inspectBundlePath: NonNullable; - - constructor(options: MacOsRuntimeProviderOptions = {}) { - const platform = options.platform ?? process.platform; - if (platform !== "darwin") { - throw runtimeEnvironmentError("MacOsRuntimeProvider requires macOS"); - } - const uid = options.currentUid ?? process.getuid?.(); - if (typeof uid !== "number" || !Number.isInteger(uid) || uid < 0) { - throw runtimeEnvironmentError("macOS user ID is unavailable"); - } - this.runner = options.runner ?? nodeCommandRunner; - this.dataRoot = options.dataRoot; - this.homeDirectory = options.homeDirectory ?? homedir(); - this.uid = uid; - this.inspectBundlePath = options.inspectBundlePath ?? lstat; - } - - private async run( - executable: string, - args: readonly string[], - options: { readonly allowNonZero?: boolean; readonly env?: Readonly> } = {}, - ): Promise { - let result: CommandResult; - try { - const request: CommandRequest = { - executable, - args, - stdin: "", - timeoutMs: COMMAND_TIMEOUT_MS, - shell: false, - ...(options.env ? { env: options.env } : {}), - }; - result = await this.runner.run(request); - } catch (error) { - throw inspectionError(error instanceof Error ? error.message : String(error)); - } - if (result.exitCode !== 0 && !options.allowNonZero) { - throw inspectionError(result.stderr.trim() || `${executable} exited ${result.exitCode}`); - } - return result; - } - - private async processRows(commandColumn: "comm" | "command" = "comm"): Promise { - const result = await this.run( - "/bin/ps", - ["-ww", "-axo", `pid=,ppid=,lstart=,${commandColumn}=`], - { env: { LC_ALL: "C" } }, - ); - return parseProcessRows(result.stdout); - } - - async currentUserPackageRoots(): Promise { - const candidates = [ - MACOS_SYSTEM_APPLICATION, - posix.resolve(this.homeDirectory, "Applications", "Codex.app"), - ]; - const roots: string[] = []; - for (const candidate of candidates) { - try { - const info = await this.inspectBundlePath(candidate); - if (info.isDirectory() && !info.isSymbolicLink()) roots.push(candidate); - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== "ENOENT") { - throw inspectionError(error instanceof Error ? error.message : String(error)); - } - } - } - return [...new Set(roots)]; - } - - async listCodexRoots(): Promise { - const roots = await this.currentUserPackageRoots(); - const executablePaths = new Set(); - for (const root of roots) { - const executable = (await this.readPlistValue(root, "CFBundleExecutable")).trim(); - if (executable) executablePaths.add(posix.resolve(root, macOsEntryRelativePath(executable))); - } - if (executablePaths.size === 0) return []; - const identities: ProcessIdentity[] = []; - for (const row of await this.processRows("command")) { - const entryPath = [...executablePaths].find((candidate) => - row.command === candidate || row.command.startsWith(`${candidate} `) - ); - if (!entryPath) continue; - identities.push({ - pid: row.pid, - parentPid: row.parentPid, - startedAt: row.startedAt, - executablePath: entryPath, - }); - } - return identities; - } - - private async readPlistValue(bundleRoot: string, key: string): Promise { - const plist = posix.resolve(bundleRoot, "Contents", "Info.plist"); - const result = await this.run( - "/usr/bin/plutil", - ["-extract", key, "raw", "-o", "-", plist], - { allowNonZero: true }, - ); - const value = result.stdout.trim(); - if (result.exitCode !== 0 || !value) { - throw new RuntimeError("CODEX_IDENTITY_INVALID", `Codex ${key} is unavailable`); - } - return value; - } - - async inspectInstall(packageRoot: string): Promise { - const root = posix.resolve(packageRoot); - let rootInfo: Awaited>>; - try { - rootInfo = await this.inspectBundlePath(root); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") { - throw new RuntimeError("CODEX_NOT_INSTALLED", "Codex.app was not found"); - } - throw inspectionError(error instanceof Error ? error.message : String(error)); - } - if (!rootInfo.isDirectory() || rootInfo.isSymbolicLink()) { - throw new RuntimeError("CODEX_IDENTITY_INVALID", "Codex.app must be a regular app bundle"); - } - const [bundleId, executableName, rawVersion, verify, display, assessment] = await Promise.all([ - this.readPlistValue(root, "CFBundleIdentifier"), - this.readPlistValue(root, "CFBundleExecutable"), - this.readPlistValue(root, "CFBundleVersion"), - this.run("/usr/bin/codesign", ["--verify", "--deep", "--strict", root], { allowNonZero: true }), - this.run("/usr/bin/codesign", ["-dv", "--verbose=4", root], { allowNonZero: true }), - this.run("/usr/sbin/spctl", ["--assess", "--type", "execute", "--verbose=4", root], { allowNonZero: true }), - ]); - if (verify.exitCode !== 0 || display.exitCode !== 0 || assessment.exitCode !== 0 || - bundleId !== MACOS_CODEX_BUNDLE_ID) { - throw new RuntimeError("CODEX_IDENTITY_INVALID", "Codex bundle identity or signature is invalid"); - } - const signing = parseSigningIdentity(`${display.stdout}\n${display.stderr}`); - const assessmentText = `${assessment.stdout}\n${assessment.stderr}`; - if (signing.teamId !== MACOS_CODEX_TEAM_ID || - signing.signer !== MACOS_CODEX_RESOURCE_SIGNER || - !assessmentText.includes(`source=${MACOS_CODEX_NOTARIZATION_AUTHORITY}`)) { - throw new RuntimeError("CODEX_IDENTITY_INVALID", "Codex signer or notarization is invalid"); - } - const entryRelativePath = macOsEntryRelativePath(executableName); - return { - packageRoot: root, - entryPath: posix.resolve(root, entryRelativePath), - identityName: MACOS_CODEX_IDENTITY_NAME, - packageVersion: normalizePackageVersion(rawVersion), - packagePublisher: MACOS_CODEX_TEAM_ID, - appId: MACOS_CODEX_BUNDLE_ID, - entryRelativePath, - entryPoint: MACOS_CODEX_ENTRY_POINT, - packageSignatureStatus: "Valid", - packageSignerCommonName: MACOS_CODEX_TEAM_ID, - catalogSignatureStatus: "Valid", - catalogSignerCommonName: MACOS_CODEX_NOTARIZATION_AUTHORITY, - entryBlockMapValid: true, - resourceSignatureStatus: "Valid", - resourceSignerCommonName: MACOS_CODEX_RESOURCE_SIGNER, - }; - } - - async inspectPort(port: number): Promise { - if (!Number.isInteger(port) || port < 1 || port > 65_535) { - throw runtimeEnvironmentError("Port must be between 1 and 65535"); - } - const result = await this.run( - "/usr/sbin/lsof", - ["-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-Fpn"], - { allowNonZero: true }, - ); - if (result.exitCode !== 0 && !result.stdout.trim()) return null; - const listener = parseLsof(result.stdout, port); - if (!listener) return null; - if (listener.host !== "127.0.0.1") { - throw new RuntimeError("CDP_ENDPOINT_UNSAFE", "CDP listener is not bound to IPv4 loopback"); - } - const rows = await this.processRows("comm"); - return { - host: listener.host, - port, - owningPid: listener.owningPid, - ancestors: ancestorPids(rows, listener.owningPid), - }; - } - - async inspectProcessStartedAt(pid: number): Promise { - validatePid(pid); - return (await this.processRows("comm")).find((row) => row.pid === pid)?.startedAt ?? null; - } - - async inspectRemoteDebuggingArguments( - rootPid: number, - startedAt: string, - ): Promise<{ readonly hasRemoteDebuggingAddress: boolean; readonly hasRemoteDebuggingPort: boolean }> { - validatePid(rootPid); - validateStartedAt(startedAt); - const rows = await this.processRows("command"); - const root = rows.find((row) => row.pid === rootPid && row.startedAt === startedAt); - if (!root) throw inspectionError("Managed Codex process identity changed"); - const descendants = descendantPids(rows, rootPid); - const commands = rows.filter((row) => descendants.has(row.pid)).map((row) => row.command); - return { - hasRemoteDebuggingAddress: commands.some((command) => commandHasFlag(command, "--remote-debugging-address")), - hasRemoteDebuggingPort: commands.some((command) => commandHasFlag(command, "--remote-debugging-port")), - }; - } - - async measureProcessCpuPercent( - rootPid: number, - startedAt: string, - sampleMs: number, - ): Promise { - validatePid(rootPid); - validateStartedAt(startedAt); - if (!Number.isInteger(sampleMs) || sampleMs < 1_000 || sampleMs > 5_000) { - throw runtimeEnvironmentError("CPU sample duration must be between one and five seconds"); - } - if (await this.inspectProcessStartedAt(rootPid) !== startedAt) { - throw inspectionError("Managed Codex process identity changed"); - } - await delay(sampleMs); - const result = await this.run( - "/bin/ps", - ["-p", String(rootPid), "-o", "%cpu="], - { env: { LC_ALL: "C" } }, - ); - if (await this.inspectProcessStartedAt(rootPid) !== startedAt) { - throw inspectionError("Managed Codex process identity changed"); - } - const value = Number(result.stdout.trim()); - if (!Number.isFinite(value) || value < 0) throw inspectionError("CPU inspection was invalid"); - return value; - } - - async activateCodexApplication(): Promise { - const result = await this.run( - "/usr/bin/open", - ["-b", MACOS_CODEX_BUNDLE_ID], - { allowNonZero: true }, - ); - if (result.exitCode !== 0) { - throw new RuntimeError( - "CODEX_WINDOW_ACTIVATION_FAILED", - result.stderr.trim() || "Codex bundle activation failed", - "Open Codex once from Applications, quit it normally, then retry.", - ); - } - } - - async inspectManagedWindows( - rootPid: number, - startedAt: string, - ): Promise { - const rootExists = await this.inspectProcessStartedAt(rootPid) === startedAt; - // macOS window enumeration requires Accessibility consent. The controller already - // verifies activation plus the owned CDP process tree, so avoid broad OS permission. - return { - rootExists, - visibleWindowCount: 0, - activationReady: rootExists, - }; - } - - async launch(executablePath: string, args: readonly string[]): Promise { - const bundleRoot = bundleRootFromExecutable(posix.resolve(executablePath)); - const requestedAt = Date.now(); - const result = await this.run( - "/usr/bin/open", - ["-n", bundleRoot, "--args", ...args], - { allowNonZero: true }, - ); - if (result.exitCode !== 0) { - throw new RuntimeError( - "CODEX_LAUNCH_FAILED", - result.stderr.trim() || "macOS failed to launch Codex", - ); - } - const deadline = Date.now() + PROCESS_LAUNCH_TIMEOUT_MS; - while (Date.now() < deadline) { - const candidates = (await this.listCodexRoots()).filter((root) => - posix.resolve(root.executablePath) === posix.resolve(executablePath) && - Date.parse(root.startedAt) >= requestedAt - 1_000 - ); - if (candidates.length === 1) return candidates[0]!; - if (candidates.length > 1) { - throw new RuntimeError("CODEX_LAUNCH_FAILED", "multiple new Codex root processes appeared"); - } - await delay(PROCESS_POLL_INTERVAL_MS); - } - throw new RuntimeError("CODEX_LAUNCH_FAILED", "Codex root process did not appear"); - } - - async waitForExit(rootPid: number, startedAt: string, timeoutMs: number): Promise { - validatePid(rootPid); - validateStartedAt(startedAt); - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - if (await this.inspectProcessStartedAt(rootPid) !== startedAt) return true; - await delay(PROCESS_POLL_INTERVAL_MS); - } - return false; - } - - async currentUserSid(): Promise { - return `uid:${this.uid}`; - } - - async secureDirectory(path: string): Promise { - if (!this.dataRoot) { - throw runtimeEnvironmentError("MacOsRuntimeProvider requires a configured data root"); - } - const root = posix.resolve(this.dataRoot); - const target = posix.resolve(path); - const child = posix.relative(root, target); - if (child.startsWith("../") || child === ".." || posix.isAbsolute(child) || child.includes("/")) { - throw runtimeEnvironmentError("Runtime directories must be the data root or one direct child"); - } - const rejectUnsafeExistingDirectory = async (candidate: string): Promise => { - try { - const info = await lstat(candidate); - if (!info.isDirectory() || info.isSymbolicLink() || info.uid !== this.uid) { - throw runtimeEnvironmentError("macOS Runtime directory ownership is invalid"); - } - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; - } - }; - await rejectUnsafeExistingDirectory(root); - await mkdir(root, { recursive: true, mode: 0o700 }); - await rejectUnsafeExistingDirectory(target); - await mkdir(target, { recursive: true, mode: 0o700 }); - await chmod(root, 0o700); - await chmod(target, 0o700); - const [rootInfo, targetInfo, canonicalRoot, canonicalTarget] = await Promise.all([ - lstat(root), - lstat(target), - realpath(root), - realpath(target), - ]); - const canonicalChild = posix.relative(canonicalRoot, canonicalTarget); - const rootMode = rootInfo.mode & 0o777; - const targetMode = targetInfo.mode & 0o777; - if (!rootInfo.isDirectory() || rootInfo.isSymbolicLink() || rootInfo.uid !== this.uid || - !targetInfo.isDirectory() || targetInfo.isSymbolicLink() || targetInfo.uid !== this.uid || - rootMode !== 0o700 || targetMode !== 0o700 || canonicalChild.startsWith("../") || - canonicalChild === ".." || posix.isAbsolute(canonicalChild)) { - throw runtimeEnvironmentError("macOS Runtime directory ownership or permissions are invalid"); - } - } -} diff --git a/runtime/windows/src/paths.ts b/runtime/windows/src/paths.ts deleted file mode 100644 index 93e4fe5..0000000 --- a/runtime/windows/src/paths.ts +++ /dev/null @@ -1,167 +0,0 @@ -import { dirname, isAbsolute, join, resolve } from "node:path"; -import { homedir } from "node:os"; -import { fileURLToPath } from "node:url"; -import { lstat, rename } from "node:fs/promises"; -import { RuntimeError } from "./errors.js"; - -const PRODUCT_DATA_DIRECTORY = "OpenChatGPTSkin"; -export const OPEN_CHATGPT_SKIN_INSTALL_ROOT = - "OPEN_CHATGPT_SKIN_INSTALL_ROOT"; -// Compatibility-only source for the one-time pre-rename data migration. -const LEGACY_PRODUCT_DATA_DIRECTORY = "OpenCodexSkin"; - -export interface RuntimePaths { - readonly dataRoot: string; - readonly installRoot: string; - readonly runtimeDirectory: string; - readonly installDirectory: string; - readonly sessionFile: string; - readonly pendingProbeFile: string; - readonly installCache: string; - readonly logDirectory: string; - readonly themesRoot: string; - readonly themeStoreDirectory: string; - readonly controllerLockFile: string; - readonly acceptanceSessionFile: string; - readonly acceptanceEvidenceDirectory: string; - readonly themeStudioDraftDirectory: string; -} - -export function createRuntimePaths(dataRoot: string, installRoot: string): RuntimePaths { - return { - dataRoot, - installRoot, - runtimeDirectory: join(dataRoot, "runtime"), - installDirectory: join(dataRoot, "install"), - sessionFile: join(dataRoot, "runtime", "session.json"), - pendingProbeFile: join(dataRoot, "runtime", "pending-probe.json"), - installCache: join(dataRoot, "install", "trusted-codex.json"), - logDirectory: join(dataRoot, "runtime", "logs"), - themesRoot: join(installRoot, "themes"), - themeStoreDirectory: join(dataRoot, "theme-store"), - controllerLockFile: join(dataRoot, "runtime", "controller.lock"), - acceptanceSessionFile: join(dataRoot, "runtime", "acceptance-session.json"), - acceptanceEvidenceDirectory: join(installRoot, "docs", "runtime-acceptance"), - themeStudioDraftDirectory: join(dataRoot, "theme-studio", "drafts"), - }; -} - -export interface ProductionRuntimeEnvironment { - readonly platform?: NodeJS.Platform; - readonly env?: NodeJS.ProcessEnv; - readonly homeDirectory?: string; - readonly installRoot?: string; -} - -function productionInstallRoot( - metaUrl: string, - environment: ProductionRuntimeEnvironment, -): string { - const configured = environment.installRoot ?? - environment.env?.[OPEN_CHATGPT_SKIN_INSTALL_ROOT] ?? - process.env[OPEN_CHATGPT_SKIN_INSTALL_ROOT]; - if (configured !== undefined) { - if (!isAbsolute(configured) || configured.includes("\0")) { - throw new RuntimeError( - "RUNTIME_ENVIRONMENT_INVALID", - "OpenChatGPTSkin install root must be an absolute path", - "Start OpenChatGPTSkin from its packaged launcher or remove the invalid install-root override.", - ); - } - return resolve(configured); - } - return resolve(dirname(fileURLToPath(metaUrl)), "../../.."); -} - -function productionDataRootFor( - environment: ProductionRuntimeEnvironment, - directoryName: string, -): string { - const platform = environment.platform ?? process.platform; - const env = environment.env ?? process.env; - if (platform === "win32") { - const localAppData = env.LOCALAPPDATA; - if (!localAppData) { - throw new RuntimeError("RUNTIME_ENVIRONMENT_INVALID", "LOCALAPPDATA is not defined"); - } - return join(localAppData, directoryName); - } - if (platform === "darwin") { - const home = environment.homeDirectory ?? env.HOME ?? homedir(); - if (!home) { - throw new RuntimeError("RUNTIME_ENVIRONMENT_INVALID", "macOS home directory is unavailable"); - } - return join(home, "Library", "Application Support", directoryName); - } - throw new RuntimeError( - "RUNTIME_ENVIRONMENT_INVALID", - `Unsupported desktop platform: ${platform}`, - "Use OpenChatGPTSkin on Windows or macOS.", - ); -} - -function productionDataRoot(environment: ProductionRuntimeEnvironment): string { - return productionDataRootFor(environment, PRODUCT_DATA_DIRECTORY); -} - -export function createProductionRuntimePaths( - metaUrl: string = import.meta.url, - environment: ProductionRuntimeEnvironment = {}, -): RuntimePaths { - const installRoot = productionInstallRoot(metaUrl, environment); - return createRuntimePaths(productionDataRoot(environment), installRoot); -} - -async function existingDirectory(path: string): Promise<"directory" | "unsafe" | "missing"> { - try { - const info = await lstat(path); - return info.isDirectory() && !info.isSymbolicLink() ? "directory" : "unsafe"; - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") return "missing"; - throw error; - } -} - -/** - * Atomically adopts pre-rename user data only when the new product directory - * does not exist. Existing new-brand data is always authoritative; directories - * are never merged or overwritten. - */ -export async function prepareProductionRuntimePaths( - metaUrl: string = import.meta.url, - environment: ProductionRuntimeEnvironment = {}, -): Promise { - const paths = createProductionRuntimePaths(metaUrl, environment); - const legacyDataRoot = productionDataRootFor( - environment, - LEGACY_PRODUCT_DATA_DIRECTORY, - ); - const [currentState, legacyState] = await Promise.all([ - existingDirectory(paths.dataRoot), - existingDirectory(legacyDataRoot), - ]); - if (currentState !== "missing" || legacyState === "missing") return paths; - if (legacyState === "unsafe") { - throw new RuntimeError( - "RUNTIME_ENVIRONMENT_INVALID", - "Previous installation data is not a regular directory", - "Review the previous data directory before starting OpenChatGPTSkin.", - ); - } - - try { - await rename(legacyDataRoot, paths.dataRoot); - } catch (error) { - // Another new-brand process may have won the same one-time migration race. - if (await existingDirectory(paths.dataRoot) === "directory" && - await existingDirectory(legacyDataRoot) === "missing") { - return paths; - } - throw new RuntimeError( - "RUNTIME_ENVIRONMENT_INVALID", - error instanceof Error ? error.message : "Previous installation data migration failed", - "Quit all OpenChatGPTSkin processes and retry.", - ); - } - return paths; -} diff --git a/runtime/windows/src/platform/provider-factory.ts b/runtime/windows/src/platform/provider-factory.ts deleted file mode 100644 index b176489..0000000 --- a/runtime/windows/src/platform/provider-factory.ts +++ /dev/null @@ -1,22 +0,0 @@ -import type { RuntimePaths } from "../paths.js"; -import type { DesktopRuntimeProvider } from "../types.js"; -import { RuntimeError } from "../errors.js"; -import { MacOsRuntimeProvider } from "../macos/macos-provider.js"; -import { PowerShellWindowsProvider } from "../windows/powershell-provider.js"; - -export function createProductionDesktopProvider( - paths: RuntimePaths, - platform: NodeJS.Platform = process.platform, -): DesktopRuntimeProvider { - if (platform === "win32") { - return new PowerShellWindowsProvider(undefined, paths.dataRoot); - } - if (platform === "darwin") { - return new MacOsRuntimeProvider({ dataRoot: paths.dataRoot }); - } - throw new RuntimeError( - "RUNTIME_ENVIRONMENT_INVALID", - `Unsupported desktop platform: ${platform}`, - "Use OpenChatGPTSkin on Windows or macOS.", - ); -} diff --git a/runtime/windows/src/probe-cli.ts b/runtime/windows/src/probe-cli.ts deleted file mode 100644 index 8a22171..0000000 --- a/runtime/windows/src/probe-cli.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { randomUUID } from "node:crypto"; -import { - CdpConnection, - waitForCompatibleCodexTarget, - waitForCompatibleAdapter, -} from "@open-chatgpt-skin/cdp-adapter"; -import { TrustedInstallStore } from "./discovery/trusted-cache.js"; -import { RuntimeError, runtimeErrorCode } from "./errors.js"; -import { - activateManagedCodexWindow, - launchManagedCodex, - waitForManagedPort, -} from "./launcher/launcher.js"; -import { prepareProductionRuntimePaths } from "./paths.js"; -import { parseProbeArguments } from "./probe-command.js"; -import { recordProbeEvidence } from "./probe-evidence.js"; -import { finalizeProbe } from "./probe-finalize.js"; -import { runProbePhaseOne } from "./probe-phase-one.js"; -import { PendingProbeStore } from "./probe-session.js"; -import { RuntimeStateStore } from "./state.js"; -import { PowerShellWindowsProvider } from "./windows/powershell-provider.js"; - -try { - if (process.platform !== "win32") { - throw new RuntimeError( - "RUNTIME_ENVIRONMENT_INVALID", - "The compatibility Probe is currently available on Windows only", - "Use the macOS Runtime commands, then complete the macOS manual acceptance checklist.", - ); - } - const command = parseProbeArguments(process.argv.slice(2)); - const paths = await prepareProductionRuntimePaths(); - const provider = new PowerShellWindowsProvider(undefined, paths.dataRoot); - const sessionStore = new PendingProbeStore(paths.pendingProbeFile); - const runtimeStateStore = new RuntimeStateStore(paths.sessionFile); - const cache = new TrustedInstallStore(paths.installCache); - - if (command.mode === "finalize") { - const evidence = await finalizeProbe({ - provider, - sessionStore, - runtimeStateStore, - recordEvidence: (value) => recordProbeEvidence(paths.installRoot, value), - }); - process.stdout.write(`${JSON.stringify({ - compatible: true, - phase: "complete", - ...evidence, - })}\n`); - } else { - const result = await runProbePhaseOne({ - provider, - sessionStore, - securePendingDirectory: () => provider.secureDirectory(paths.runtimeDirectory), - launch: () => launchManagedCodex({ provider, cache }), - waitForPort: (receipt) => waitForManagedPort(provider, receipt), - activateWindow: (receipt) => activateManagedCodexWindow(provider, receipt), - waitForTarget: (endpoint) => waitForCompatibleCodexTarget(endpoint), - connectPage: (target, endpoint) => CdpConnection.connect( - target.webSocketDebuggerUrl, - endpoint, - ), - waitForAdapter: (adapter) => waitForCompatibleAdapter(adapter), - now: () => new Date().toISOString(), - newSessionId: randomUUID, - }, command.recordEvidence); - process.stdout.write(`${JSON.stringify(result)}\n`); - } -} catch (error) { - process.exitCode = 1; - process.stderr.write(`${JSON.stringify({ - compatible: false, - error: { code: runtimeErrorCode(error) }, - })}\n`); -} diff --git a/runtime/windows/src/probe-command.ts b/runtime/windows/src/probe-command.ts deleted file mode 100644 index b44ad16..0000000 --- a/runtime/windows/src/probe-command.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { RuntimeError } from "./errors.js"; - -export type ProbeCommand = - | { readonly mode: "start"; readonly recordEvidence: boolean } - | { readonly mode: "finalize" }; - -export function parseProbeArguments(args: readonly string[]): ProbeCommand { - if (args.length === 0) return { mode: "start", recordEvidence: false }; - if (args.length === 1 && args[0] === "--record-evidence") { - return { mode: "start", recordEvidence: true }; - } - if (args.length === 1 && args[0] === "--finalize") { - return { mode: "finalize" }; - } - throw new RuntimeError( - "RUNTIME_ENVIRONMENT_INVALID", - "probe accepts only --record-evidence or --finalize", - ); -} diff --git a/runtime/windows/src/probe-evidence.ts b/runtime/windows/src/probe-evidence.ts deleted file mode 100644 index a52d22f..0000000 --- a/runtime/windows/src/probe-evidence.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { randomUUID } from "node:crypto"; -import { mkdir, rename, unlink, writeFile } from "node:fs/promises"; -import { join } from "node:path"; -import { z } from "zod"; - -export const ProbeObservationSchema = z.object({ - packageIdentity: z.literal("OpenAI.Codex"), - packageVersion: z.string().regex(/^\d+\.\d+\.\d+\.\d+$/), - target: z.object({ - type: z.literal("page"), - protocol: z.enum(["app:", "https:"]), - host: z.string().max(100), - pathCategory: z.enum(["app-root", "codex"]), - }).strict(), - capabilities: z.object({ - main: z.literal(true), - navigation: z.literal(true), - composer: z.literal(true), - }).strict(), - markerRoundTrip: z.literal(true), - loopbackOnly: z.literal(true), - windowActivationVerified: z.literal(true), - officialAppearanceRestored: z.literal(true), -}).strict(); - -export type ProbeObservation = z.infer; - -export const ProbeEvidenceSchema = z.object({ - schemaVersion: z.literal(2), - ...ProbeObservationSchema.shape, - managedExitVerified: z.literal(true), - cdpClosedVerified: z.literal(true), -}).strict(); - -export type ProbeEvidence = z.infer; - -export async function recordProbeEvidence( - repositoryRoot: string, - evidence: ProbeEvidence, -): Promise { - const validated = ProbeEvidenceSchema.parse(evidence); - const directory = join(repositoryRoot, "docs", "runtime-probes"); - const path = join(directory, `codex-${validated.packageVersion}.json`); - await mkdir(directory, { recursive: true }); - const temporary = `${path}.${process.pid}-${randomUUID()}.tmp`; - try { - await writeFile(temporary, `${JSON.stringify(validated, null, 2)}\n`, "utf8"); - await rename(temporary, path); - } catch (error) { - try { - await unlink(temporary); - } catch (cleanupError) { - if ((cleanupError as NodeJS.ErrnoException).code !== "ENOENT") throw cleanupError; - } - throw error; - } - return path; -} diff --git a/runtime/windows/src/probe-finalize.ts b/runtime/windows/src/probe-finalize.ts deleted file mode 100644 index 1d84981..0000000 --- a/runtime/windows/src/probe-finalize.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { RuntimeError } from "./errors.js"; -import { ProbeEvidenceSchema, type ProbeEvidence } from "./probe-evidence.js"; -import type { PendingProbeStore } from "./probe-session.js"; -import type { RuntimeStateStore } from "./state.js"; -import type { WindowsRuntimeProvider } from "./types.js"; - -export interface FinalizeProbeDependencies { - readonly provider: WindowsRuntimeProvider; - readonly sessionStore: PendingProbeStore; - readonly runtimeStateStore: RuntimeStateStore; - readonly recordEvidence: (evidence: ProbeEvidence) => Promise; -} - -export async function finalizeProbe( - dependencies: FinalizeProbeDependencies, -): Promise { - const pending = await dependencies.sessionStore.read(); - if (!pending) { - throw new RuntimeError("PROBE_PENDING_SESSION_INVALID", "No pending probe exists"); - } - const exited = await dependencies.provider.waitForExit( - pending.root.pid, - pending.root.startedAt, - 100, - ); - if (!exited || await dependencies.provider.inspectPort(pending.cdp.port)) { - throw new RuntimeError("PROBE_EXIT_PENDING", "Managed Codex or CDP is active"); - } - const runtime = await dependencies.runtimeStateStore.read(); - if (runtime?.codex && - runtime.codex.rootPid === pending.root.pid && - runtime.codex.startedAt === pending.root.startedAt) { - throw new RuntimeError("PROBE_EXIT_PENDING", "Runtime still owns this root"); - } - if (pending.status === "failed-awaiting-exit") { - await dependencies.sessionStore.clear(); - throw new RuntimeError(pending.failureCode, "Phase-one probe failed"); - } - const evidence = ProbeEvidenceSchema.parse({ - schemaVersion: 2, - ...pending.observation, - managedExitVerified: true, - cdpClosedVerified: true, - }); - if (pending.recordEvidenceRequested) { - await dependencies.recordEvidence(evidence); - } - await dependencies.sessionStore.clear(); - return evidence; -} diff --git a/runtime/windows/src/probe-phase-one.ts b/runtime/windows/src/probe-phase-one.ts deleted file mode 100644 index 3c40eb0..0000000 --- a/runtime/windows/src/probe-phase-one.ts +++ /dev/null @@ -1,172 +0,0 @@ -import { - CurrentCodexAdapter, - REMOVE_EXPRESSION, - type AdapterProbe, - type CdpEndpoint, - type CdpRuntimeClient, - type CdpTarget, -} from "@open-chatgpt-skin/cdp-adapter"; -import { RuntimeError, runtimeErrorCode } from "./errors.js"; -import type { LaunchReceipt } from "./launcher/launcher.js"; -import { ProbeObservationSchema, type ProbeObservation } from "./probe-evidence.js"; -import type { PendingProbeStore } from "./probe-session.js"; -import type { PortInspection, WindowsRuntimeProvider } from "./types.js"; - -const PROBE_MARKER_EXPRESSION = `(() => { - const selector = '[data-open-chatgpt-skin]'; - if (document.querySelectorAll(selector).length !== 0) return false; - const style = document.createElement("style"); - style.dataset.openChatgptSkin = "theme"; - style.textContent = ":root{--ocs-probe:1}"; - document.head.append(style); - const created = document.querySelectorAll(selector).length === 1; - style.remove(); - return created && document.querySelectorAll(selector).length === 0; -})()`; -const VERIFY_NO_MARKERS_EXPRESSION = - `document.querySelectorAll('[data-open-chatgpt-skin]').length`; - -export interface ClosableCdpRuntimeClient extends CdpRuntimeClient { - close(): void; -} - -export interface ProbePhaseOneDependencies { - readonly provider: WindowsRuntimeProvider; - readonly sessionStore: PendingProbeStore; - readonly securePendingDirectory: () => Promise; - readonly launch: () => Promise; - readonly waitForPort: (receipt: LaunchReceipt) => Promise; - readonly activateWindow: (receipt: LaunchReceipt) => Promise; - readonly waitForTarget: (endpoint: CdpEndpoint) => Promise; - readonly connectPage: ( - target: CdpTarget, - endpoint: CdpEndpoint, - ) => Promise; - readonly waitForAdapter: (adapter: CurrentCodexAdapter) => Promise; - readonly now: () => string; - readonly newSessionId: () => string; -} - -export interface ProbeAwaitingExitResult { - readonly compatible: null; - readonly phase: "awaiting-exit"; - readonly nextAction: string; -} - -function targetCategory(targetUrl: string): ProbeObservation["target"] { - const url = new URL(targetUrl); - return { - type: "page", - protocol: url.protocol as "app:" | "https:", - host: url.hostname, - pathCategory: url.protocol === "app:" ? "app-root" : "codex", - }; -} - -async function ensureNoPendingProbe( - dependencies: ProbePhaseOneDependencies, -): Promise { - const pending = await dependencies.sessionStore.read(); - if (!pending) return; - const exited = await dependencies.provider.waitForExit( - pending.root.pid, - pending.root.startedAt, - 100, - ); - if (!exited || await dependencies.provider.inspectPort(pending.cdp.port)) { - throw new RuntimeError("PROBE_EXIT_PENDING", "Previous managed Codex is active"); - } - if (pending.status === "passed-awaiting-exit") { - throw new RuntimeError( - "PROBE_FINALIZE_REQUIRED", - "Finalize the previous successful probe first", - ); - } - await dependencies.sessionStore.clear(); -} - -async function removeAndVerifyMarkers(page: CdpRuntimeClient): Promise { - const managedRemaining = await page.evaluate(REMOVE_EXPRESSION); - const allRemaining = await page.evaluate(VERIFY_NO_MARKERS_EXPRESSION); - if (managedRemaining !== 0 || allRemaining !== 0) { - throw new RuntimeError("THEME_CLEANUP_FAILED", "probe marker cleanup failed"); - } -} - -export async function runProbePhaseOne( - dependencies: ProbePhaseOneDependencies, - recordEvidenceRequested: boolean, -): Promise { - await ensureNoPendingProbe(dependencies); - await dependencies.securePendingDirectory(); - let receipt: LaunchReceipt | null = null; - let page: ClosableCdpRuntimeClient | null = null; - try { - receipt = await dependencies.launch(); - await dependencies.waitForPort(receipt); - receipt = await dependencies.activateWindow(receipt); - const target = await dependencies.waitForTarget(receipt.cdp); - page = await dependencies.connectPage(target, receipt.cdp); - await dependencies.waitForAdapter(new CurrentCodexAdapter(page)); - const markerRoundTrip = await page.evaluate(PROBE_MARKER_EXPRESSION); - await removeAndVerifyMarkers(page); - if (!markerRoundTrip) { - throw new RuntimeError("THEME_CLEANUP_FAILED", "probe marker round trip failed"); - } - const observation = ProbeObservationSchema.parse({ - packageIdentity: "OpenAI.Codex", - packageVersion: receipt.install.packageVersion, - target: targetCategory(target.url), - capabilities: { main: true, navigation: true, composer: true }, - markerRoundTrip: true, - loopbackOnly: true, - windowActivationVerified: true, - officialAppearanceRestored: true, - }); - const now = dependencies.now(); - await dependencies.sessionStore.write({ - schemaVersion: 1, - sessionId: dependencies.newSessionId(), - status: "passed-awaiting-exit", - root: { pid: receipt.root.pid, startedAt: receipt.root.startedAt }, - cdp: receipt.cdp, - packageVersion: receipt.install.packageVersion, - observation, - recordEvidenceRequested, - createdAt: now, - updatedAt: now, - }); - return { - compatible: null, - phase: "awaiting-exit", - nextAction: "Quit Codex completely, then run runtime:probe -- --finalize.", - }; - } catch (error) { - let failure = error; - if (page) { - try { - await removeAndVerifyMarkers(page); - } catch (cleanupError) { - failure = cleanupError; - } - } - if (receipt) { - const now = dependencies.now(); - await dependencies.sessionStore.write({ - schemaVersion: 1, - sessionId: dependencies.newSessionId(), - status: "failed-awaiting-exit", - root: { pid: receipt.root.pid, startedAt: receipt.root.startedAt }, - cdp: receipt.cdp, - packageVersion: receipt.install.packageVersion, - failureCode: runtimeErrorCode(failure), - recordEvidenceRequested, - createdAt: now, - updatedAt: now, - }); - } - throw failure; - } finally { - page?.close(); - } -} diff --git a/runtime/windows/src/probe-session.ts b/runtime/windows/src/probe-session.ts deleted file mode 100644 index 238d40c..0000000 --- a/runtime/windows/src/probe-session.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { randomUUID } from "node:crypto"; -import { mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises"; -import { dirname } from "node:path"; -import { z } from "zod"; -import { RUNTIME_ERROR_CODES, RuntimeError } from "./errors.js"; -import { ProbeObservationSchema } from "./probe-evidence.js"; - -const baseShape = { - schemaVersion: z.literal(1), - sessionId: z.string().uuid(), - root: z.object({ - pid: z.number().int().positive(), - startedAt: z.string().datetime(), - }).strict(), - cdp: z.object({ - host: z.literal("127.0.0.1"), - port: z.number().int().min(1).max(65535), - }).strict(), - packageVersion: z.string().regex(/^\d+\.\d+\.\d+\.\d+$/), - recordEvidenceRequested: z.boolean(), - createdAt: z.string().datetime(), - updatedAt: z.string().datetime(), -}; - -const PassedPendingProbeSchema = z.object({ - ...baseShape, - status: z.literal("passed-awaiting-exit"), - observation: ProbeObservationSchema, -}).strict(); - -const FailedPendingProbeSchema = z.object({ - ...baseShape, - status: z.literal("failed-awaiting-exit"), - failureCode: z.enum(RUNTIME_ERROR_CODES), -}).strict(); - -export const PendingProbeSessionSchema = z.discriminatedUnion("status", [ - PassedPendingProbeSchema, - FailedPendingProbeSchema, -]); - -export type PendingProbeSession = z.infer; - -export class PendingProbeStore { - constructor(private readonly path: string) {} - - async read(): Promise { - let text: string; - try { - text = await readFile(this.path, "utf8"); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; - throw error; - } - try { - return PendingProbeSessionSchema.parse(JSON.parse(text)); - } catch (error) { - throw new RuntimeError( - "PROBE_PENDING_SESSION_INVALID", - error instanceof Error ? error.message : String(error), - ); - } - } - - async write(value: PendingProbeSession): Promise { - const session = PendingProbeSessionSchema.parse(value); - await mkdir(dirname(this.path), { recursive: true }); - const temporary = `${this.path}.${process.pid}-${randomUUID()}.tmp`; - await writeFile(temporary, `${JSON.stringify(session, null, 2)}\n`, "utf8"); - await rename(temporary, this.path); - } - - async clear(): Promise { - try { - await unlink(this.path); - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; - } - } -} diff --git a/runtime/windows/src/session/model.ts b/runtime/windows/src/session/model.ts deleted file mode 100644 index 74423f6..0000000 --- a/runtime/windows/src/session/model.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { z } from "zod"; -import { - ThemeIdSchema, - ThemeVersionSchema, -} from "@open-chatgpt-skin/theme-schema"; - -export const RuntimeStatusSchema = z.enum([ - "launching", - "active", - "paused", - "paused-incompatible", - "recovery-required", - "restoring", - "restored-awaiting-exit", - "restored-cleanup-required", -]); - -export type RuntimeStatus = z.infer; - -export const RuntimeOperationSchema = z.enum([ - "launch", - "switch", - "pause", - "resume", - "restore", -]); - -export type RuntimeOperation = z.infer; - -export const RuntimeThemeRefSchema = z.object({ - id: ThemeIdSchema, - version: ThemeVersionSchema, -}).strict(); - -export type RuntimeThemeRef = z.infer; - -export const PendingOperationSchema = z.object({ - kind: RuntimeOperationSchema, - requestId: z.string().uuid(), - startedAt: z.string().datetime(), - previousStatus: RuntimeStatusSchema.nullable(), - previousSelectedTheme: RuntimeThemeRefSchema.nullable(), - previousAppliedTheme: RuntimeThemeRefSchema.nullable(), - candidateTheme: RuntimeThemeRefSchema.nullable(), -}).strict(); - -export type PendingOperation = z.infer; - -export const RuntimeProcessSchema = z.object({ - pid: z.number().int().positive(), - startedAt: z.string().datetime(), -}).strict(); - -export const RuntimeCodexIdentitySchema = z.object({ - rootPid: z.number().int().positive(), - startedAt: z.string().datetime(), - executablePath: z.string().min(1), - packageRoot: z.string().min(1), - packageVersion: z.string().regex(/^\d+\.\d+\.\d+\.\d+$/), -}).strict(); - -export const RuntimeCdpIdentitySchema = z.object({ - host: z.literal("127.0.0.1"), - port: z.number().int().min(1).max(65_535), -}).strict(); - -export const RuntimeAdapterIdentitySchema = z.object({ - id: z.string().min(1), - version: z.literal(1), -}).strict(); diff --git a/runtime/windows/src/session/state-machine.ts b/runtime/windows/src/session/state-machine.ts deleted file mode 100644 index 48708ab..0000000 --- a/runtime/windows/src/session/state-machine.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { RuntimeError } from "../errors.js"; -import { - RuntimeSessionStateSchema, - type RuntimeSessionState, - type RuntimeStatus, -} from "../state.js"; - -export const RUNTIME_STATE_TRANSITIONS: Readonly> = { - launching: ["launching", "active", "recovery-required", "restored-awaiting-exit"], - active: ["active", "paused", "paused-incompatible", "recovery-required", "restoring"], - paused: ["paused", "active", "paused-incompatible", "recovery-required", "restoring"], - "paused-incompatible": [ - "paused-incompatible", - "paused", - "active", - "recovery-required", - "restoring", - ], - "recovery-required": ["recovery-required", "restoring"], - restoring: ["restoring", "active", "recovery-required", "restored-awaiting-exit"], - "restored-awaiting-exit": ["restored-awaiting-exit", "restored-cleanup-required"], - "restored-cleanup-required": ["restored-cleanup-required"], -}; - -export function transitionRuntimeState( - current: RuntimeSessionState, - next: RuntimeSessionState, -): RuntimeSessionState { - if (!RUNTIME_STATE_TRANSITIONS[current.status].includes(next.status)) { - throw new RuntimeError( - "RUNTIME_INVALID_STATE", - `Illegal Runtime transition: ${current.status} -> ${next.status}`, - ); - } - return RuntimeSessionStateSchema.parse(next); -} diff --git a/runtime/windows/src/state.ts b/runtime/windows/src/state.ts deleted file mode 100644 index fa3aee8..0000000 --- a/runtime/windows/src/state.ts +++ /dev/null @@ -1,243 +0,0 @@ -import { randomUUID } from "node:crypto"; -import { mkdir, open, readFile, rename, unlink } from "node:fs/promises"; -import { dirname } from "node:path"; -import { z } from "zod"; -import { - ControlCommandSchema, - ControlResponseSchema, -} from "./control/result.js"; -import { RuntimeError } from "./errors.js"; -import { - PendingOperationSchema, - RuntimeAdapterIdentitySchema, - RuntimeCdpIdentitySchema, - RuntimeCodexIdentitySchema, - RuntimeProcessSchema, - RuntimeStatusSchema, - RuntimeThemeRefSchema, - type RuntimeThemeRef, -} from "./session/model.js"; - -export * from "./session/model.js"; - -export const RecentRequestSchema = z.object({ - requestId: z.string().uuid(), - command: ControlCommandSchema, - response: ControlResponseSchema, - completedAt: z.string().datetime(), -}).strict(); - -export type RecentRequest = z.infer; - -function sameTheme( - left: RuntimeThemeRef | null, - right: RuntimeThemeRef | null, -): boolean { - return left !== null && right !== null && - left.id === right.id && left.version === right.version; -} - -function addStateIssue( - context: z.RefinementCtx, - message: string, - path: readonly (string | number)[] = [], -): void { - context.addIssue({ code: z.ZodIssueCode.custom, message, path: [...path] }); -} - -export const RuntimeSessionStateSchema = z.object({ - schemaVersion: z.literal(2), - sessionId: z.string().uuid(), - status: RuntimeStatusSchema, - runtime: RuntimeProcessSchema, - codex: RuntimeCodexIdentitySchema.nullable(), - cdp: RuntimeCdpIdentitySchema.nullable(), - adapter: RuntimeAdapterIdentitySchema.nullable(), - selectedTheme: RuntimeThemeRefSchema, - appliedTheme: RuntimeThemeRefSchema.nullable(), - skinApplied: z.boolean().nullable(), - pendingOperation: PendingOperationSchema.nullable(), - recentRequests: z.array(RecentRequestSchema).max(32), - createdAt: z.string().datetime(), - updatedAt: z.string().datetime(), -}).strict().superRefine((state, context) => { - if (state.status === "active") { - if (!state.codex || !state.cdp || !state.adapter) { - addStateIssue(context, "active state requires complete managed identities"); - } - if (state.skinApplied !== true || !sameTheme(state.selectedTheme, state.appliedTheme)) { - addStateIssue(context, "active state requires the selected theme to be verified as applied"); - } - } - - if (state.status !== "launching" && (!state.codex || !state.cdp)) { - addStateIssue(context, `${state.status} requires exact Codex and CDP identities`); - } - - if (["active", "paused", "paused-incompatible"].includes(state.status) && !state.adapter) { - addStateIssue(context, `${state.status} requires the last verified Adapter identity`); - } - - if (["paused", "paused-incompatible", "restored-awaiting-exit", "restored-cleanup-required"] - .includes(state.status) && - (state.appliedTheme !== null || state.skinApplied !== false)) { - addStateIssue(context, `${state.status} requires verified official appearance`); - } - - if (state.status === "recovery-required" && - (state.appliedTheme !== null || state.skinApplied !== null)) { - addStateIssue(context, "recovery-required must represent unknown appearance"); - } - - if (state.status === "restoring" && state.pendingOperation?.kind !== "restore") { - addStateIssue(context, "restoring requires a restore pending operation"); - } - - const byRequestId = new Map(); - for (const [index, record] of state.recentRequests.entries()) { - if (record.response.requestId !== record.requestId) { - addStateIssue(context, "recent response request ID must match its record", ["recentRequests", index]); - } - const prior = byRequestId.get(record.requestId); - if (prior && prior.command !== record.command) { - addStateIssue(context, "recent request IDs cannot belong to different commands", ["recentRequests", index]); - } - byRequestId.set(record.requestId, record); - } -}); - -export type RuntimeSessionState = z.infer; - -function sameRecentResponse(left: RecentRequest, right: RecentRequest): boolean { - return left.command === right.command && - left.completedAt === right.completedAt && - JSON.stringify(left.response) === JSON.stringify(right.response); -} - -function mergeRecentRequests( - current: readonly RecentRequest[], - incoming: readonly RecentRequest[], -): RecentRequest[] { - const result: RecentRequest[] = []; - const byRequestId = new Map(); - for (const record of [...current, ...incoming]) { - const validated = RecentRequestSchema.parse(record); - const prior = byRequestId.get(validated.requestId); - if (prior) { - if (!sameRecentResponse(prior, validated)) { - throw new RuntimeError( - "RUNTIME_SESSION_STALE", - "Recent Runtime response records conflict", - ); - } - continue; - } - byRequestId.set(validated.requestId, validated); - result.push(validated); - } - return result.slice(-32); -} - -export class RuntimeStateStore { - private writeQueue: Promise = Promise.resolve(); - - constructor(private readonly path: string) {} - - async read(): Promise { - await this.writeQueue; - return this.readCurrent(); - } - - async write(value: RuntimeSessionState): Promise { - const incoming = RuntimeSessionStateSchema.parse(value); - await this.enqueue(async () => { - const current = await this.readCurrent(); - const state = RuntimeSessionStateSchema.parse({ - ...incoming, - recentRequests: mergeRecentRequests(current?.recentRequests ?? [], incoming.recentRequests), - }); - await this.writeCurrent(state); - }); - } - - async appendRecentRequest(record: RecentRequest): Promise { - const incoming = RecentRequestSchema.parse(record); - return this.enqueue(async () => { - const current = await this.readCurrent(); - if (!current) return false; - - const prior = current.recentRequests.find((entry) => entry.requestId === incoming.requestId); - if (prior) { - if (!sameRecentResponse(prior, incoming)) { - throw new RuntimeError( - "RUNTIME_SESSION_STALE", - "Recent Runtime response records conflict", - ); - } - return true; - } - - const next = RuntimeSessionStateSchema.parse({ - ...current, - recentRequests: mergeRecentRequests(current.recentRequests, [incoming]), - updatedAt: incoming.completedAt, - }); - await this.writeCurrent(next); - return true; - }); - } - - async clear(): Promise { - await this.enqueue(async () => { - try { - await unlink(this.path); - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; - } - }); - } - - private async readCurrent(): Promise { - let text: string; - try { - text = await readFile(this.path, "utf8"); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; - throw error; - } - - try { - return RuntimeSessionStateSchema.parse(JSON.parse(text)); - } catch (error) { - throw new RuntimeError( - "RUNTIME_SESSION_STALE", - error instanceof Error ? error.message : String(error), - ); - } - } - - private async writeCurrent(state: RuntimeSessionState): Promise { - await mkdir(dirname(this.path), { recursive: true }); - const temporary = `${this.path}.${process.pid}-${randomUUID()}.tmp`; - const handle = await open(temporary, "wx", 0o600); - - try { - await handle.writeFile(`${JSON.stringify(state, null, 2)}\n`, "utf8"); - await handle.sync(); - } catch (error) { - await handle.close(); - await unlink(temporary); - throw error; - } - - await handle.close(); - await rename(temporary, this.path); - } - - private enqueue(operation: () => Promise): Promise { - const previous = this.writeQueue; - let release!: () => void; - this.writeQueue = new Promise((resolve) => { release = resolve; }); - return previous.then(operation).finally(release); - } -} diff --git a/runtime/windows/src/themes/ids.ts b/runtime/windows/src/themes/ids.ts deleted file mode 100644 index 50a7cfe..0000000 --- a/runtime/windows/src/themes/ids.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { z } from "zod"; - -export const RUNTIME_BUILTIN_THEME_IDS = [ - "future-idol-cyan", - "rose-carpet-star", - "mountain-mist", - "glacier-aurora", - "yua-mikami-starlight", -] as const; - -export const RuntimeBuiltinThemeIdSchema = z.enum(RUNTIME_BUILTIN_THEME_IDS); -export type RuntimeBuiltinThemeId = z.infer; diff --git a/runtime/windows/src/themes/runtime-theme-repository.ts b/runtime/windows/src/themes/runtime-theme-repository.ts deleted file mode 100644 index 164edd1..0000000 --- a/runtime/windows/src/themes/runtime-theme-repository.ts +++ /dev/null @@ -1,204 +0,0 @@ -import { readFile } from "node:fs/promises"; -import { extname, join } from "node:path"; -import { - loadThemeCatalog, - loadThemeDirectory, - ThemeValidationError, - ThemeStore, - unpackTheme, - type ValidatedThemeBundle, -} from "@open-chatgpt-skin/theme-core"; -import { - ThemeIdSchema, - ThemeVersionSchema, -} from "@open-chatgpt-skin/theme-schema"; -import { - compileTheme, - RuntimeThemeError, - type CompiledTheme, -} from "@open-chatgpt-skin/cdp-adapter"; -import { isRuntimeErrorCode, RuntimeError } from "../errors.js"; -import { - RUNTIME_BUILTIN_THEME_IDS, - RuntimeBuiltinThemeIdSchema, - type RuntimeBuiltinThemeId, -} from "./ids.js"; - -export interface RuntimeThemeDescriptor { - readonly id: string; - readonly name: string; - readonly version: string; - readonly ready: true; -} - -export type RuntimeThemeLookup = string | { - readonly id: string; - readonly version?: string; -}; - -export interface LoadedRuntimeTheme { - readonly descriptor: RuntimeThemeDescriptor; - readonly bundle: ValidatedThemeBundle; - readonly compiled: CompiledTheme; -} - -function runtimeValidationError(error: ThemeValidationError): RuntimeError { - return new RuntimeError( - isRuntimeErrorCode(error.code) ? error.code : "THEME_NOT_READY", - error.message, - ); -} - -export class RuntimeThemeRepository { - private readonly userStore: ThemeStore | null; - - constructor( - private readonly themesRoot: string, - userThemeStoreRoot?: string, - ) { - this.userStore = userThemeStoreRoot ? new ThemeStore(userThemeStoreRoot) : null; - } - - async list(): Promise { - const catalog = await loadThemeCatalog(this.themesRoot); - return RUNTIME_BUILTIN_THEME_IDS.map((id) => { - const entry = catalog.builtins.find((candidate) => candidate.id === id); - if (!entry || entry.kind !== "theme" || !entry.ready || entry.localOnly) { - throw new RuntimeError("THEME_NOT_READY", `Runtime theme is not ready: ${id}`); - } - return { id, name: entry.name, version: entry.version, ready: true }; - }); - } - - async importFile(themeFile: string): Promise { - if (!this.userStore) { - throw new RuntimeError("RUNTIME_ENVIRONMENT_INVALID", "Personal theme store is unavailable"); - } - if (extname(themeFile).toLowerCase() !== ".ocskin") { - throw new RuntimeError("THEME_NOT_READY", "Runtime imports only .ocskin theme files"); - } - - try { - const bundle = await unpackTheme(await readFile(themeFile)); - if (bundle.theme.kind !== "theme") { - throw new RuntimeError("THEME_NOT_READY", "Imported archive is not a complete theme"); - } - const descriptor = { - id: bundle.theme.id, - name: bundle.theme.name, - version: bundle.theme.version, - ready: true as const, - }; - this.compile(descriptor, bundle); - await this.userStore.install(bundle); - return descriptor; - } catch (error) { - if (error instanceof RuntimeError) throw error; - if (error instanceof ThemeValidationError) { - throw runtimeValidationError(error); - } - throw error; - } - } - - async load(value: RuntimeThemeLookup): Promise { - const stringLookup = typeof value === "string"; - const lookup = stringLookup - ? { id: value, version: undefined } - : { - id: ThemeIdSchema.parse(value.id), - version: value.version === undefined - ? undefined - : ThemeVersionSchema.parse(value.version), - }; - const catalog = await loadThemeCatalog(this.themesRoot); - const entry = [...catalog.builtins, ...catalog.recipes] - .find((candidate) => candidate.id === lookup.id); - - if (!entry) { - if (stringLookup) { - throw new RuntimeError("THEME_NOT_FOUND", `Unknown Runtime theme: ${lookup.id}`); - } - return this.loadPersonalTheme(lookup.id, lookup.version); - } - - const id = RuntimeBuiltinThemeIdSchema.safeParse(lookup.id); - if (!id.success || entry.kind !== "theme" || !entry.ready || entry.localOnly) { - throw new RuntimeError("THEME_NOT_READY", `Theme is not Runtime-ready: ${lookup.id}`); - } - if (lookup.version !== undefined && lookup.version !== entry.version) { - throw new RuntimeError( - "THEME_NOT_READY", - `Built-in theme version is unavailable: ${lookup.id}@${lookup.version}`, - ); - } - - let bundle: ValidatedThemeBundle; - try { - bundle = await loadThemeDirectory(join(this.themesRoot, ...entry.path.split("/"))); - } catch (error) { - if (error instanceof ThemeValidationError) throw runtimeValidationError(error); - throw error; - } - if (bundle.theme.id !== entry.id || bundle.theme.version !== entry.version) { - throw new RuntimeError("THEME_NOT_READY", `Theme metadata does not match catalog: ${lookup.id}`); - } - - return this.compile({ - id: id.data, - name: entry.name, - version: entry.version, - ready: true, - }, bundle); - } - - private async loadPersonalTheme( - id: string, - version: string | undefined, - ): Promise { - if (!version) { - throw new RuntimeError( - "THEME_NOT_READY", - `Personal themes require an exact version: ${id}`, - ); - } - if (!this.userStore) { - throw new RuntimeError("THEME_NOT_FOUND", `Unknown Runtime theme: ${id}`); - } - - let bundle: ValidatedThemeBundle; - try { - bundle = await this.userStore.readTheme({ id, version }); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") { - throw new RuntimeError("THEME_NOT_FOUND", `Unknown Runtime theme: ${id}@${version}`); - } - if (error instanceof ThemeValidationError) throw runtimeValidationError(error); - throw error; - } - if (bundle.theme.kind !== "theme" || bundle.theme.id !== id || - bundle.theme.version !== version) { - throw new RuntimeError("THEME_NOT_READY", `Personal theme is not Runtime-ready: ${id}@${version}`); - } - return this.compile({ - id, - name: bundle.theme.name, - version, - ready: true, - }, bundle); - } - - private compile( - descriptor: RuntimeThemeDescriptor, - bundle: ValidatedThemeBundle, - ): LoadedRuntimeTheme { - try { - return { descriptor, bundle, compiled: compileTheme(bundle) }; - } catch (error) { - if (error instanceof RuntimeThemeError && error.code === "THEME_RUNTIME_TOO_LARGE") { - throw new RuntimeError("THEME_RUNTIME_TOO_LARGE", error.message); - } - throw error; - } - } -} diff --git a/runtime/windows/src/types.ts b/runtime/windows/src/types.ts deleted file mode 100644 index 9d2d9b2..0000000 --- a/runtime/windows/src/types.ts +++ /dev/null @@ -1,75 +0,0 @@ -export interface ProcessIdentity { - readonly pid: number; - readonly parentPid: number; - readonly startedAt: string; - readonly executablePath: string; -} - -export interface InstallInspection { - readonly packageRoot: string; - readonly entryPath: string; - readonly identityName: string; - readonly packageVersion: string; - readonly packagePublisher: string; - readonly appId: string; - readonly entryRelativePath: string; - readonly entryPoint: string; - readonly packageSignatureStatus: string; - readonly packageSignerCommonName: string; - readonly catalogSignatureStatus: string; - readonly catalogSignerCommonName: string; - readonly entryBlockMapValid: boolean; - readonly resourceSignatureStatus: string; - readonly resourceSignerCommonName: string; -} - -export interface PortInspection { - readonly host: string; - readonly port: number; - readonly owningPid: number; - readonly ancestors: readonly number[]; -} - -export interface ManagedWindowInspection { - readonly rootExists: boolean; - /** Number of visible windows when the platform can observe them without broad consent. */ - readonly visibleWindowCount: number; - /** Provider-specific activation signal; CDP ownership is verified separately. */ - readonly activationReady: boolean; -} - -export type DesktopRuntimePlatform = "win32" | "darwin"; - -export interface DesktopRuntimeProvider { - readonly platform?: DesktopRuntimePlatform; - listCodexRoots(): Promise; - currentUserPackageRoots(): Promise; - inspectInstall(packageRoot: string): Promise; - inspectPort(port: number): Promise; - inspectProcessStartedAt(pid: number): Promise; - inspectRemoteDebuggingArguments(rootPid: number, startedAt: string): Promise<{ - readonly hasRemoteDebuggingAddress: boolean; - readonly hasRemoteDebuggingPort: boolean; - }>; - measureProcessCpuPercent(rootPid: number, startedAt: string, sampleMs: number): Promise; - activateCodexApplication(): Promise; - inspectManagedWindows( - rootPid: number, - startedAt: string, - ): Promise; - launch(executablePath: string, args: readonly string[]): Promise; - waitForExit(rootPid: number, startedAt: string, timeoutMs: number): Promise; - currentUserSid(): Promise; - secureDirectory(path: string): Promise; -} - -/** @deprecated Use DesktopRuntimeProvider for platform-neutral Runtime code. */ -export type WindowsRuntimeProvider = DesktopRuntimeProvider; - -export interface VerifiedCodexInstall extends InstallInspection {} - -export interface DiscoveryResult { - readonly install: VerifiedCodexInstall; - readonly source: "running" | "appx" | "application" | "cache"; - readonly runningRoot: ProcessIdentity | null; -} diff --git a/runtime/windows/src/windows/command-runner.ts b/runtime/windows/src/windows/command-runner.ts deleted file mode 100644 index 00e1acc..0000000 --- a/runtime/windows/src/windows/command-runner.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { spawn } from "node:child_process"; - -const MAX_OUTPUT_BYTES = 1024 * 1024; - -export interface CommandRequest { - readonly executable: string; - readonly args: readonly string[]; - readonly stdin: string; - readonly timeoutMs: number; - readonly shell: false; - readonly env?: Readonly>; -} - -export interface CommandResult { - readonly exitCode: number; - readonly stdout: string; - readonly stderr: string; -} - -export interface CommandRunner { - run(request: CommandRequest): Promise; -} - -export const nodeCommandRunner: CommandRunner = { - run: (request) => new Promise((resolve, reject) => { - const child = spawn(request.executable, [...request.args], { - shell: false, - windowsHide: true, - stdio: ["pipe", "pipe", "pipe"], - ...(request.env ? { env: { ...process.env, ...request.env } } : {}), - }); - let stdout = ""; - let stderr = ""; - let settled = false; - const fail = (error: Error) => { - if (settled) return; - settled = true; - clearTimeout(timer); - child.kill(); - reject(error); - }; - const append = (target: "stdout" | "stderr", chunk: string) => { - if (target === "stdout") stdout += chunk; - else stderr += chunk; - if (Buffer.byteLength(stdout) > MAX_OUTPUT_BYTES || Buffer.byteLength(stderr) > MAX_OUTPUT_BYTES) { - fail(new Error("command output exceeds 1 MB")); - } - }; - const timer = setTimeout(() => fail(new Error("command timed out")), request.timeoutMs); - child.stdout.setEncoding("utf8").on("data", (chunk: string) => append("stdout", chunk)); - child.stderr.setEncoding("utf8").on("data", (chunk: string) => append("stderr", chunk)); - child.once("error", fail); - child.once("close", (code) => { - if (settled) return; - settled = true; - clearTimeout(timer); - resolve({ exitCode: code ?? -1, stdout, stderr }); - }); - child.stdin.end(request.stdin); - }), -}; diff --git a/runtime/windows/src/windows/powershell-path.ts b/runtime/windows/src/windows/powershell-path.ts deleted file mode 100644 index 32eace9..0000000 --- a/runtime/windows/src/windows/powershell-path.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { join } from "node:path"; - -export const WINDOWS_POWERSHELL = join( - process.env.SystemRoot ?? "C:\\Windows", - "System32", - "WindowsPowerShell", - "v1.0", - "powershell.exe", -); diff --git a/runtime/windows/src/windows/powershell-provider.ts b/runtime/windows/src/windows/powershell-provider.ts deleted file mode 100644 index 585245f..0000000 --- a/runtime/windows/src/windows/powershell-provider.ts +++ /dev/null @@ -1,292 +0,0 @@ -import { spawn } from "node:child_process"; -import { mkdir } from "node:fs/promises"; -import { isAbsolute, relative, resolve } from "node:path"; -import { RuntimeError } from "../errors.js"; -import type { - InstallInspection, - ManagedWindowInspection, - PortInspection, - ProcessIdentity, - WindowsRuntimeProvider, -} from "../types.js"; -import { - type CommandRunner, - nodeCommandRunner, -} from "./command-runner.js"; -import { POWERSHELL_SCRIPT } from "./powershell-script.js"; -import { WINDOWS_POWERSHELL } from "./powershell-path.js"; - -const POWERSHELL_REQUEST_ENV = "OPEN_CHATGPT_SKIN_REQUEST_JSON"; -const POWERSHELL_STDIN_COMMAND = - "& ([scriptblock]::Create([Console]::In.ReadToEnd()))"; - -function arrayOf(value: T | readonly T[] | null): readonly T[] { - if (value === null) return []; - return Array.isArray(value) ? value : [value as T]; -} - -function wait(delayMs: number): Promise { - return new Promise((resolveWait) => setTimeout(resolveWait, delayMs)); -} - -function assertProcessIdentity(rootPid: number, startedAt: string): void { - if (!Number.isInteger(rootPid) || rootPid < 1) { - throw new RuntimeError("RUNTIME_ENVIRONMENT_INVALID", "Process ID must be a positive integer"); - } - if (!startedAt.endsWith("Z") || !Number.isFinite(Date.parse(startedAt))) { - throw new RuntimeError("RUNTIME_ENVIRONMENT_INVALID", "Process start time is invalid"); - } -} - -function remoteDebuggingArguments(value: unknown): { - readonly hasRemoteDebuggingAddress: boolean; - readonly hasRemoteDebuggingPort: boolean; -} { - if (!value || typeof value !== "object" || Array.isArray(value)) { - throw new RuntimeError("PROCESS_INSPECTION_DENIED", "Debug argument inspection was invalid"); - } - const result = value as Readonly>; - if (Object.keys(result).length !== 2 || - typeof result.hasRemoteDebuggingAddress !== "boolean" || - typeof result.hasRemoteDebuggingPort !== "boolean") { - throw new RuntimeError("PROCESS_INSPECTION_DENIED", "Debug argument inspection was invalid"); - } - return { - hasRemoteDebuggingAddress: result.hasRemoteDebuggingAddress, - hasRemoteDebuggingPort: result.hasRemoteDebuggingPort, - }; -} - -export function windowsPathsEqual(left: string, right: string): boolean { - const normalize = (value: string) => value.replaceAll("/", "\\").toLowerCase(); - return normalize(left) === normalize(right); -} - -export class PowerShellWindowsProvider implements WindowsRuntimeProvider { - readonly platform = "win32" as const; - constructor( - private readonly runner: CommandRunner = nodeCommandRunner, - private readonly dataRoot?: string, - ) {} - - private async invoke(request: Readonly>): Promise { - let result; - try { - const requestJson = JSON.stringify(request); - result = await this.runner.run({ - executable: WINDOWS_POWERSHELL, - args: [ - "-NoLogo", - "-NoProfile", - "-NonInteractive", - "-Command", - POWERSHELL_STDIN_COMMAND, - ], - shell: false, - stdin: POWERSHELL_SCRIPT, - env: { [POWERSHELL_REQUEST_ENV]: requestJson }, - timeoutMs: 10_000, - }); - } catch (error) { - throw new RuntimeError( - "PROCESS_INSPECTION_DENIED", - error instanceof Error ? error.message : String(error), - ); - } - if (result.exitCode !== 0) { - throw new RuntimeError( - "PROCESS_INSPECTION_DENIED", - result.stderr.trim() || `PowerShell inspection exited ${result.exitCode}`, - ); - } - const text = result.stdout.trim(); - return (text ? JSON.parse(text) : null) as T; - } - - async listCodexRoots(): Promise { - return arrayOf(await this.invoke({ - action: "listCodexRoots", - })); - } - - async currentUserPackageRoots(): Promise { - return arrayOf(await this.invoke({ - action: "currentUserPackageRoots", - })); - } - - inspectInstall(packageRoot: string): Promise { - return this.invoke({ action: "inspectInstall", packageRoot }); - } - - inspectPort(port: number): Promise { - return this.invoke({ action: "inspectPort", port }); - } - - async inspectProcessStartedAt(pid: number): Promise { - if (!Number.isInteger(pid) || pid < 1) { - throw new RuntimeError("RUNTIME_ENVIRONMENT_INVALID", "Process ID must be a positive integer"); - } - const startedAt = await this.invoke({ action: "inspectProcessStartedAt", pid }); - if (startedAt === null) return null; - if (typeof startedAt !== "string" || !startedAt.endsWith("Z") || - !Number.isFinite(Date.parse(startedAt))) { - throw new RuntimeError("PROCESS_INSPECTION_DENIED", "Process start time inspection was invalid"); - } - return startedAt; - } - - async inspectRemoteDebuggingArguments( - rootPid: number, - startedAt: string, - ): Promise<{ - readonly hasRemoteDebuggingAddress: boolean; - readonly hasRemoteDebuggingPort: boolean; - }> { - assertProcessIdentity(rootPid, startedAt); - return remoteDebuggingArguments(await this.invoke({ - action: "inspectRemoteDebuggingArguments", - rootPid, - startedAt, - })); - } - - async measureProcessCpuPercent( - rootPid: number, - startedAt: string, - sampleMs: number, - ): Promise { - assertProcessIdentity(rootPid, startedAt); - if (!Number.isInteger(sampleMs) || sampleMs < 1_000 || sampleMs > 5_000) { - throw new RuntimeError( - "RUNTIME_ENVIRONMENT_INVALID", - "CPU sample duration must be between one and five seconds", - ); - } - const value = await this.invoke({ - action: "measureProcessCpuPercent", - rootPid, - startedAt, - sampleMs, - }); - if (typeof value !== "number" || !Number.isFinite(value) || value < 0) { - throw new RuntimeError("PROCESS_INSPECTION_DENIED", "CPU inspection was invalid"); - } - return value; - } - - async activateCodexApplication(): Promise { - let result: { readonly activated: true }; - try { - result = await this.invoke<{ readonly activated: true }>({ - action: "activateCodexApplication", - }); - } catch (error) { - throw new RuntimeError( - "CODEX_WINDOW_ACTIVATION_FAILED", - error instanceof Error ? error.message : String(error), - "Start Codex from the Windows Start menu, then Quit from the Codex menu.", - ); - } - if (result.activated !== true) { - throw new RuntimeError( - "CODEX_WINDOW_ACTIVATION_FAILED", - "Codex AUMID activation did not report success", - "Start Codex from the Windows Start menu, then Quit from the Codex menu.", - ); - } - } - - async inspectManagedWindows( - rootPid: number, - startedAt: string, - ): Promise { - const value = await this.invoke>({ - action: "inspectManagedWindows", - rootPid, - startedAt, - }); - if (typeof value.rootExists !== "boolean" || !Number.isInteger(value.visibleWindowCount) || - value.visibleWindowCount < 0) { - throw new RuntimeError("PROCESS_INSPECTION_DENIED", "Window inspection was invalid"); - } - return { - ...value, - activationReady: value.rootExists && value.visibleWindowCount > 0, - }; - } - - currentUserSid(): Promise { - return this.invoke({ action: "currentUserSid" }); - } - - async secureDirectory(path: string): Promise { - if (!this.dataRoot) { - throw new RuntimeError( - "RUNTIME_ENVIRONMENT_INVALID", - "PowerShellWindowsProvider requires a configured data root", - ); - } - const root = resolve(this.dataRoot); - const target = resolve(path); - const child = relative(root, target); - if (child.startsWith("..") || isAbsolute(child)) { - throw new RuntimeError( - "RUNTIME_ENVIRONMENT_INVALID", - "refusing to secure a directory outside the Runtime data root", - ); - } - await mkdir(target, { recursive: true }); - await this.invoke<{ readonly secured: true }>({ action: "secureDirectory", path: target }); - } - - async launch(executablePath: string, args: readonly string[]): Promise { - const requestedAt = Date.now(); - await new Promise((resolveLaunch, reject) => { - const child = spawn(executablePath, [...args], { - shell: false, - detached: true, - windowsHide: true, - stdio: "ignore", - }); - child.once("error", reject); - child.once("spawn", () => { - child.unref(); - resolveLaunch(); - }); - }).catch((error: unknown) => { - throw new RuntimeError( - "CODEX_LAUNCH_FAILED", - error instanceof Error ? error.message : String(error), - ); - }); - - const deadline = Date.now() + 10_000; - while (Date.now() < deadline) { - const candidates = (await this.listCodexRoots()).filter((processInfo) => - windowsPathsEqual(processInfo.executablePath, executablePath) && - Date.parse(processInfo.startedAt) >= requestedAt - 1_000 - ); - if (candidates.length === 1) return candidates[0]!; - if (candidates.length > 1) { - throw new RuntimeError("CODEX_LAUNCH_FAILED", "multiple new Codex root processes appeared"); - } - await wait(100); - } - throw new RuntimeError("CODEX_LAUNCH_FAILED", "Codex root process did not appear"); - } - - async waitForExit(rootPid: number, startedAt: string, timeoutMs: number): Promise { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - const status = await this.invoke<{ readonly exists: boolean }>({ - action: "processExists", - pid: rootPid, - startedAt, - }); - if (!status.exists) return true; - await wait(100); - } - return false; - } -} diff --git a/runtime/windows/src/windows/powershell-script.ts b/runtime/windows/src/windows/powershell-script.ts deleted file mode 100644 index 5151264..0000000 --- a/runtime/windows/src/windows/powershell-script.ts +++ /dev/null @@ -1,436 +0,0 @@ -export const POWERSHELL_SCRIPT = String.raw` -$ErrorActionPreference = "Stop" -$requestJson = [Environment]::GetEnvironmentVariable( - "OPEN_CHATGPT_SKIN_REQUEST_JSON", - [EnvironmentVariableTarget]::Process -) -if ([string]::IsNullOrWhiteSpace($requestJson)) { - throw "Runtime inspection request is missing" -} -$request = $requestJson | ConvertFrom-Json -[Environment]::SetEnvironmentVariable( - "OPEN_CHATGPT_SKIN_REQUEST_JSON", - $null, - [EnvironmentVariableTarget]::Process -) - -$nativeProcessSource = @' -using System; -using System.Collections.Generic; -using System.ComponentModel; -using System.Runtime.InteropServices; - -namespace OpenChatGPTSkin { - public sealed class NativeProcessEntry { - public int ProcessId { get; set; } - public int ParentProcessId { get; set; } - public string ExecutableName { get; set; } - } - - public static class NativeProcessInspector { - private const uint SnapshotProcesses = 0x00000002; - private const uint QueryLimitedInformation = 0x00001000; - private const int ProcessCommandLineInformation = 60; - - [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] - private struct ProcessEntry32 { - public uint Size; - public uint Usage; - public uint ProcessId; - public IntPtr DefaultHeapId; - public uint ModuleId; - public uint Threads; - public uint ParentProcessId; - public int BasePriority; - public uint Flags; - [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 260)] - public string ExecutableName; - } - - [StructLayout(LayoutKind.Sequential)] - private struct UnicodeString { - public ushort Length; - public ushort MaximumLength; - public IntPtr Buffer; - } - - [DllImport("kernel32.dll", SetLastError = true)] - private static extern IntPtr CreateToolhelp32Snapshot(uint flags, uint processId); - - [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] - private static extern bool Process32First(IntPtr snapshot, ref ProcessEntry32 entry); - - [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] - private static extern bool Process32Next(IntPtr snapshot, ref ProcessEntry32 entry); - - [DllImport("kernel32.dll", SetLastError = true)] - private static extern IntPtr OpenProcess(uint access, bool inheritHandle, int processId); - - [DllImport("kernel32.dll")] - private static extern bool CloseHandle(IntPtr handle); - - [DllImport("ntdll.dll")] - private static extern int NtQueryInformationProcess( - IntPtr process, - int informationClass, - IntPtr information, - int informationLength, - out int returnLength - ); - - public static NativeProcessEntry[] Snapshot() { - IntPtr snapshot = CreateToolhelp32Snapshot(SnapshotProcesses, 0); - if (snapshot == new IntPtr(-1)) { - throw new Win32Exception(Marshal.GetLastWin32Error()); - } - try { - var entries = new List(); - var entry = new ProcessEntry32(); - entry.Size = (uint)Marshal.SizeOf(typeof(ProcessEntry32)); - if (!Process32First(snapshot, ref entry)) { - throw new Win32Exception(Marshal.GetLastWin32Error()); - } - do { - entries.Add(new NativeProcessEntry { - ProcessId = checked((int)entry.ProcessId), - ParentProcessId = checked((int)entry.ParentProcessId), - ExecutableName = entry.ExecutableName ?? string.Empty, - }); - entry.Size = (uint)Marshal.SizeOf(typeof(ProcessEntry32)); - } while (Process32Next(snapshot, ref entry)); - int error = Marshal.GetLastWin32Error(); - if (error != 18) { - throw new Win32Exception(error); - } - return entries.ToArray(); - } finally { - CloseHandle(snapshot); - } - } - - public static string ReadCommandLine(int processId) { - IntPtr process = OpenProcess(QueryLimitedInformation, false, processId); - if (process == IntPtr.Zero) { - throw new Win32Exception(Marshal.GetLastWin32Error()); - } - try { - int length; - NtQueryInformationProcess( - process, - ProcessCommandLineInformation, - IntPtr.Zero, - 0, - out length - ); - if (length < Marshal.SizeOf(typeof(UnicodeString))) { - throw new InvalidOperationException("Process command line length is invalid"); - } - IntPtr buffer = Marshal.AllocHGlobal(length); - try { - int status = NtQueryInformationProcess( - process, - ProcessCommandLineInformation, - buffer, - length, - out length - ); - if (status != 0) { - throw new InvalidOperationException("Process command line query failed"); - } - var value = (UnicodeString)Marshal.PtrToStructure( - buffer, - typeof(UnicodeString) - ); - return value.Buffer == IntPtr.Zero - ? string.Empty - : Marshal.PtrToStringUni(value.Buffer, value.Length / 2) ?? string.Empty; - } finally { - Marshal.FreeHGlobal(buffer); - } - } finally { - CloseHandle(process); - } - } - } -} -'@ - -[void](Add-Type -TypeDefinition $nativeProcessSource -ErrorAction Stop) - -function Get-ProcessSnapshotById() { - $byId = @{} - foreach ($entry in @([OpenChatGPTSkin.NativeProcessInspector]::Snapshot())) { - $byId[[int]$entry.ProcessId] = $entry - } - return $byId -} - -function Get-ProcessAncestors([int]$processId, $snapshotById) { - $ancestors = @() - $cursor = $processId - $seen = @{} - while ($cursor -gt 0 -and -not $seen.ContainsKey($cursor)) { - $seen[$cursor] = $true - $ancestors += $cursor - if (-not $snapshotById.ContainsKey($cursor)) { break } - $cursor = [int]$snapshotById[$cursor].ParentProcessId - } - return $ancestors -} - -function Get-SignatureSummary([string]$path) { - $signature = Get-AuthenticodeSignature -LiteralPath $path - $signer = "" - if ($null -ne $signature.SignerCertificate) { - $signer = $signature.SignerCertificate.GetNameInfo( - [Security.Cryptography.X509Certificates.X509NameType]::SimpleName, - $false - ) - } - return [pscustomobject]@{ status = [string]$signature.Status; signer = $signer } -} - -function Test-AppxBlockMapFile([string]$root, [string]$relativePath) { - [xml]$blockMap = Get-Content -Raw -LiteralPath ([IO.Path]::Combine($root, "AppxBlockMap.xml")) - $normalized = $relativePath.Replace("/", "\") - $file = @($blockMap.BlockMap.File) | - Where-Object { ([string]$_.Name).Replace("/", "\") -ieq $normalized } | - Select-Object -First 1 - if ($null -eq $file) { return $false } - $path = [IO.Path]::Combine($root, $relativePath) - $info = Get-Item -LiteralPath $path - if ([int64]$file.Size -ne $info.Length) { return $false } - $stream = [IO.File]::OpenRead($path) - $sha = [Security.Cryptography.SHA256]::Create() - try { - foreach ($block in @($file.Block)) { - $count = [int][Math]::Min(65536, $stream.Length - $stream.Position) - if ($count -le 0) { return $false } - $buffer = New-Object byte[] $count - $offset = 0 - while ($offset -lt $count) { - $read = $stream.Read($buffer, $offset, $count - $offset) - if ($read -le 0) { return $false } - $offset += $read - } - $actual = [Convert]::ToBase64String($sha.ComputeHash($buffer)) - if ($actual -cne [string]$block.Hash) { return $false } - } - return $stream.Position -eq $stream.Length - } finally { - $sha.Dispose() - $stream.Dispose() - } -} - -function Get-ExactProcess([int]$processId, [string]$startedAt) { - if ($processId -lt 1 -or [string]::IsNullOrWhiteSpace($startedAt)) { - throw "Process identity is invalid" - } - $process = Get-Process -Id $processId -ErrorAction SilentlyContinue - if ($null -eq $process -or - $process.StartTime.ToUniversalTime().ToString("o") -ne $startedAt) { - throw "Process identity does not match" - } - return $process -} - -switch ($request.action) { - "currentUserPackageRoots" { - $roots = @(Get-AppxPackage -Name "OpenAI.Codex" -ErrorAction SilentlyContinue | - ForEach-Object { $_.InstallLocation }) - ConvertTo-Json -InputObject $roots -Compress - } - "listCodexRoots" { - $all = @([OpenChatGPTSkin.NativeProcessInspector]::Snapshot() | - Where-Object { $_.ExecutableName -ieq "ChatGPT.exe" }) - $ids = @{} - foreach ($item in $all) { $ids[[int]$item.ProcessId] = $true } - $roots = @($all | Where-Object { -not $ids.ContainsKey([int]$_.ParentProcessId) } | - ForEach-Object { - $process = Get-Process -Id ([int]$_.ProcessId) -ErrorAction Stop - [pscustomobject]@{ - pid = [int]$_.ProcessId - parentPid = [int]$_.ParentProcessId - startedAt = $process.StartTime.ToUniversalTime().ToString("o") - executablePath = $process.Path - } - }) - ConvertTo-Json -InputObject $roots -Compress - } - "inspectInstall" { - $root = [IO.Path]::GetFullPath([string]$request.packageRoot) - $manifestPath = [IO.Path]::Combine($root, "AppxManifest.xml") - [xml]$manifest = Get-Content -Raw -LiteralPath $manifestPath - $application = @($manifest.Package.Applications.Application) | - Where-Object { $_.Id -eq "App" } | Select-Object -First 1 - if ($null -eq $application) { throw "App entry is missing" } - $entry = [IO.Path]::GetFullPath([IO.Path]::Combine($root, [string]$application.Executable)) - $packageSignature = Get-SignatureSummary ([IO.Path]::Combine($root, "AppxSignature.p7x")) - $catalogSignature = Get-SignatureSummary ([IO.Path]::Combine($root, "AppxMetadata\CodeIntegrity.cat")) - $resourceSignature = Get-SignatureSummary ([IO.Path]::Combine($root, "app\resources\codex.exe")) - $entryBlockMapValid = Test-AppxBlockMapFile $root ([string]$application.Executable) - [pscustomobject]@{ - packageRoot = $root - entryPath = $entry - identityName = [string]$manifest.Package.Identity.Name - packageVersion = [string]$manifest.Package.Identity.Version - packagePublisher = [string]$manifest.Package.Identity.Publisher - appId = [string]$application.Id - entryRelativePath = ([string]$application.Executable).Replace("\", "/") - entryPoint = [string]$application.EntryPoint - packageSignatureStatus = $packageSignature.status - packageSignerCommonName = $packageSignature.signer - catalogSignatureStatus = $catalogSignature.status - catalogSignerCommonName = $catalogSignature.signer - entryBlockMapValid = [bool]$entryBlockMapValid - resourceSignatureStatus = $resourceSignature.status - resourceSignerCommonName = $resourceSignature.signer - } | ConvertTo-Json -Compress - } - "inspectPort" { - $connections = @(Get-NetTCPConnection -State Listen -LocalPort ([int]$request.port) -ErrorAction SilentlyContinue) - if ($connections.Count -eq 0) { $null | ConvertTo-Json -Compress; break } - if ($connections.Count -ne 1 -or $connections[0].LocalAddress -ne "127.0.0.1") { - throw "port is not bound exclusively to 127.0.0.1" - } - $connection = $connections[0] - $snapshotById = Get-ProcessSnapshotById - $ancestors = @(Get-ProcessAncestors ([int]$connection.OwningProcess) $snapshotById) - [pscustomobject]@{ - host = "127.0.0.1" - port = [int]$request.port - owningPid = [int]$connection.OwningProcess - ancestors = $ancestors - } | ConvertTo-Json -Compress - } - "activateCodexApplication" { - $aumid = "OpenAI.Codex_2p2nqsd0c76g0!App" - $shell = New-Object -ComObject Shell.Application - try { - $folder = $shell.NameSpace("shell:AppsFolder") - if ($null -eq $folder) { throw "AppsFolder is unavailable" } - $item = $folder.ParseName($aumid) - if ($null -eq $item) { throw "official Codex AUMID is unavailable" } - $item.InvokeVerb("open") - [pscustomobject]@{ activated = $true } | ConvertTo-Json -Compress - } finally { - if ($null -ne $shell) { - [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($shell) - } - } - } - "inspectManagedWindows" { - $rootPid = [int]$request.rootPid - $root = Get-Process -Id $rootPid -ErrorAction SilentlyContinue - $rootExists = $false - if ($null -ne $root) { - $rootExists = $root.StartTime.ToUniversalTime().ToString("o") -eq - [string]$request.startedAt - } - $visible = 0 - if ($rootExists) { - $snapshotById = Get-ProcessSnapshotById - foreach ($candidate in @(Get-Process -Name "ChatGPT" -ErrorAction SilentlyContinue)) { - if ($candidate.MainWindowHandle -eq 0) { continue } - $ancestors = @(Get-ProcessAncestors ([int]$candidate.Id) $snapshotById) - if ($ancestors -contains $rootPid) { - $visible += 1 - } - } - } - [pscustomobject]@{ - rootExists = [bool]$rootExists - visibleWindowCount = [int]$visible - } | ConvertTo-Json -Compress - } - "inspectProcessStartedAt" { - $pidText = [string]$request.pid - if ($pidText -notmatch "^[1-9][0-9]*$") { throw "PID must be a positive integer" } - $processId = [int]$pidText - $process = Get-Process -Id $processId -ErrorAction SilentlyContinue - if ($null -eq $process) { $null | ConvertTo-Json -Compress; break } - $process.StartTime.ToUniversalTime().ToString("o") | ConvertTo-Json -Compress - } - "inspectRemoteDebuggingArguments" { - $pidText = [string]$request.rootPid - if ($pidText -notmatch "^[1-9][0-9]*$") { throw "Process identity is invalid" } - $processId = [int]$pidText - [void](Get-ExactProcess $processId ([string]$request.startedAt)) - $commandLine = [OpenChatGPTSkin.NativeProcessInspector]::ReadCommandLine($processId) - [void](Get-ExactProcess $processId ([string]$request.startedAt)) - [pscustomobject]@{ - hasRemoteDebuggingAddress = [regex]::IsMatch( - $commandLine, - "(?i)(?:^|\s)--remote-debugging-address(?:=|\s|$)" - ) - hasRemoteDebuggingPort = [regex]::IsMatch( - $commandLine, - "(?i)(?:^|\s)--remote-debugging-port(?:=|\s|$)" - ) - } | ConvertTo-Json -Compress - } - "measureProcessCpuPercent" { - $pidText = [string]$request.rootPid - $sampleText = [string]$request.sampleMs - if ($pidText -notmatch "^[1-9][0-9]*$" -or $sampleText -notmatch "^[0-9]+$") { - throw "CPU inspection input is invalid" - } - $processId = [int]$pidText - $sampleMs = [int]$sampleText - if ($sampleMs -lt 1000 -or $sampleMs -gt 5000) { - throw "CPU sample duration is invalid" - } - $process = Get-ExactProcess $processId ([string]$request.startedAt) - $firstCpuMs = $process.TotalProcessorTime.TotalMilliseconds - $watch = [Diagnostics.Stopwatch]::StartNew() - Start-Sleep -Milliseconds $sampleMs - $process.Refresh() - if ($process.StartTime.ToUniversalTime().ToString("o") -ne [string]$request.startedAt) { - throw "Process identity does not match" - } - $elapsedMs = $watch.Elapsed.TotalMilliseconds - $secondCpuMs = $process.TotalProcessorTime.TotalMilliseconds - $processors = [Environment]::ProcessorCount - if ($elapsedMs -le 0 -or $processors -lt 1) { throw "CPU sample is invalid" } - $percent = (($secondCpuMs - $firstCpuMs) / $elapsedMs / $processors) * 100 - if ([double]::IsNaN($percent) -or [double]::IsInfinity($percent) -or $percent -lt 0) { - throw "CPU sample is invalid" - } - [Math]::Round($percent, 2) | ConvertTo-Json -Compress - } - "processExists" { - $process = Get-Process -Id ([int]$request.pid) -ErrorAction SilentlyContinue - $exists = $false - if ($null -ne $process) { - $exists = $process.StartTime.ToUniversalTime().ToString("o") -eq [string]$request.startedAt - } - [pscustomobject]@{ exists = $exists } | ConvertTo-Json -Compress - } - "currentUserSid" { - [Security.Principal.WindowsIdentity]::GetCurrent().User.Value | ConvertTo-Json -Compress - } - "secureDirectory" { - $path = [IO.Path]::GetFullPath([string]$request.path) - $sid = [Security.Principal.WindowsIdentity]::GetCurrent().User.Value - $userGrant = "*" + $sid + ":(OI)(CI)F" - $systemGrant = "*S-1-5-18:(OI)(CI)F" - & icacls.exe $path /reset | Out-Null - if ($LASTEXITCODE -ne 0) { throw "icacls reset failed with exit code $LASTEXITCODE" } - & icacls.exe $path /inheritance:r /grant:r $userGrant $systemGrant | Out-Null - if ($LASTEXITCODE -ne 0) { throw "icacls ACL update failed with exit code $LASTEXITCODE" } - $directory = [System.IO.DirectoryInfo]::new($path) - $verified = $directory.GetAccessControl() - $expected = @($sid, "S-1-5-18") | Sort-Object - $actual = @($verified.Access | ForEach-Object { - $_.IdentityReference.Translate([Security.Principal.SecurityIdentifier]).Value - } | Sort-Object -Unique) - if (-not $verified.AreAccessRulesProtected -or - ($actual -join "|") -ne ($expected -join "|")) { - throw "secureDirectory ACL verification failed" - } - [pscustomobject]@{ secured = $true } | ConvertTo-Json -Compress - } - default { throw "Unsupported action" } -} -`; diff --git a/scripts/contracts/baseline-runner.ts b/scripts/contracts/baseline-runner.ts index f3964cb..b999390 100644 --- a/scripts/contracts/baseline-runner.ts +++ b/scripts/contracts/baseline-runner.ts @@ -1,66 +1,25 @@ -import { execFile } from "node:child_process"; -import { randomUUID } from "node:crypto"; -import { access, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; +import { readFile } from "node:fs/promises"; import { join, resolve } from "node:path"; import { pathToFileURL } from "node:url"; -import { promisify } from "node:util"; -import { - loadThemeCatalog, - loadThemeDirectory, - packTheme, - ThemeStore, - unpackTheme, - validateThemeBundle, - type ValidatedThemeBundle, -} from "../../packages/theme-core/src/index.js"; -import { - parseThemeDocument, - type ThemeDocument, -} from "../../packages/theme-schema/src/index.js"; -import { - STUDIO_SECURITY_HEADERS, -} from "../../packages/theme-studio-core/src/index.js"; -import { - CONTROL_MAX_FRAME_BYTES, - ControlRequestSchema, - ControlResponseSchema, - ControllerLock, - ControllerLockRecordSchema, - createRuntimePaths, - decodeControlFrame, - encodeControlFrame, - controlEndpointForIdentity, - PowerShellWindowsProvider, - RuntimeControlDispatcher, - RuntimeStateStore, - RuntimeSessionStateSchema, - sendControlRequest, - startSecureControlServer, - TrustedInstallStore, - TrustedCodexInstallSchema, - type ControlRequest, - type RecentRequest, - type RuntimeController, - type RuntimeStateStore, - type RuntimeStatusView, -} from "../../runtime/windows/src/index.js"; -import { - DraftRecordSchema, - startThemeStudioServer, - ThemeStudioWorkspace, -} from "../../runtime/theme-studio-service/src/index.js"; -import { strToU8, zipSync } from "fflate"; export type BaselineImplementation = "node" | "go"; export type BaselineSuite = "studio" | "runtime" | "theme" | "data"; export interface BaselineSuiteResult { - readonly implementation: BaselineImplementation; + readonly implementation: "node"; readonly suite: BaselineSuite; readonly result: Readonly>; } +interface FrozenNodeBaseline { + readonly implementation: "node"; + readonly suites: readonly BaselineSuiteResult[]; + readonly review: { + readonly status: "reviewed"; + readonly knownNodeBugs: readonly string[]; + }; +} + export class BaselineRunnerError extends Error { constructor(public readonly code: string, message: string) { super(message); @@ -68,629 +27,45 @@ export class BaselineRunnerError extends Error { } } -const execFileAsync = promisify(execFile); - -const stoppedStatus: RuntimeStatusView = { - status: "stopped", - controllerAvailable: true, - selectedTheme: null, - appliedTheme: null, - skinApplied: false, - packageVersion: null, - operation: null, - nextAction: "None", -}; - -function activeStatus(themeId: string, version: string): RuntimeStatusView { - return { - ...stoppedStatus, - status: "active", - selectedTheme: { id: themeId, version }, - appliedTheme: { id: themeId, version }, - skinApplied: true, - packageVersion: "26.707.12708.0", - }; -} - -async function runStudioSuite(): Promise> { - const token = "a".repeat(64); - const server = await startThemeStudioServer({ - studioVersion: "0.2.0", - runtimeStatus: async () => stoppedStatus, - indexHtml: "OpenChatGPTSkin baseline", - newToken: () => token, - }); - try { - const unauthenticated = await fetch(`${server.origin}/api/bootstrap`); - const exchange = await fetch(`${server.origin}/api/session`, { - method: "POST", - headers: { "Content-Type": "application/json", Origin: server.origin }, - body: JSON.stringify({ token }), - }); - const cookie = exchange.headers.get("set-cookie")?.split(";", 1)[0]; - if (!cookie) throw new Error("Studio baseline session cookie is missing"); - const bootstrap = await fetch(`${server.origin}/api/bootstrap`, { - headers: { Cookie: cookie }, - }); - const bootstrapBody = await bootstrap.json() as { readonly protocolVersion?: unknown }; - const events = await fetch(`${server.origin}/api/events`, { - headers: { Cookie: cookie }, - }); - const reader = events.body?.getReader(); - if (!reader) throw new Error("Studio baseline SSE body is missing"); - const first = await reader.read(); - await reader.cancel(); - const eventText = new TextDecoder().decode(first.value); - const dataLine = eventText.split("\n").find((line) => line.startsWith("data: ")); - if (!dataLine) throw new Error("Studio baseline SSE event is missing"); - const event = JSON.parse(dataLine.slice("data: ".length)) as { readonly kind?: unknown }; - - return { - protocolVersion: bootstrapBody.protocolVersion, - sessionStatus: exchange.status, - bootstrapStatus: bootstrap.status, - unauthenticatedStatus: unauthenticated.status, - securityHeaderCount: STUDIO_SECURITY_HEADERS.filter((header) => - bootstrap.headers.has(header) - ).length, - eventKind: event.kind, - }; - } finally { - await server.close(); - } -} - -function runtimeController(): RuntimeController { - const controller = { - status: async () => stoppedStatus, - launch: async (id: string, _requestId: string, version = "1.3.0") => - activeStatus(id, version), - switchTheme: async (id: string, _requestId: string, version = "1.3.0") => - activeStatus(id, version), - pause: async () => ({ ...stoppedStatus, status: "paused" as const }), - resume: async () => activeStatus("mountain-mist", "1.3.0"), - restore: async () => ({ ...stoppedStatus, status: "restored-awaiting-exit" as const }), - startExitMonitoring: () => {}, - }; - return controller as unknown as RuntimeController; -} - -function runtimeState(): RuntimeStateStore { - const recentRequests: RecentRequest[] = []; - return { - read: async () => ({ recentRequests }), - appendRecentRequest: async (record: RecentRequest) => { - if (!recentRequests.some(({ requestId }) => requestId === record.requestId)) { - recentRequests.push(record); - } - return true; - }, - } as unknown as RuntimeStateStore; -} - -function baselineControlPlatform(): "win32" | "darwin" { - if (process.platform === "win32" || process.platform === "darwin") { - return process.platform; - } - if (process.platform === "linux") { - // Linux CI exercises the same UID, chmod(0600), dev/inode and stream - // framing contract through a Unix-domain socket. Native macOS proof remains - // a separate Gate A requirement and is never inferred from this corpus run. - return "darwin"; - } - throw new BaselineRunnerError( - "NODE_BASELINE_TRANSPORT_UNAVAILABLE", - `The Node baseline control transport does not support ${process.platform}`, - ); -} - -async function runRuntimeSuite(): Promise> { - const dispatcher = new RuntimeControlDispatcher(runtimeController(), runtimeState()); - const platform = baselineControlPlatform(); - const identity = platform === "win32" - ? await new PowerShellWindowsProvider().currentUserSid() - : (() => { - const uid = process.getuid?.(); - if (!Number.isInteger(uid) || uid! < 0) { - throw new BaselineRunnerError( - "NODE_BASELINE_TRANSPORT_UNAVAILABLE", - "The Node baseline Unix socket identity is unavailable", - ); - } - return `uid:${uid}`; - })(); - const endpoint = controlEndpointForIdentity(identity, platform); - const control = await startSecureControlServer({ - platform, - userIdentity: identity, - dispatch: (request) => dispatcher.dispatch(request), - }); - const dispatch = async (value: unknown) => sendControlRequest({ - sid: identity, - endpoint, - request: ControlRequestSchema.parse(value), - }); - const statusRequest: ControlRequest = { - protocolVersion: 1, - requestId: "00000000-0000-4000-8000-000000000201", - command: "status", - params: {}, - }; - const launchRequest: ControlRequest = { - protocolVersion: 1, - requestId: "00000000-0000-4000-8000-000000000202", - command: "launch", - params: { themeId: "mountain-mist" }, - }; - try { - const status = ControlResponseSchema.parse(await dispatch(statusRequest)); - const launched = ControlResponseSchema.parse(await dispatch(launchRequest)); - const replayed = ControlResponseSchema.parse(await dispatch(launchRequest)); - const conflicting = ControlResponseSchema.parse(await dispatch({ - ...launchRequest, - command: "pause", - params: {}, - })); - if (!status.ok || !launched.ok || !replayed.ok || conflicting.ok) { - throw new Error("Runtime baseline responses have unexpected success states"); - } - return { - protocolVersion: status.protocolVersion, - frameLimitBytes: CONTROL_MAX_FRAME_BYTES, - transportSecurityVerified: control.securityVerified, - status: status.result.status, - launchStatus: launched.result.status, - replayed: JSON.stringify(replayed) === JSON.stringify(launched), - conflictingRequestCode: conflicting.error.code, - }; - } finally { - await control.close(); - } -} - -function legacyTheme(theme: ThemeDocument, version: 1 | 2 | 3): unknown { - const value = structuredClone(theme) as Record; - value.schemaVersion = version; - delete value.home; - delete value.composition; - delete value.interfaceImages; - const typography = value.typography as Record; - for (const key of [ - "displayFamily", - "displayFontAssetKey", - "displaySize", - "displayWeight", - "displayLineHeight", - "displayLetterSpacing", - ]) delete typography[key]; - if (version <= 2) { - const assets = value.assets as Record; - delete assets.profileAvatar; - delete assets.suggestionIcons; - delete assets.projectIcons; - } - if (version === 1) { - const colors = value.colors as Record; - for (const key of ["textSecondary", "link", "inputText", "placeholder", "codeText"]) { - delete colors[key]; - } +async function readFrozenNodeBaseline(workspaceRootInput: string): Promise { + const workspaceRoot = resolve(workspaceRootInput); + const path = join(workspaceRoot, "host", "go", "testdata", "v0.2.0", "golden", "node.json"); + const value = JSON.parse(await readFile(path, "utf8")) as FrozenNodeBaseline; + const suites = value.suites?.map(({ suite }) => suite); + if (value.implementation !== "node" || value.review?.status !== "reviewed" || + JSON.stringify(suites) !== JSON.stringify(["studio", "runtime", "theme", "data"])) { + throw new BaselineRunnerError("NODE_BASELINE_GOLDEN_INVALID", "The reviewed Node v0.2.0 golden result is invalid"); } return value; } -function errorCode(error: unknown): string { - return error && typeof error === "object" && "code" in error && - typeof (error as { readonly code?: unknown }).code === "string" - ? (error as { readonly code: string }).code - : error instanceof Error - ? error.name - : "UNKNOWN"; -} - -async function rejectsWithCode( - operation: () => Promise, - expectedCode: string, -): Promise { - try { - await operation(); - return false; - } catch (error) { - const actual = errorCode(error); - if (actual !== expectedCode) { - throw new Error(`Expected ${expectedCode}, received ${actual}`); - } - return true; - } -} - -async function runThemeSuite(workspaceRoot: string): Promise> { - const themesRoot = join(workspaceRoot, "themes"); - const catalog = await loadThemeCatalog(themesRoot); - const bundles: ValidatedThemeBundle[] = []; - for (const entry of catalog.builtins) { - bundles.push(await loadThemeDirectory(join(themesRoot, ...entry.path.split("/")))); - } - let archiveRoundTrips = 0; - for (const bundle of bundles) { - const unpacked = await unpackTheme(packTheme(bundle)); - if (unpacked.theme.id !== bundle.theme.id || unpacked.theme.version !== bundle.theme.version) { - throw new Error(`Theme archive identity changed: ${bundle.theme.id}`); - } - archiveRoundTrips += 1; - } - const migrationBase = bundles.find(({ theme }) => theme.id === "mountain-mist")?.theme; - if (!migrationBase) throw new Error("Theme migration baseline is missing mountain-mist"); - const migratedVersions = ([1, 2, 3] as const).map((version) => { - const parsed = parseThemeDocument(legacyTheme(migrationBase, version)); - if (parsed.schemaVersion !== 4) throw new Error(`Theme v${version} migration failed`); - return version; - }); - parseThemeDocument(migrationBase); - migratedVersions.push(4); - - let archiveNegativeCode = ""; - try { - await unpackTheme(zipSync({ "../secret.txt": strToU8("secret") })); - } catch (error) { - archiveNegativeCode = errorCode(error); - } - let futureVersionCode = ""; - try { - parseThemeDocument({ ...migrationBase, schemaVersion: 99 }); - } catch (error) { - futureVersionCode = errorCode(error); - } - const v4 = bundles.find(({ theme }) => theme.id === "yua-mikami-starlight")?.theme; - if (!v4) throw new Error("Theme v4 capability baseline is missing"); - return { - builtins: bundles.map(({ theme }) => theme.id), - migratedVersions, - archiveRoundTrips, - archiveNegativeCode, - futureVersionCode, - v4Capabilities: { - welcomeLocales: Object.keys(v4.home?.welcome.localized ?? {}).sort(), - displayFont: Boolean(v4.typography.displayFontAssetKey), - profileAvatar: Boolean(v4.assets.profileAvatar), - suggestionIcons: Object.keys(v4.assets.suggestionIcons ?? {}).length, - compositionLayers: v4.composition.layers.length, - }, - }; -} - -interface DataCorpus { - readonly schemaVersion: 1; - readonly draft: { - readonly draftId: string; - readonly revision: number; - readonly dirty: boolean; - readonly past: number; - readonly future: number; - readonly savedRef: { readonly id: string; readonly version: string }; - }; - readonly personalTheme: { readonly id: string; readonly versions: readonly string[] }; - readonly runtime: { - readonly sessionId: string; - readonly status: "restored-awaiting-exit"; - readonly theme: { readonly id: string; readonly version: string }; - }; - readonly trustedCacheSchemaVersion: 1; - readonly controllerLockSchemaVersion: 1; - readonly corruptRecordCount: number; - readonly corruptStoreCount: number; -} - -async function runDataSuite(workspaceRoot: string): Promise> { - const corpusPath = join( - workspaceRoot, - "host", "go", "testdata", "v0.2.0", "data-root", "corpus.json", - ); - const corpusText = await readFile(corpusPath, "utf8"); - const corpus = JSON.parse(corpusText) as DataCorpus; - const bundle = await loadThemeDirectory(join(workspaceRoot, "themes", "builtin", "mountain-mist")); - const timestamp = "2026-07-24T00:00:00.000Z"; - const temporaryRoot = await mkdtemp(join(tmpdir(), "openchatgptskin-data-corpus-")); - let personalVersions = 0; - let draftRecord!: ReturnType; - let savedDraftWasClean = false; - let undoExposedRedo = false; - let redoConsumedFuture = false; - let persistedRuntime!: ReturnType; - let persistedTrusted!: ReturnType; - let lockRecord!: ReturnType; - let corruptStoresRejected = 0; - try { - const paths = createRuntimePaths(temporaryRoot, workspaceRoot); - let firstId = true; - const workspace = new ThemeStudioWorkspace({ - paths, - runtimeStatus: async () => stoppedStatus, - applyRuntimeTheme: async (ref) => activeStatus(ref.id, ref.version), - restoreRuntimeTheme: async () => stoppedStatus, - now: () => timestamp, - newId: () => { - if (firstId) { - firstId = false; - return corpus.draft.draftId; - } - return randomUUID(); - }, - }); - await workspace.initialize(); - const source = (await workspace.listThemes()).themes.find(({ ref }) => - ref.id === bundle.theme.id && ref.version === bundle.theme.version - ); - if (!source) throw new Error("Data compatibility source theme is missing"); - let draft = await workspace.createDraft({ - source: { source: source.source, ref: source.ref }, - themeId: corpus.draft.savedRef.id, - name: "Baseline dataRoot draft", - }); - draft = await workspace.updateDraft({ - draftId: draft.draftId, - expectedRevision: draft.revision, - theme: { ...draft.theme, name: "Baseline dataRoot draft v2" }, - }); - draft = await workspace.updateDraft({ - draftId: draft.draftId, - expectedRevision: draft.revision, - theme: { ...draft.theme, description: "reviewed baseline draft" }, - }); - const undone = await workspace.undo({ - draftId: draft.draftId, - expectedRevision: draft.revision, - }); - undoExposedRedo = undone.redoAvailable; - const redone = await workspace.redo({ - draftId: undone.draftId, - expectedRevision: undone.revision, - }); - redoConsumedFuture = !redone.redoAvailable; - const saved = await workspace.saveVersion({ - draftId: redone.draftId, - expectedRevision: redone.revision, - }); - savedDraftWasClean = !saved.draft.dirty && saved.ref.id === corpus.draft.savedRef.id; - draft = await workspace.updateDraft({ - draftId: saved.draft.draftId, - expectedRevision: saved.draft.revision, - theme: { ...saved.draft.theme, description: "dirty after reviewed save" }, - }); - const draftPath = join(paths.themeStudioDraftDirectory, draft.draftId, "draft.json"); - draftRecord = DraftRecordSchema.parse(JSON.parse(await readFile(draftPath, "utf8"))); - if (draftRecord.revision !== corpus.draft.revision || - draftRecord.dirty !== corpus.draft.dirty || - draftRecord.past.length !== corpus.draft.past || - draftRecord.future.length !== corpus.draft.future || - draftRecord.savedRef?.id !== corpus.draft.savedRef.id || - draftRecord.savedRef?.version !== corpus.draft.savedRef.version || - !savedDraftWasClean || !undoExposedRedo || !redoConsumedFuture) { - throw new Error("Data compatibility draft lifecycle changed"); - } - - const store = new ThemeStore(paths.themeStoreDirectory); - for (const version of corpus.personalTheme.versions) { - const theme = parseThemeDocument({ - ...bundle.theme, - id: corpus.personalTheme.id, - version, - }); - await store.install(validateThemeBundle(theme, bundle.files)); - } - personalVersions = (await store.list()).filter(({ id }) => id === corpus.personalTheme.id).length; - - const runtime = RuntimeSessionStateSchema.parse({ - schemaVersion: 2, - sessionId: corpus.runtime.sessionId, - status: corpus.runtime.status, - runtime: { pid: process.pid, startedAt: timestamp }, - codex: { - rootPid: process.pid, - startedAt: timestamp, - executablePath: "C:/Program Files/WindowsApps/OpenAI.Codex/app/ChatGPT.exe", - packageRoot: "C:/Program Files/WindowsApps/OpenAI.Codex", - packageVersion: "26.707.12708.0", - }, - cdp: { host: "127.0.0.1", port: 55123 }, - adapter: { id: "baseline", version: 1 }, - selectedTheme: corpus.runtime.theme, - appliedTheme: null, - skinApplied: false, - pendingOperation: null, - recentRequests: [], - createdAt: timestamp, - updatedAt: timestamp, - }); - const stateStore = new RuntimeStateStore(paths.sessionFile); - await stateStore.write(runtime); - persistedRuntime = RuntimeSessionStateSchema.parse(await stateStore.read()); - - const trusted = TrustedCodexInstallSchema.parse({ - schemaVersion: corpus.trustedCacheSchemaVersion, - packageRoot: "C:/Program Files/WindowsApps/OpenAI.Codex", - entryPath: "C:/Program Files/WindowsApps/OpenAI.Codex/app/ChatGPT.exe", - identityName: "OpenAI.Codex", - packageVersion: "26.707.12708.0", - packagePublisher: "CN=50BDFD77-8903-4850-9FFE-6E8522F64D5B", - appId: "App", - entryRelativePath: "app/ChatGPT.exe", - entryPoint: "Windows.FullTrustApplication", - packageSignatureStatus: "Valid", - packageSignerCommonName: "50BDFD77-8903-4850-9FFE-6E8522F64D5B", - catalogSignatureStatus: "Valid", - catalogSignerCommonName: "50BDFD77-8903-4850-9FFE-6E8522F64D5B", - entryBlockMapValid: true, - resourceSignatureStatus: "Valid", - resourceSignerCommonName: "OpenAI OpCo, LLC", - verifiedAt: timestamp, - }); - const trustedStore = new TrustedInstallStore(paths.installCache); - await trustedStore.write(trusted); - persistedTrusted = TrustedCodexInstallSchema.parse(await trustedStore.read()); - - const lock = await ControllerLock.acquire( - paths.controllerLockFile, - { pid: process.pid, startedAt: timestamp }, - async (pid) => pid === process.pid ? timestamp : null, - ); - lockRecord = ControllerLockRecordSchema.parse(JSON.parse( - await readFile(paths.controllerLockFile, "utf8"), - )); - await lock.release(); - - await writeFile(draftPath, "{}\n", "utf8"); - if (await rejectsWithCode( - () => workspace.openDraft(draft.draftId), - "STUDIO_DRAFT_INVALID", - )) corruptStoresRejected += 1; - await writeFile(paths.sessionFile, "{}\n", "utf8"); - if (await rejectsWithCode( - () => stateStore.read(), - "RUNTIME_SESSION_STALE", - )) corruptStoresRejected += 1; - await writeFile(paths.installCache, "{}\n", "utf8"); - if (await trustedStore.read() === null) corruptStoresRejected += 1; - await writeFile(paths.controllerLockFile, "{}\n", "utf8"); - if (await rejectsWithCode( - () => ControllerLock.acquire( - paths.controllerLockFile, - { pid: process.pid, startedAt: timestamp }, - async () => null, - ), - "RUNTIME_SESSION_STALE", - )) corruptStoresRejected += 1; - } finally { - await rm(temporaryRoot, { recursive: true, force: true }); - } - - const corruptRecordsRejected = [ - DraftRecordSchema.safeParse({ ...draftRecord, schemaVersion: 9 }), - RuntimeSessionStateSchema.safeParse({ ...persistedRuntime, skinApplied: true }), - TrustedCodexInstallSchema.safeParse({ ...persistedTrusted, identityName: "Unknown.Product" }), - ControllerLockRecordSchema.safeParse({ ...lockRecord, startedAt: "invalid" }), - ].filter(({ success }) => !success).length; - const sensitiveValuesFound = /(?:[a-z]:[\\/]Users[\\/]|\/Users\/|\/home\/|token|conversation)/i - .test(corpusText); - if (corruptRecordsRejected !== corpus.corruptRecordCount) { - throw new Error("Data compatibility corrupt-record count changed"); - } - if (corruptStoresRejected !== corpus.corruptStoreCount) { - throw new Error("Data compatibility corrupt-store count changed"); - } - return { - draft: { - schemaVersion: draftRecord.schemaVersion, - dirty: draftRecord.dirty, - revision: draftRecord.revision, - past: draftRecord.past.length, - future: draftRecord.future.length, - savedRef: draftRecord.savedRef, - savedDraftWasClean, - undoExposedRedo, - redoConsumedFuture, - }, - personalVersions, - runtimeTerminalStatus: persistedRuntime.status, - trustedCacheSchemaVersion: persistedTrusted.schemaVersion, - controllerLockSchemaVersion: lockRecord.schemaVersion, - corruptRecordsRejected, - corruptStoresRejected, - sensitiveValuesFound, - }; -} - -async function runGoSuite( - workspaceRoot: string, - suite: BaselineSuite, -): Promise> { - const executable = process.env.OPENCHATGPTSKIN_GO_HOST ?? join( - workspaceRoot, - "host", - "go", - "bin", - process.platform === "win32" ? "OpenChatGPTSkin.exe" : "OpenChatGPTSkin", - ); - try { - await access(executable); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") { - throw new BaselineRunnerError( - "GO_BASELINE_IMPLEMENTATION_UNAVAILABLE", - "The Go baseline implementation is not available yet", - ); - } - throw error; - } - const corpusRoot = join(workspaceRoot, "host", "go", "testdata", "v0.2.0"); - const { stdout } = await execFileAsync(executable, [ - "contract-baseline", - "--suite", - suite, - "--corpus-root", - corpusRoot, - ], { encoding: "utf8", windowsHide: true }); - const value = JSON.parse(stdout) as unknown; - if (typeof value !== "object" || value === null || Array.isArray(value)) { +function assertReadOnlyImplementation(implementation: BaselineImplementation): asserts implementation is "node" { + if (implementation !== "node") { throw new BaselineRunnerError( - "GO_BASELINE_RESULT_INVALID", - "The Go baseline implementation returned an invalid result", + "GO_BASELINE_IMPLEMENTATION_UNAVAILABLE", + "The compatibility runner exposes only the frozen v0.2.0 Node result; current Go behavior is tested directly", ); } - return value as Record; } export async function runBaselineSuite( - workspaceRootInput: string, + workspaceRoot: string, implementation: BaselineImplementation, suite: BaselineSuite, ): Promise { - const workspaceRoot = resolve(workspaceRootInput); - const result = implementation === "go" - ? await runGoSuite(workspaceRoot, suite) - : suite === "studio" - ? await runStudioSuite() - : suite === "runtime" - ? await runRuntimeSuite() - : suite === "theme" - ? await runThemeSuite(workspaceRoot) - : await runDataSuite(workspaceRoot); - return { implementation, suite, result }; + assertReadOnlyImplementation(implementation); + const baseline = await readFrozenNodeBaseline(workspaceRoot); + const result = baseline.suites.find((entry) => entry.suite === suite); + if (!result) throw new BaselineRunnerError("NODE_BASELINE_GOLDEN_INVALID", `Missing frozen Node suite: ${suite}`); + return result; } export async function runBaselineCorpus( workspaceRoot: string, implementation: BaselineImplementation, -): Promise<{ - readonly implementation: BaselineImplementation; - readonly suites: readonly BaselineSuiteResult[]; - readonly review: { - readonly status: "reviewed"; - readonly knownNodeBugs: readonly string[]; - }; -}> { - const suites: BaselineSuiteResult[] = []; - for (const suite of ["studio", "runtime", "theme", "data"] as const) { - suites.push(await runBaselineSuite(workspaceRoot, implementation, suite)); - } - const result = { - implementation, - suites, - review: { status: "reviewed" as const, knownNodeBugs: [] as readonly string[] }, - }; - if (implementation === "node") { - const goldenPath = join( - workspaceRoot, - "host", "go", "testdata", "v0.2.0", "golden", "node.json", - ); - const golden = JSON.parse(await readFile(goldenPath, "utf8")) as unknown; - if (JSON.stringify(result) !== JSON.stringify(golden)) { - throw new BaselineRunnerError( - "NODE_BASELINE_GOLDEN_MISMATCH", - "The Node v0.2.0 observable result differs from the reviewed golden result", - ); - } - } - return result; +): Promise { + assertReadOnlyImplementation(implementation); + return readFrozenNodeBaseline(workspaceRoot); } function parseArguments(arguments_: readonly string[]): { diff --git a/scripts/contracts/baseline.ts b/scripts/contracts/baseline.ts index 6105444..afd6372 100644 --- a/scripts/contracts/baseline.ts +++ b/scripts/contracts/baseline.ts @@ -6,7 +6,7 @@ import { STUDIO_PROTOCOL_VERSION } from import { THEME_SCHEMA_VERSION } from "../../packages/theme-schema/src/index.js"; import { CONTROL_PROTOCOL_VERSION } from - "../../runtime/windows/src/control/result.js"; + "../../packages/runtime-contract/src/index.js"; import { z } from "zod"; const SHA256_PATTERN = /^[0-9a-f]{64}$/; @@ -212,7 +212,7 @@ export async function verifyFrozenBaseline( if (baseline.contracts.studioProtocol !== STUDIO_PROTOCOL_VERSION || baseline.contracts.runtimeControl !== CONTROL_PROTOCOL_VERSION || baseline.contracts.theme !== THEME_SCHEMA_VERSION) { - throw new Error("Baseline protocol versions do not match the Node author sources"); + throw new Error("Baseline protocol versions do not match the checked-in author sources"); } const themesRoot = join(workspaceRoot, "themes"); diff --git a/scripts/contracts/build.ts b/scripts/contracts/build.ts index d474b53..af53121 100644 --- a/scripts/contracts/build.ts +++ b/scripts/contracts/build.ts @@ -60,12 +60,12 @@ import { RuntimeSessionStateSchema, RuntimeStatusViewSchema, TrustedCodexInstallSchema, -} from "../../runtime/windows/src/index.js"; +} from "../../packages/runtime-contract/src/index.js"; import { DraftRecordSchema, PersistedDraftRecordSchema, -} from "../../runtime/theme-studio-service/src/workspace.js"; -import { ReleaseManifestSchema } from "../release/payload.js"; +} from "../../packages/theme-studio-core/src/index.js"; +import { ReleaseManifestSchema } from "../release/manifest.js"; import type { ZodTypeAny } from "zod"; import { zodToJsonSchema } from "zod-to-json-schema"; @@ -93,13 +93,13 @@ const DATA_COMPATIBILITY_CASES = [ { name: "runtime state v2", valid: true, schema: "runtimeState", version: 2 }, { name: "trusted install cache v1", valid: true, schema: "trustedInstall", version: 1 }, { name: "controller lock v1", valid: true, schema: "controllerLock", version: 1 }, - { name: "release manifest v1", valid: true, schema: "releaseManifest", version: 1 }, + { name: "release manifest v2", valid: true, schema: "releaseManifest", version: 2 }, { name: "unknown draft version", valid: false, schema: "draftRecord", expectedErrorCode: "DATA_SCHEMA_INVALID", expectedPath: "/schemaVersion" }, { name: "dirty draft without history", valid: false, schema: "draftRecord", expectedErrorCode: "DATA_SCHEMA_INVALID", expectedPath: "/past" }, { name: "runtime request ID mismatch", valid: false, schema: "runtimeState", expectedErrorCode: "RUNTIME_INVALID_STATE", expectedPath: "/recentRequests/0/requestId" }, { name: "trusted cache identity mismatch", valid: false, schema: "trustedInstall", expectedErrorCode: "CODEX_IDENTITY_INVALID", expectedPath: "/packagePublisher" }, { name: "lock without process identity", valid: false, schema: "controllerLock", expectedErrorCode: "RUNTIME_SESSION_STALE", expectedPath: "/startedAt" }, - { name: "release file hash malformed", valid: false, schema: "releaseManifest", expectedErrorCode: "RELEASE_MANIFEST_INVALID", expectedPath: "/files/sha256" }, + { name: "release file hash malformed", valid: false, schema: "releaseManifest", expectedErrorCode: "RELEASE_MANIFEST_INVALID", expectedPath: "/files/0/sha256" }, ] as const; function portable(path: string): string { diff --git a/scripts/dev/start-go-studio.ts b/scripts/dev/start-go-studio.ts new file mode 100644 index 0000000..7bbd4ae --- /dev/null +++ b/scripts/dev/start-go-studio.ts @@ -0,0 +1,66 @@ +import { execFile, spawn, type ChildProcess } from "node:child_process"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); +const workspaceRoot = resolve("."); +const viteOrigin = "http://127.0.0.1:5173"; +const buildRoot = await mkdtemp(join(tmpdir(), "openchatgptskin-dev-")); +const executable = join(buildRoot, process.platform === "win32" ? "OpenChatGPTSkin.exe" : "OpenChatGPTSkin"); +const children = new Set(); +let stopping = false; + +function stop(exitCode: number): void { + if (stopping) return; + stopping = true; + for (const child of children) { + if (child.exitCode === null && child.signalCode === null) child.kill(); + } + void rm(buildRoot, { recursive: true, force: true }).finally(() => { + process.exitCode = exitCode; + }); +} + +async function waitForVite(): Promise { + const deadline = Date.now() + 30_000; + while (Date.now() < deadline) { + try { + const response = await fetch(viteOrigin, { signal: AbortSignal.timeout(1_000) }); + if (response.ok) return; + } catch { + // Vite owns readiness; connection refusal is expected during startup. + } + await new Promise((resolveDelay) => setTimeout(resolveDelay, 100)); + } + throw new Error("Vite did not become ready within 30 seconds"); +} + +try { + await execFileAsync("go", [ + "-C", "host/go", "build", "-buildvcs=false", "-tags", "nodynamic", + "-o", executable, "./cmd/openchatgptskin", + ], { cwd: workspaceRoot, windowsHide: true }); + + const vite = spawn(process.platform === "win32" ? "npm.cmd" : "npm", [ + "run", "dev", "-w", "@open-chatgpt-skin/theme-studio", "--", + "--host", "127.0.0.1", "--port", "5173", "--strictPort", + ], { cwd: workspaceRoot, stdio: "inherit", windowsHide: true }); + children.add(vite); + vite.once("exit", (code) => stop(code ?? 1)); + await waitForVite(); + + const host = spawn(executable, ["studio", "--dev", "--vite-origin", viteOrigin, "--no-open"], { + cwd: workspaceRoot, + stdio: "inherit", + windowsHide: true, + }); + children.add(host); + host.once("exit", (code) => stop(code ?? 1)); + process.once("SIGINT", () => stop(130)); + process.once("SIGTERM", () => stop(143)); +} catch (error) { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + stop(1); +} diff --git a/scripts/release/accept-go-release.ts b/scripts/release/accept-go-release.ts new file mode 100644 index 0000000..4dfce14 --- /dev/null +++ b/scripts/release/accept-go-release.ts @@ -0,0 +1,13 @@ +import { resolve } from "node:path"; +import { acceptGoReleasePackages } from "./go-release.js"; +import { releaseOption } from "./options.js"; + +try { + const args = process.argv.slice(2); + const output = resolve(releaseOption(args, "--output") ?? "artifacts/release"); + const result = await acceptGoReleasePackages(output, args.includes("--require-all-native")); + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); +} catch (error) { + process.stderr.write(`${JSON.stringify({ error: { code: "GO_RELEASE_ACCEPTANCE_FAILED", message: error instanceof Error ? error.message : String(error) } }, null, 2)}\n`); + process.exitCode = 1; +} diff --git a/scripts/release/accept-go-spike.ts b/scripts/release/accept-go-spike.ts deleted file mode 100644 index 6ebe3fb..0000000 --- a/scripts/release/accept-go-spike.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { resolve } from "node:path"; -import { acceptGoSpikePackages } from "./go-spike.js"; -import { releaseOption } from "./options.js"; - -try { - const args = process.argv.slice(2); - const output = resolve(releaseOption(args, "--output") ?? "artifacts/go-spike"); - const result = await acceptGoSpikePackages(output, args.includes("--require-all-native")); - process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); -} catch (error) { - process.stderr.write(`${JSON.stringify({ error: { code: "GO_SPIKE_ACCEPTANCE_FAILED", message: error instanceof Error ? error.message : String(error) } }, null, 2)}\n`); - process.exitCode = 1; -} diff --git a/scripts/release/accept-macos-distribution.ts b/scripts/release/accept-macos-distribution.ts deleted file mode 100644 index fdb672c..0000000 --- a/scripts/release/accept-macos-distribution.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { resolve } from "node:path"; -import { - acceptMacDmg, - MacDmgCleanupError, -} from "./macos-acceptance.js"; -import { - releaseOption, - requiredReleaseOption, -} from "./options.js"; -import { writeReleaseReport } from "./report.js"; - -async function main(): Promise { - const args = process.argv.slice(2); - const report = await acceptMacDmg( - resolve(requiredReleaseOption(args, "--dmg")), - ); - await writeReleaseReport(releaseOption(args, "--report"), report); - process.stdout.write(`${JSON.stringify(report)}\n`); -} - -void main().catch(async (error: unknown) => { - const code = error instanceof MacDmgCleanupError - ? "MACOS_DMG_CLEANUP_FAILED" - : "MACOS_DMG_ACCEPTANCE_FAILED"; - const publicError = { - code, - message: "The macOS distribution did not pass acceptance.", - nextAction: "Review the redacted acceptance diagnostics and retry.", - }; - try { - await writeReleaseReport( - releaseOption(process.argv.slice(2), "--report"), - { scenario: "macos-dmg", error: { code } }, - ); - } catch { - process.stderr.write(`${JSON.stringify({ - error: { - code: "MACOS_DMG_REPORT_FAILED", - message: "The macOS acceptance report could not be written.", - }, - })}\n`); - process.exitCode = 1; - return; - } - process.stderr.write(`${JSON.stringify({ error: publicError })}\n`); - process.exitCode = 1; -}); diff --git a/scripts/release/accept-portable.ts b/scripts/release/accept-portable.ts deleted file mode 100644 index f17a37f..0000000 --- a/scripts/release/accept-portable.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { mkdtemp, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { basename, join, resolve } from "node:path"; -import { acceptReleasePayload } from "./acceptance.js"; -import { releaseOption, requiredReleaseOption } from "./options.js"; -import { writeReleaseReport } from "./report.js"; -import { runReleaseCommand } from "./release-command.js"; - -async function main(): Promise { - const args = process.argv.slice(2); - const archive = resolve(requiredReleaseOption(args, "--archive")); - const extractionRoot = await mkdtemp(join(tmpdir(), "open-chatgpt-skin-archive-")); - try { - if (basename(archive).endsWith(".zip")) { - await runReleaseCommand( - "7z", - ["x", "-y", `-o${extractionRoot}`, archive], - ); - } else if (basename(archive).endsWith(".tar.gz")) { - await runReleaseCommand( - "tar", - ["-xzf", archive, "-C", extractionRoot], - ); - } else { - throw new Error(`Unsupported Release archive: ${basename(archive)}`); - } - const report = await acceptReleasePayload( - join(extractionRoot, "OpenChatGPTSkin"), - "portable-archive", - ); - const archiveReport = { archive: basename(archive), ...report }; - await writeReleaseReport(releaseOption(args, "--report"), archiveReport); - process.stdout.write(`${JSON.stringify(archiveReport)}\n`); - } finally { - await rm(extractionRoot, { recursive: true, force: true }); - } -} - -void main().catch(async (error: unknown) => { - await writeReleaseReport( - releaseOption(process.argv.slice(2), "--report"), - { - scenario: "portable-archive", - error: { code: "RELEASE_ARCHIVE_ACCEPTANCE_FAILED" }, - }, - ); - process.stderr.write(`${JSON.stringify({ - error: { - code: "RELEASE_ARCHIVE_ACCEPTANCE_FAILED", - message: error instanceof Error ? error.message : "Release archive acceptance failed", - }, - })}\n`); - process.exitCode = 1; -}); diff --git a/scripts/release/accept-release.ts b/scripts/release/accept-release.ts deleted file mode 100644 index e4835b3..0000000 --- a/scripts/release/accept-release.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { resolve } from "node:path"; -import { - acceptReleasePayload, - RELEASE_ACCEPTANCE_SCENARIOS, - type ReleaseAcceptanceScenario, -} from "./acceptance.js"; -import { releaseOption, requiredReleaseOption } from "./options.js"; -import { writeReleaseReport } from "./report.js"; - -async function main(): Promise { - const args = process.argv.slice(2); - const scenario = releaseOption(args, "--scenario") ?? "staged-payload"; - if (!RELEASE_ACCEPTANCE_SCENARIOS.includes( - scenario as ReleaseAcceptanceScenario, - )) { - throw new Error(`Release acceptance scenario is invalid: ${scenario}`); - } - const report = await acceptReleasePayload( - resolve(requiredReleaseOption(args, "--release-root")), - scenario as ReleaseAcceptanceScenario, - ); - await writeReleaseReport(releaseOption(args, "--report"), report); - process.stdout.write(`${JSON.stringify(report)}\n`); -} - -void main().catch(async (error: unknown) => { - const args = process.argv.slice(2); - await writeReleaseReport(releaseOption(args, "--report"), { - scenario: releaseOption(args, "--scenario") ?? "staged-payload", - error: { code: "RELEASE_ACCEPTANCE_FAILED" }, - }); - process.stderr.write(`${JSON.stringify({ - error: { - code: "RELEASE_ACCEPTANCE_FAILED", - message: error instanceof Error ? error.message : "Release acceptance failed", - }, - })}\n`); - process.exitCode = 1; -}); diff --git a/scripts/release/accept-windows-installer.ps1 b/scripts/release/accept-windows-installer.ps1 deleted file mode 100644 index 21c44c3..0000000 --- a/scripts/release/accept-windows-installer.ps1 +++ /dev/null @@ -1,127 +0,0 @@ -param( - [Parameter(Mandatory = $true)][string]$SetupPath, - [Parameter(Mandatory = $true)][string]$InstallDirectory, - [Parameter(Mandatory = $true)][string]$DataDirectory, - [string]$ReportPath -) - -$ErrorActionPreference = 'Stop' - -$setup = (Resolve-Path -LiteralPath $SetupPath).Path -$install = [IO.Path]::GetFullPath($InstallDirectory) -if (Test-Path -LiteralPath $install) { - throw "Installer acceptance directory already exists: $install" -} -$startMenuGroup = Join-Path $env:APPDATA 'Microsoft\Windows\Start Menu\Programs\OpenChatGPTSkin' -if (Test-Path -LiteralPath $startMenuGroup) { - throw "Installer acceptance Start menu group already exists: $startMenuGroup" -} -$expectedDataRoot = [IO.Path]::GetFullPath((Join-Path $env:LOCALAPPDATA 'OpenChatGPTSkin')) -$sentinelRoot = [IO.Path]::GetFullPath($DataDirectory) -if (-not $sentinelRoot.Equals($expectedDataRoot, [StringComparison]::OrdinalIgnoreCase)) { - throw "Installer acceptance must use the production user data directory: $expectedDataRoot" -} -if (Test-Path -LiteralPath $sentinelRoot) { - throw "Installer acceptance data directory already exists: $sentinelRoot" -} -$themeSentinel = Join-Path $sentinelRoot 'theme-store\themes\acceptance-theme\1.0.0\theme.json' -$draftSentinel = Join-Path $sentinelRoot 'theme-studio\drafts\acceptance-draft.json' -New-Item -ItemType Directory -Path (Split-Path -Parent $themeSentinel) -Force | Out-Null -New-Item -ItemType Directory -Path (Split-Path -Parent $draftSentinel) -Force | Out-Null -Set-Content -LiteralPath $themeSentinel -Value '{"preserved":true}' -Encoding utf8 -Set-Content -LiteralPath $draftSentinel -Value '{"preserved":true}' -Encoding utf8 - -function Assert-UserDataPreserved { - if (-not (Test-Path -LiteralPath $themeSentinel) -or -not (Test-Path -LiteralPath $draftSentinel)) { - throw 'Installer modified or removed existing personal theme data' - } -} - -$arguments = @( - '/CURRENTUSER', - '/VERYSILENT', - '/SUPPRESSMSGBOXES', - '/NORESTART', - "/DIR=$install" -) -$process = Start-Process -FilePath $setup -ArgumentList $arguments -Wait -PassThru -WindowStyle Hidden -if ($process.ExitCode -ne 0) { - throw "Installer exited with code $($process.ExitCode)" -} -Assert-UserDataPreserved - -$manifest = Join-Path $install 'release-manifest.json' -if (-not (Test-Path -LiteralPath $manifest)) { - throw 'Installed Release manifest is missing' -} -$releaseManifest = Get-Content -Raw -Encoding utf8 $manifest | ConvertFrom-Json -$uninstallRegistry = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\{A7E2825E-2E95-4AF1-B0B7-CC5D7482AF36}_is1' -$registration = Get-ItemProperty -LiteralPath $uninstallRegistry -ErrorAction Stop -if ($registration.DisplayVersion -ne $releaseManifest.version) { - throw "Installer registration version mismatch: $($registration.DisplayVersion)" -} -if ([IO.Path]::GetFullPath($registration.InstallLocation).TrimEnd('\') -ne $install.TrimEnd('\')) { - throw "Installer registration location mismatch: $($registration.InstallLocation)" -} -$startShortcut = Join-Path $startMenuGroup 'OpenChatGPTSkin.lnk' -$uninstallShortcut = Join-Path $startMenuGroup '卸载 OpenChatGPTSkin.lnk' -if (-not (Test-Path -LiteralPath $startShortcut) -or -not (Test-Path -LiteralPath $uninstallShortcut)) { - throw 'Windows Start menu launch or uninstall entry is missing' -} - -npm run release:acceptance -- --release-root $install --scenario installed-payload -if ($LASTEXITCODE -ne 0) { - throw "Installed payload acceptance failed with code $LASTEXITCODE" -} - -$upgrade = Start-Process -FilePath $setup -ArgumentList $arguments -Wait -PassThru -WindowStyle Hidden -if ($upgrade.ExitCode -ne 0) { - throw "Installer overwrite upgrade exited with code $($upgrade.ExitCode)" -} -Assert-UserDataPreserved -$upgradedRegistration = Get-ItemProperty -LiteralPath $uninstallRegistry -ErrorAction Stop -if ($upgradedRegistration.DisplayVersion -ne $releaseManifest.version) { - throw 'Installer overwrite upgrade changed the registered version incorrectly' -} - -$uninstaller = Join-Path $install 'unins000.exe' -if (-not (Test-Path -LiteralPath $uninstaller)) { - throw 'Windows uninstaller is missing' -} -$uninstall = Start-Process -FilePath $uninstaller -ArgumentList @( - '/VERYSILENT', - '/SUPPRESSMSGBOXES', - '/NORESTART' -) -Wait -PassThru -WindowStyle Hidden -if ($uninstall.ExitCode -ne 0) { - throw "Uninstaller exited with code $($uninstall.ExitCode)" -} -if (Test-Path -LiteralPath $install) { - throw 'Program directory remains after uninstall' -} -if (Test-Path -LiteralPath $uninstallRegistry) { - throw 'Installer registration remains after uninstall' -} -if (Test-Path -LiteralPath $startMenuGroup) { - throw 'Windows Start menu group remains after uninstall' -} -Assert-UserDataPreserved - -Remove-Item -LiteralPath $sentinelRoot -Recurse -Force - -$report = [ordered]@{ - scenario = 'windows-installer' - installerAccepted = $true - payloadAccepted = $true - registrationVersionVerified = $true - startMenuEntriesVerified = $true - upgradePreservedData = $true - uninstallPreservedData = $true -} -$reportJson = $report | ConvertTo-Json -Compress -if ($ReportPath) { - $reportOutput = [IO.Path]::GetFullPath($ReportPath) - New-Item -ItemType Directory -Path (Split-Path -Parent $reportOutput) -Force | Out-Null - Set-Content -LiteralPath $reportOutput -Value $reportJson -Encoding utf8 -} -Write-Output $reportJson diff --git a/scripts/release/acceptance.ts b/scripts/release/acceptance.ts deleted file mode 100644 index 60f1139..0000000 --- a/scripts/release/acceptance.ts +++ /dev/null @@ -1,522 +0,0 @@ -import { createHash } from "node:crypto"; -import { spawn, type ChildProcess } from "node:child_process"; -import { readFile, readdir, mkdtemp, rm } from "node:fs/promises"; -import { request, type IncomingHttpHeaders } from "node:http"; -import { tmpdir } from "node:os"; -import { dirname, join, relative, sep } from "node:path"; -import { Script } from "node:vm"; -import { parseHTML } from "linkedom"; -import { - readReleaseManifest, - type ReleaseManifest, -} from "./payload.js"; - -export interface ReleasePayloadVerification { - readonly version: string; - readonly target: ReleaseManifest["target"]; - readonly filesVerified: number; - readonly bytesVerified: number; -} - -export const RELEASE_ACCEPTANCE_SCENARIOS = [ - "staged-payload", - "portable-archive", - "installed-payload", - "macos-app-bundle", -] as const; - -export type ReleaseAcceptanceScenario = - typeof RELEASE_ACCEPTANCE_SCENARIOS[number]; - -const INSTALLED_PAYLOAD_FILES = new Set([ - "unins000.dat", - "unins000.exe", -]); - -export interface ReleaseAcceptanceReport extends ReleasePayloadVerification { - readonly scenario: ReleaseAcceptanceScenario; - readonly durationMs: number; - readonly steps: { - readonly manifest: "passed"; - readonly nodeRuntime: "passed"; - readonly productionUi: "passed"; - readonly session: "passed"; - readonly runtimeStatus: "passed"; - readonly themes: "passed"; - readonly imageProcessing: "passed"; - readonly themeExport: "passed"; - readonly gracefulExit: "passed"; - }; - readonly uiVerified: true; - readonly themesVerified: number; - readonly imageProcessingVerified: true; - readonly exportBytes: number; - readonly gracefulExitVerified: true; -} - -interface HttpResult { - readonly status: number; - readonly headers: IncomingHttpHeaders; - readonly body: Buffer; -} - -interface HttpInput { - readonly method?: string; - readonly headers?: Readonly>; - readonly body?: Uint8Array; -} - -const START_TIMEOUT_MS = 15_000; -const EXIT_TIMEOUT_MS = 5_000; -const NODE_VERSION_TIMEOUT_MS = 5_000; - -function sha256(bytes: Uint8Array): string { - return createHash("sha256").update(bytes).digest("hex"); -} - -function portablePath(path: string): string { - return path.split(sep).join("/"); -} - -function isolatedChildEnvironment( - nodeExecutable: string, - releaseRoot: string, - dataRoot: string, - manifest: ReleaseManifest, -): NodeJS.ProcessEnv { - const inherited = Object.fromEntries(Object.entries(process.env).filter(([key]) => { - const upper = key.toUpperCase(); - return upper !== "PATH" && !upper.startsWith("NODE_"); - })); - return { - ...inherited, - PATH: dirname(nodeExecutable), - OPEN_CHATGPT_SKIN_INSTALL_ROOT: releaseRoot, - OPEN_CHATGPT_SKIN_VERSION: manifest.version, - ...(process.platform === "win32" - ? { LOCALAPPDATA: dataRoot } - : { HOME: dataRoot }), - }; -} - -function verifyBundledNodeRuntime( - executable: string, - environment: NodeJS.ProcessEnv, - expectedVersion: string, -): Promise { - return new Promise((resolveVersion, rejectVersion) => { - const child = spawn(executable, ["--version"], { - env: environment, - stdio: ["ignore", "pipe", "pipe"], - windowsHide: true, - }); - let stdout = ""; - let stderr = ""; - let settled = false; - const settle = (callback: () => void) => { - if (settled) return; - settled = true; - clearTimeout(timer); - callback(); - }; - const timer = setTimeout(() => { - child.kill("SIGKILL"); - settle(() => rejectVersion(new Error("Bundled Node Runtime version probe timed out"))); - }, NODE_VERSION_TIMEOUT_MS); - child.stdout?.on("data", (chunk: Buffer | string) => { - stdout = `${stdout}${chunk.toString()}`.slice(-1_024); - }); - child.stderr?.on("data", (chunk: Buffer | string) => { - stderr = `${stderr}${chunk.toString()}`.slice(-1_024); - }); - child.once("error", (error) => settle(() => rejectVersion(error))); - child.once("exit", (code) => settle(() => { - const actual = stdout.trim(); - if (code !== 0) { - rejectVersion(new Error( - `Bundled Node Runtime version probe exited with code ${String(code)}: ${stderr.trim()}`, - )); - } else if (actual !== `v${expectedVersion}`) { - rejectVersion(new Error( - `Bundled Node Runtime version mismatch: expected v${expectedVersion}, received ${actual || "empty"}`, - )); - } else { - resolveVersion(); - } - })); - }); -} - -async function payloadFiles( - root: string, - current: string = root, -): Promise { - const files: string[] = []; - for (const entry of await readdir(current, { withFileTypes: true })) { - const path = join(current, entry.name); - if (entry.isSymbolicLink()) { - throw new Error(`Release payload contains a symbolic link: ${path}`); - } - if (entry.isDirectory()) { - files.push(...await payloadFiles(root, path)); - } else if (entry.isFile()) { - files.push(portablePath(relative(root, path))); - } - } - return files.sort(); -} - -export async function verifyReleasePayload( - releaseRoot: string, - scenario: ReleaseAcceptanceScenario = "staged-payload", -): Promise { - const manifest = await readReleaseManifest(releaseRoot); - const listed = Object.keys(manifest.files).sort(); - const actual = (await payloadFiles(releaseRoot)) - .filter((path) => path !== "release-manifest.json" && - !(scenario === "installed-payload" && INSTALLED_PAYLOAD_FILES.has(path))); - if (JSON.stringify(listed) !== JSON.stringify(actual)) { - throw new Error("Release manifest file list does not match the payload"); - } - - let bytesVerified = 0; - for (const path of listed) { - if (/source\.png$/i.test(path) || - /^docs\/superpowers\/|^docs\/assets\/design-qa\/|^node_modules\/vite\/|\.map$|\.d\.(?:c|m)?ts$|\.tsbuildinfo$/.test(path)) { - throw new Error(`Release payload contains a forbidden file: ${path}`); - } - const bytes = await readFile(join(releaseRoot, ...path.split("/"))); - const expected = manifest.files[path]!; - if (bytes.length !== expected.bytes || sha256(bytes) !== expected.sha256) { - throw new Error(`Release payload checksum mismatch: ${path}`); - } - bytesVerified += bytes.length; - } - return { - version: manifest.version, - target: manifest.target, - filesVerified: listed.length, - bytesVerified, - }; -} - -function httpRequest(url: string, input: HttpInput = {}): Promise { - return new Promise((resolveRequest, rejectRequest) => { - const outgoing = request(url, { - method: input.method ?? "GET", - headers: { - ...(input.body ? { "Content-Length": String(input.body.length) } : {}), - ...input.headers, - }, - }, (response) => { - const chunks: Buffer[] = []; - response.on("data", (chunk: Buffer | string) => { - chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); - }); - response.once("end", () => resolveRequest({ - status: response.statusCode ?? 0, - headers: response.headers, - body: Buffer.concat(chunks), - })); - }); - outgoing.once("error", rejectRequest); - if (input.body) outgoing.write(input.body); - outgoing.end(); - }); -} - -function jsonBody(result: HttpResult): T { - return JSON.parse(result.body.toString("utf8")) as T; -} - -function waitForBootstrapUrl(child: ChildProcess): Promise { - return new Promise((resolveUrl, rejectUrl) => { - let stdout = ""; - let stderr = ""; - const timer = setTimeout(() => { - cleanup(); - rejectUrl(new Error(`Release process did not become ready: ${stderr.slice(-2_000)}`)); - }, START_TIMEOUT_MS); - const cleanup = () => { - clearTimeout(timer); - child.stdout?.off("data", onStdout); - child.stderr?.off("data", onStderr); - child.off("error", onError); - child.off("exit", onExit); - }; - const onStdout = (chunk: Buffer | string) => { - stdout += chunk.toString(); - while (stdout.includes("\n")) { - const newline = stdout.indexOf("\n"); - const line = stdout.slice(0, newline).trim(); - stdout = stdout.slice(newline + 1); - if (!line) continue; - try { - const parsed = JSON.parse(line) as { url?: unknown }; - if (typeof parsed.url === "string") { - cleanup(); - resolveUrl(parsed.url); - return; - } - } catch { - // Preserve non-JSON output for the timeout diagnostic. - } - } - }; - const onStderr = (chunk: Buffer | string) => { - stderr = `${stderr}${chunk.toString()}`.slice(-4_000); - }; - const onError = (error: Error) => { - cleanup(); - rejectUrl(error); - }; - const onExit = (code: number | null) => { - cleanup(); - rejectUrl(new Error( - `Release process exited before startup with code ${String(code)}: ${stderr.slice(-2_000)}`, - )); - }; - child.stdout?.on("data", onStdout); - child.stderr?.on("data", onStderr); - child.once("error", onError); - child.once("exit", onExit); - }); -} - -async function stopGracefully(child: ChildProcess): Promise { - if (child.exitCode !== null) return true; - const exited = new Promise((resolveExit) => { - const timer = setTimeout(() => resolveExit(false), EXIT_TIMEOUT_MS); - child.once("exit", () => { - clearTimeout(timer); - resolveExit(true); - }); - }); - child.kill("SIGTERM"); - const graceful = await exited; - if (!graceful && child.exitCode === null) child.kill("SIGKILL"); - return graceful; -} - -function expectStatus(result: HttpResult, expected: number, step: string): void { - if (result.status !== expected) { - throw new Error( - `${step} returned HTTP ${result.status}: ${result.body.toString("utf8").slice(0, 1_000)}`, - ); - } -} - -export async function acceptReleasePayload( - releaseRoot: string, - scenario: ReleaseAcceptanceScenario = "staged-payload", -): Promise { - const startedAt = Date.now(); - const verification = await verifyReleasePayload(releaseRoot, scenario); - const manifest = await readReleaseManifest(releaseRoot); - if (manifest.target.platform !== process.platform || manifest.target.arch !== process.arch) { - throw new Error( - `Release target ${manifest.target.platform}/${manifest.target.arch} cannot run on ${process.platform}/${process.arch}`, - ); - } - const dataRoot = await mkdtemp(join(tmpdir(), "open-chatgpt-skin-release-acceptance-")); - const nodeExecutable = join( - releaseRoot, - "runtime", - manifest.target.platform === "win32" ? "node.exe" : "node", - ); - const serviceCli = join( - releaseRoot, - "node_modules", - "@open-chatgpt-skin", - "theme-studio-service", - "dist", - "cli.js", - ); - const acceptanceImage = await readFile(join( - releaseRoot, - "themes", - "builtin", - "mountain-mist", - "assets", - "background.webp", - )); - const childEnvironment = isolatedChildEnvironment( - nodeExecutable, - releaseRoot, - dataRoot, - manifest, - ); - await verifyBundledNodeRuntime( - nodeExecutable, - childEnvironment, - manifest.runtime.nodeVersion, - ); - const child = spawn(nodeExecutable, [serviceCli, "--no-open"], { - cwd: releaseRoot, - env: childEnvironment, - stdio: ["ignore", "pipe", "pipe"], - windowsHide: true, - }); - - let gracefulExitVerified = false; - try { - const bootstrapUrl = await waitForBootstrapUrl(child); - const parsedUrl = new URL(bootstrapUrl); - if (parsedUrl.hostname !== "127.0.0.1") { - throw new Error(`Release Host is not loopback-only: ${parsedUrl.hostname}`); - } - const token = new URLSearchParams(parsedUrl.hash.slice(1)).get("bootstrap"); - if (!token) throw new Error("Release bootstrap token is missing"); - parsedUrl.hash = ""; - const origin = parsedUrl.origin; - - const page = await httpRequest(parsedUrl.href); - expectStatus(page, 200, "Production UI"); - const html = page.body.toString("utf8"); - if (html.includes("/@vite/client") || - html.includes("__OPEN_CHATGPT_SKIN_CSP_NONCE__")) { - throw new Error("Production UI contains development or unresolved nonce markers"); - } - const csp = String(page.headers["content-security-policy"] ?? ""); - if (!csp.includes("script-src 'self' 'nonce-") || - !csp.includes("style-src 'self' 'nonce-") || - csp.match(/(?:script-src|style-src) [^;]*'unsafe-inline'/)) { - throw new Error("Production UI CSP is not nonce-bound"); - } - const scriptNonce = csp.match(/script-src[^;]*'nonce-([^']+)'/)?.[1]; - const styleNonce = csp.match(/style-src[^;]*'nonce-([^']+)'/)?.[1]; - const { document } = parseHTML(html); - const declaredNonce = document.querySelector('meta[property="csp-nonce"]') - ?.getAttribute("nonce"); - const inlineAssets = [...document.querySelectorAll("script, style")]; - if (!scriptNonce || scriptNonce !== styleNonce || scriptNonce !== declaredNonce || - inlineAssets.length === 0 || - inlineAssets.some((element) => element.getAttribute("nonce") !== scriptNonce)) { - throw new Error("Production UI inline assets are not nonce-bound"); - } - for (const script of document.querySelectorAll("script")) { - try { - new Script(script.textContent, { filename: "theme-studio-inline.js" }); - } catch (error) { - throw new Error( - `Production UI inline script is not syntactically valid: ${String(error)}`, - ); - } - } - if (String(page.headers["referrer-policy"] ?? "") !== "no-referrer" || - String(page.headers["x-frame-options"] ?? "") !== "DENY") { - throw new Error("Production UI security headers are invalid"); - } - - const exchange = await httpRequest(`${origin}/api/session`, { - method: "POST", - headers: { "Content-Type": "application/json", Origin: origin }, - body: Buffer.from(JSON.stringify({ token })), - }); - expectStatus(exchange, 204, "Session exchange"); - const setCookie = exchange.headers["set-cookie"]?.[0]; - const cookie = setCookie?.split(";", 1)[0]; - if (!cookie) throw new Error("Session exchange did not return a cookie"); - const authenticatedHeaders = { Cookie: cookie }; - const mutationHeaders = { - ...authenticatedHeaders, - Origin: origin, - "Content-Type": "application/json", - }; - - const bootstrap = await httpRequest(`${origin}/api/bootstrap`, { - headers: authenticatedHeaders, - }); - expectStatus(bootstrap, 200, "Bootstrap"); - const bootstrapBody = jsonBody<{ - studioVersion?: string; - runtime?: { status?: string }; - }>(bootstrap); - if (bootstrapBody.studioVersion !== manifest.version || - bootstrapBody.runtime?.status !== "stopped") { - throw new Error("Bootstrap version or initial Runtime status is invalid"); - } - - const themesResult = await httpRequest(`${origin}/api/themes`, { - headers: authenticatedHeaders, - }); - expectStatus(themesResult, 200, "Theme catalog"); - const themes = jsonBody<{ - themes: { source: string; ref: { id: string; version: string } }[]; - }>(themesResult).themes; - if (themes.length !== manifest.themes.length || - manifest.themes.some((id) => !themes.some((theme) => theme.ref.id === id))) { - throw new Error("Theme catalog does not match the Release manifest"); - } - const source = themes.find((theme) => theme.ref.id === "mountain-mist")!; - const created = await httpRequest(`${origin}/api/drafts`, { - method: "POST", - headers: mutationHeaders, - body: Buffer.from(JSON.stringify({ source: { source: source.source, ref: source.ref } })), - }); - expectStatus(created, 201, "Draft creation"); - const draft = jsonBody<{ draftId: string; revision: number }>(created); - - const upload = await httpRequest( - `${origin}/api/drafts/${encodeURIComponent(draft.draftId)}/assets?revision=${draft.revision}&slot=background`, - { - method: "POST", - headers: { - ...authenticatedHeaders, - Origin: origin, - "Content-Type": "image/webp", - "X-File-Name": "acceptance.webp", - }, - body: acceptanceImage, - }, - ); - expectStatus(upload, 200, "Image processing"); - const uploaded = jsonBody<{ revision: number }>(upload); - - const saved = await httpRequest( - `${origin}/api/drafts/${encodeURIComponent(draft.draftId)}/save`, - { - method: "POST", - headers: mutationHeaders, - body: Buffer.from(JSON.stringify({ expectedRevision: uploaded.revision })), - }, - ); - expectStatus(saved, 200, "Theme save"); - const ref = jsonBody<{ ref: { id: string; version: string } }>(saved).ref; - const exported = await httpRequest( - `${origin}/api/export?id=${encodeURIComponent(ref.id)}&version=${encodeURIComponent(ref.version)}`, - { headers: authenticatedHeaders }, - ); - expectStatus(exported, 200, "Theme export"); - if (exported.body.length === 0) throw new Error("Theme export is empty"); - - gracefulExitVerified = await stopGracefully(child); - if (!gracefulExitVerified) { - throw new Error("Release process did not exit gracefully after SIGTERM"); - } - return { - ...verification, - scenario, - durationMs: Date.now() - startedAt, - steps: { - manifest: "passed", - nodeRuntime: "passed", - productionUi: "passed", - session: "passed", - runtimeStatus: "passed", - themes: "passed", - imageProcessing: "passed", - themeExport: "passed", - gracefulExit: "passed", - }, - uiVerified: true, - themesVerified: themes.length, - imageProcessingVerified: true, - exportBytes: exported.body.length, - gracefulExitVerified: true, - }; - } finally { - if (!gracefulExitVerified) await stopGracefully(child); - await rm(dataRoot, { recursive: true, force: true }); - } -} diff --git a/scripts/release/artifact-names.ts b/scripts/release/artifact-names.ts deleted file mode 100644 index e5ec85c..0000000 --- a/scripts/release/artifact-names.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { - assertReleaseTarget, - assertReleaseVersion, - type ReleaseArch, - type ReleasePlatform, -} from "./payload.js"; - -export function releasePlatformLabel( - platform: ReleasePlatform, -): "windows" | "macos" { - return platform === "win32" ? "windows" : "macos"; -} - -export function portableArtifactName( - version: string, - platform: ReleasePlatform, - arch: ReleaseArch, -): string { - assertReleaseVersion(version); - assertReleaseTarget(platform, arch); - const suffix = platform === "win32" ? ".zip" : ".tar.gz"; - return `OpenChatGPTSkin_${version}_${releasePlatformLabel(platform)}_${arch}${suffix}`; -} - -export function macDmgArtifactName( - version: string, - arch: ReleaseArch, -): string { - assertReleaseVersion(version); - assertReleaseTarget("darwin", arch); - return `OpenChatGPTSkin_${version}_macos_${arch}.dmg`; -} diff --git a/scripts/release/build-go-spike.ts b/scripts/release/build-go-release.ts similarity index 61% rename from scripts/release/build-go-spike.ts rename to scripts/release/build-go-release.ts index d152977..cc0cb12 100644 --- a/scripts/release/build-go-spike.ts +++ b/scripts/release/build-go-release.ts @@ -1,19 +1,19 @@ import { resolve } from "node:path"; -import { buildGoSpikePackages } from "./go-spike.js"; +import { buildGoReleasePackages } from "./go-release.js"; import { releaseOption } from "./options.js"; try { const args = process.argv.slice(2); - const output = resolve(releaseOption(args, "--output") ?? "artifacts/go-spike"); - const report = await buildGoSpikePackages({ + const output = resolve(releaseOption(args, "--output") ?? "artifacts/release"); + const report = await buildGoReleasePackages({ workspaceRoot: process.cwd(), outputDirectory: output, nativeInstallers: !args.includes("--portable-only"), nativeArtifactsOnly: args.includes("--native-only"), - onProgress: (message) => process.stderr.write(`[go-package] ${message}\n`), + onProgress: (message) => process.stderr.write(`[release] ${message}\n`), }); process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); } catch (error) { - process.stderr.write(`${JSON.stringify({ error: { code: "GO_SPIKE_PACKAGE_FAILED", message: error instanceof Error ? error.message : String(error) } }, null, 2)}\n`); + process.stderr.write(`${JSON.stringify({ error: { code: "GO_RELEASE_BUILD_FAILED", message: error instanceof Error ? error.message : String(error) } }, null, 2)}\n`); process.exitCode = 1; } diff --git a/scripts/release/build-macos-distribution.ts b/scripts/release/build-macos-distribution.ts deleted file mode 100644 index 5f1ad26..0000000 --- a/scripts/release/build-macos-distribution.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { - mkdir, - mkdtemp, - rm, -} from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join, resolve } from "node:path"; -import { buildMacAppBundle } from "./macos-app.js"; -import { - buildMacDmg, - generateMacIcon, - type MacDmgArtifact, -} from "./macos-dmg.js"; -import { requiredReleaseOption } from "./options.js"; - -async function main(): Promise { - if (process.platform !== "darwin") { - throw new Error("macOS distribution builds require Darwin"); - } - const args = process.argv.slice(2); - const releaseRoot = resolve(requiredReleaseOption(args, "--release-root")); - const output = resolve(requiredReleaseOption(args, "--output")); - const iconSource = resolve(requiredReleaseOption(args, "--icon-source")); - await mkdir(output, { recursive: true }); - const temporary = await mkdtemp( - join(tmpdir(), "open-chatgpt-skin-macos-"), - ); - let artifact: MacDmgArtifact | undefined; - let failure: unknown; - try { - const iconPath = join(temporary, "AppIcon.icns"); - await generateMacIcon(iconSource, iconPath); - const app = await buildMacAppBundle({ - releaseRoot, - outputDirectory: temporary, - iconPath, - }); - artifact = await buildMacDmg({ - appPath: app.appPath, - outputDirectory: output, - version: app.version, - arch: app.arch, - }); - } catch (error) { - failure = error; - } - try { - await rm(temporary, { recursive: true, force: true }); - } catch (error) { - failure = failure - ? new AggregateError([failure, error], "macOS build cleanup failed") - : error; - } - if (failure) throw failure; - if (!artifact) throw new Error("macOS build produced no artifact"); - const { name, bytes, sha256 } = artifact; - process.stdout.write(`${JSON.stringify({ - artifact: { name, bytes, sha256 }, - })}\n`); -} - -void main().catch((error: unknown) => { - process.stderr.write(`${JSON.stringify({ - error: { - code: "MACOS_DISTRIBUTION_FAILED", - message: "The macOS distribution could not be built.", - nextAction: "Review the macOS build diagnostics and retry.", - errorType: error instanceof Error ? error.name : "UnknownError", - }, - })}\n`); - process.exitCode = 1; -}); diff --git a/scripts/release/build-windows-installer.ps1 b/scripts/release/build-windows-installer.ps1 deleted file mode 100644 index fe1d50d..0000000 --- a/scripts/release/build-windows-installer.ps1 +++ /dev/null @@ -1,50 +0,0 @@ -param( - [Parameter(Mandatory = $true)][string]$ReleaseRoot, - [Parameter(Mandatory = $true)][string]$OutputDirectory, - [Parameter(Mandatory = $true)][string]$Version -) - -$ErrorActionPreference = 'Stop' - -$release = (Resolve-Path -LiteralPath $ReleaseRoot).Path -if ((Split-Path -Leaf $release) -ne 'OpenChatGPTSkin') { - throw 'Release staging directory must be named OpenChatGPTSkin' -} -$manifestPath = Join-Path $release 'release-manifest.json' -$manifest = Get-Content -Raw -Encoding utf8 $manifestPath | ConvertFrom-Json -if ($manifest.version -ne $Version) { - throw "Installer version does not match Release manifest: $($manifest.version)" -} -if ($manifest.target.platform -ne 'win32' -or $manifest.target.arch -ne 'x64') { - throw 'Windows installer requires a win32/x64 Release payload' -} - -$output = [IO.Path]::GetFullPath($OutputDirectory) -New-Item -ItemType Directory -Force -Path $output | Out-Null -$isccCandidates = @( - $env:INNO_SETUP_COMPILER, - 'C:\Program Files (x86)\Inno Setup 6\ISCC.exe', - 'C:\Program Files\Inno Setup 6\ISCC.exe' -) | Where-Object { $_ } -$iscc = $isccCandidates | Where-Object { Test-Path -LiteralPath $_ } | Select-Object -First 1 -if (-not $iscc) { - throw 'Inno Setup 6 compiler was not found' -} - -$script = Join-Path $PSScriptRoot 'windows-installer.iss' -$arguments = @( - "/DAppVersion=$Version", - "/DReleaseRoot=$release", - "/DOutputDirectory=$output", - $script -) -& $iscc @arguments -if ($LASTEXITCODE -ne 0) { - throw "Inno Setup compiler exited with code $LASTEXITCODE" -} - -$installer = Join-Path $output "OpenChatGPTSkin_${Version}_windows_x64_Setup.exe" -if (-not (Test-Path -LiteralPath $installer)) { - throw 'Inno Setup did not create the expected installer' -} -Get-Item -LiteralPath $installer | Select-Object FullName,Length diff --git a/scripts/release/build-windows-local.ps1 b/scripts/release/build-windows-local.ps1 deleted file mode 100644 index 56ce940..0000000 --- a/scripts/release/build-windows-local.ps1 +++ /dev/null @@ -1,196 +0,0 @@ -param( - [string]$OutputDirectory, - [string]$NodeVersion = '22.18.0' -) - -$ErrorActionPreference = 'Stop' -Set-StrictMode -Version Latest - -$repositoryRoot = (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot '..\..')).Path -if ([string]::IsNullOrWhiteSpace($OutputDirectory)) { - $OutputDirectory = Join-Path $repositoryRoot 'artifacts\windows-x64' -} -$output = [IO.Path]::GetFullPath($OutputDirectory) -$buildRoot = Join-Path $env:TEMP "OpenChatGPTSkin-local-$([Guid]::NewGuid().ToString('N'))" -$nodeOutput = Join-Path $buildRoot 'node' -$releaseRoot = Join-Path $buildRoot 'release\OpenChatGPTSkin' -$temporaryArtifacts = Join-Path $buildRoot 'artifacts' -$innoSetupVersion = '6.7.1' -$innoSetupUrl = "https://github.com/jrsoftware/issrc/releases/download/is-6_7_1/innosetup-$innoSetupVersion.exe" -$innoSetupSha256 = '4D11E8050B6185E0D49BD9E8CC661A7A59F44959A621D31D11033124C4E8A7B0' - -$npmCommand = Get-Command npm.cmd -ErrorAction SilentlyContinue -if (-not $npmCommand) { - $npmCommand = Get-Command npm -ErrorAction Stop -} -$npmExecutable = $npmCommand.Source - -function Invoke-Npm { - param([Parameter(Mandatory = $true)][string[]]$Arguments) - - & $npmExecutable @Arguments - if ($LASTEXITCODE -ne 0) { - throw "npm $($Arguments -join ' ') failed with exit code $LASTEXITCODE" - } -} - -function Invoke-NpmCapture { - param([Parameter(Mandatory = $true)][string[]]$Arguments) - - $captured = & $npmExecutable @Arguments - if ($LASTEXITCODE -ne 0) { - throw "npm $($Arguments -join ' ') failed with exit code $LASTEXITCODE" - } - return $captured -} - -function Test-LockedDependencies { - & $npmExecutable 'ls' '--depth=0' '--silent' *> $null - return $LASTEXITCODE -eq 0 -} - -function Resolve-InnoSetupCompiler { - $installedCandidates = @( - $env:INNO_SETUP_COMPILER, - 'C:\Program Files (x86)\Inno Setup 6\ISCC.exe', - 'C:\Program Files\Inno Setup 6\ISCC.exe' - ) | Where-Object { $_ } - $installed = $installedCandidates | Where-Object { Test-Path -LiteralPath $_ } | Select-Object -First 1 - if ($installed) { - return $installed - } - - Write-Host "Inno Setup $innoSetupVersion was not found; fetching the verified portable compiler..." - $installer = Join-Path $buildRoot "innosetup-$innoSetupVersion.exe" - $portableRoot = Join-Path $buildRoot 'inno-setup' - $previousProtocol = [Net.ServicePointManager]::SecurityProtocol - try { - [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 - Invoke-WebRequest -UseBasicParsing -Uri $innoSetupUrl -OutFile $installer - } finally { - [Net.ServicePointManager]::SecurityProtocol = $previousProtocol - } - $actualHash = (Get-FileHash -LiteralPath $installer -Algorithm SHA256).Hash - if (-not $actualHash.Equals($innoSetupSha256, [StringComparison]::OrdinalIgnoreCase)) { - throw "Inno Setup SHA-256 mismatch: expected $innoSetupSha256, received $actualHash" - } - - $process = Start-Process -FilePath $installer -ArgumentList @( - '/VERYSILENT', - '/SUPPRESSMSGBOXES', - '/NORESTART', - '/SP-', - '/PORTABLE=1', - "/DIR=$portableRoot" - ) -Wait -PassThru - if ($process.ExitCode -ne 0) { - throw "Portable Inno Setup bootstrap failed with exit code $($process.ExitCode)" - } - $portableCompiler = Join-Path $portableRoot 'ISCC.exe' - if (-not (Test-Path -LiteralPath $portableCompiler)) { - throw 'Portable Inno Setup bootstrap did not create ISCC.exe' - } - return $portableCompiler -} - -$succeeded = $false -Push-Location $repositoryRoot -try { - New-Item -ItemType Directory -Path $buildRoot -Force | Out-Null - $env:INNO_SETUP_COMPILER = Resolve-InnoSetupCompiler - - Write-Host '[1/7] Checking locked dependencies...' - if (Test-LockedDependencies) { - Write-Host 'Existing dependencies are complete; skipping dependency installation.' - } else { - Write-Host 'Dependencies are missing or inconsistent; repairing them from package-lock.json...' - Invoke-Npm -Arguments @('install', '--no-audit', '--no-fund') - } - - Write-Host '[2/7] Verifying version, tests, types, and Theme Studio...' - Invoke-Npm -Arguments @('run', 'release:version') - Invoke-Npm -Arguments @('run', 'verify') - Invoke-Npm -Arguments @('run', 'studio:build') - - Write-Host '[3/7] Fetching the official bundled Node.js runtime...' - $metadataOutput = Invoke-NpmCapture -Arguments @( - 'run', '--silent', 'release:node', '--', - '--version', $NodeVersion, - '--platform', 'win32', - '--arch', 'x64', - '--output', $buildRoot - ) - $metadataText = [string]::Join([Environment]::NewLine, @($metadataOutput)) - $metadata = $metadataText | ConvertFrom-Json - Expand-Archive -LiteralPath $metadata.archivePath -DestinationPath $nodeOutput -Force - $nodeRoot = Join-Path $nodeOutput $metadata.rootDirectory - - Write-Host '[4/7] Staging and validating the production payload...' - $commit = (& git rev-parse HEAD).Trim() - if ($LASTEXITCODE -ne 0) { - throw "git rev-parse HEAD failed with exit code $LASTEXITCODE" - } - Invoke-Npm -Arguments @( - 'run', 'release:stage', '--', - '--output', $releaseRoot, - '--node-executable', (Join-Path $nodeRoot 'node.exe'), - '--node-license', (Join-Path $nodeRoot 'LICENSE'), - '--node-version', $NodeVersion, - '--build-commit', $commit, - '--platform', 'win32', - '--arch', 'x64' - ) - Invoke-Npm -Arguments @( - 'run', 'release:acceptance', '--', - '--release-root', $releaseRoot, - '--scenario', 'staged-payload' - ) - - Write-Host '[5/7] Building and validating the portable ZIP...' - Invoke-Npm -Arguments @( - 'run', 'release:package', '--', - '--release-root', $releaseRoot, - '--output', $temporaryArtifacts - ) - $archive = (Get-ChildItem -LiteralPath $temporaryArtifacts -Filter '*.zip' | Select-Object -First 1).FullName - if (-not $archive) { - throw 'Portable release archive was not created' - } - Invoke-Npm -Arguments @( - 'run', 'release:acceptance:archive', '--', - '--archive', $archive - ) - - Write-Host '[6/7] Building the Windows Setup executable...' - $version = (Get-Content -Raw -LiteralPath 'package.json' | ConvertFrom-Json).version - & (Join-Path $PSScriptRoot 'build-windows-installer.ps1') ` - -ReleaseRoot $releaseRoot ` - -OutputDirectory $temporaryArtifacts ` - -Version $version - if ($LASTEXITCODE -ne 0) { - throw "Windows installer build failed with exit code $LASTEXITCODE" - } - - Write-Host '[7/7] Writing checksums and copying final artifacts...' - Invoke-Npm -Arguments @( - 'run', 'release:checksums', '--', - '--output', $temporaryArtifacts - ) - New-Item -ItemType Directory -Path $output -Force | Out-Null - Get-ChildItem -LiteralPath $temporaryArtifacts -File | Copy-Item -Destination $output -Force - - $succeeded = $true -} catch { - Write-Host "Local build files were retained for diagnosis: $buildRoot" -ForegroundColor Yellow - throw -} finally { - Pop-Location -} - -if ($succeeded) { - Remove-Item -LiteralPath $buildRoot -Recurse -Force - Write-Host '' - Write-Host 'Windows release build completed:' -ForegroundColor Green - Get-ChildItem -LiteralPath $output -File | Sort-Object Name | Format-Table Name, Length, LastWriteTime -AutoSize - Write-Host "Output: $output" -} diff --git a/scripts/release/checksums.ts b/scripts/release/checksums.ts new file mode 100644 index 0000000..94d7166 --- /dev/null +++ b/scripts/release/checksums.ts @@ -0,0 +1,24 @@ +import { createHash } from "node:crypto"; +import { readFile, readdir, stat, writeFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; + +export async function writeReleaseChecksums(outputDirectoryInput: string): Promise { + const outputDirectory = resolve(outputDirectoryInput); + const releaseSuffixes = [".zip", ".tar.gz", ".exe", ".dmg"] as const; + const files = (await readdir(outputDirectory, { withFileTypes: true })) + .filter((entry) => entry.isFile() && releaseSuffixes.some((suffix) => entry.name.endsWith(suffix))) + .map((entry) => entry.name) + .sort(); + if (files.length === 0) throw new Error("No Release artifacts are available for checksums"); + const lines: string[] = []; + for (const file of files) { + const path = join(outputDirectory, file); + const info = await stat(path); + if (!info.isFile()) throw new Error(`Release artifact is not a file: ${file}`); + const digest = createHash("sha256").update(await readFile(path)).digest("hex"); + lines.push(`${digest} ${file}`); + } + const checksums = join(outputDirectory, "checksums.txt"); + await writeFile(checksums, `${lines.join("\n")}\n`, "utf8"); + return checksums; +} diff --git a/scripts/release/fetch-node-runtime.ts b/scripts/release/fetch-node-runtime.ts deleted file mode 100644 index c001eb4..0000000 --- a/scripts/release/fetch-node-runtime.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { createHash } from "node:crypto"; -import { mkdir, writeFile } from "node:fs/promises"; -import { join, resolve } from "node:path"; -import { requiredReleaseOption } from "./options.js"; - -async function download(url: string): Promise { - const response = await fetch(url, { redirect: "follow" }); - if (!response.ok) { - throw new Error(`Node Runtime download failed with HTTP ${response.status}: ${url}`); - } - return Buffer.from(await response.arrayBuffer()); -} - -async function main(): Promise { - const args = process.argv.slice(2); - const version = requiredReleaseOption(args, "--version"); - if (!/^\d+\.\d+\.\d+$/.test(version)) { - throw new Error(`Node Runtime version is invalid: ${version}`); - } - const platform = requiredReleaseOption(args, "--platform"); - const arch = requiredReleaseOption(args, "--arch"); - if (platform !== "win32" && platform !== "darwin") { - throw new Error(`Node Runtime platform is unsupported: ${platform}`); - } - if (arch !== "x64" && arch !== "arm64") { - throw new Error(`Node Runtime architecture is unsupported: ${arch}`); - } - const nodePlatform = platform === "win32" ? "win" : "darwin"; - const extension = platform === "win32" ? "zip" : "tar.gz"; - const archiveName = `node-v${version}-${nodePlatform}-${arch}.${extension}`; - const rootDirectory = `node-v${version}-${nodePlatform}-${arch}`; - const baseUrl = `https://nodejs.org/dist/v${version}`; - const [checksums, archive] = await Promise.all([ - download(`${baseUrl}/SHASUMS256.txt`), - download(`${baseUrl}/${archiveName}`), - ]); - const checksumLine = checksums.toString("utf8") - .split(/\r?\n/) - .find((line) => line.endsWith(` ${archiveName}`)); - if (!checksumLine) { - throw new Error(`Official Node Runtime checksum is missing: ${archiveName}`); - } - const expected = checksumLine.split(/\s+/, 1)[0]!; - const actual = createHash("sha256").update(archive).digest("hex"); - if (actual !== expected) { - throw new Error(`Official Node Runtime checksum mismatch: ${archiveName}`); - } - const output = resolve(requiredReleaseOption(args, "--output")); - await mkdir(output, { recursive: true }); - const archivePath = join(output, archiveName); - await writeFile(archivePath, archive); - process.stdout.write(`${JSON.stringify({ - archivePath, - archiveName, - rootDirectory, - sha256: actual, - })}\n`); -} - -void main().catch((error: unknown) => { - process.stderr.write(`${JSON.stringify({ - error: { - code: "NODE_RUNTIME_FETCH_FAILED", - message: error instanceof Error ? error.message : "Node Runtime download failed", - }, - })}\n`); - process.exitCode = 1; -}); diff --git a/scripts/release/go-spike.ts b/scripts/release/go-release.ts similarity index 64% rename from scripts/release/go-spike.ts rename to scripts/release/go-release.ts index 414c23d..18cbca3 100644 --- a/scripts/release/go-spike.ts +++ b/scripts/release/go-release.ts @@ -4,6 +4,7 @@ import { access, chmod, cp, + link, mkdir, mkdtemp, readFile, @@ -15,10 +16,11 @@ import { import { tmpdir } from "node:os"; import { dirname, join, relative, resolve, sep } from "node:path"; import { promisify } from "node:util"; -import { strToU8, zipSync } from "fflate"; +import { zipSync } from "fflate"; +import { generateMacIcon } from "./macos-icon.js"; +import { readReleaseManifest, ReleaseManifestSchema } from "./manifest.js"; const execFileAsync = promisify(execFile); -const SPIKE_VERSION = "0.3.0-alpha.1"; const PRODUCT = "OpenChatGPTSkin"; const IMAGE_IMPLEMENTATION = "gen2brain-webp-wasm2go-nodynamic-plus-internal-pipeline"; @@ -66,7 +68,7 @@ const TARGETS: readonly Target[] = [ }, ] as const; -export interface GoSpikeTargetReport { +export interface GoReleaseTargetReport { readonly target: Target["target"]; readonly executableFormat: Target["executableFormat"]; readonly executableBytes: number; @@ -74,7 +76,7 @@ export interface GoSpikeTargetReport { readonly baselineStageBytes: number; } -export interface GoSpikeArtifactReport { +export interface GoReleaseArtifactReport { readonly name: string; readonly kind: | Target["archiveKind"] @@ -86,17 +88,17 @@ export interface GoSpikeArtifactReport { readonly baselineBytes: number; } -export interface GoSpikeReport { +export interface GoReleaseReport { readonly schemaVersion: 1; - readonly version: typeof SPIKE_VERSION; + readonly version: string; readonly imageImplementation: typeof IMAGE_IMPLEMENTATION; readonly cgo: false; readonly sidecars: readonly []; - readonly targets: readonly GoSpikeTargetReport[]; - readonly artifacts: readonly GoSpikeArtifactReport[]; + readonly targets: readonly GoReleaseTargetReport[]; + readonly artifacts: readonly GoReleaseArtifactReport[]; } -export interface BuildGoSpikeOptions { +export interface BuildGoReleaseOptions { readonly workspaceRoot: string; readonly outputDirectory: string; readonly nativeInstallers: boolean; @@ -104,7 +106,7 @@ export interface BuildGoSpikeOptions { readonly onProgress?: (message: string) => void; } -interface GoSpikeBuildMetadata { +interface GoReleaseBuildMetadata { readonly goVersion: string; readonly commit: string; readonly dirty: boolean; @@ -132,7 +134,7 @@ async function walkFiles(root: string, current = root): Promise { const path = join(current, entry.name); if (entry.isDirectory()) files.push(...await walkFiles(root, path)); else if (entry.isFile()) files.push(portable(relative(root, path))); - else throw new Error(`Go spike payload contains a non-file entry: ${path}`); + else throw new Error(`Go release payload contains a non-file entry: ${path}`); } return files.sort(); } @@ -194,7 +196,7 @@ async function directorySha256(root: string): Promise { return hash.digest("hex"); } -async function buildMetadata(workspaceRoot: string): Promise { +async function buildMetadata(workspaceRoot: string): Promise { const [{ stdout: goVersion }, { stdout: commit }, { stdout: status }, cdpManifest] = await Promise.all([ execFileAsync("go", ["env", "GOVERSION"], { cwd: join(workspaceRoot, "host", "go"), windowsHide: true }), execFileAsync("git", ["rev-parse", "HEAD"], { cwd: workspaceRoot, windowsHide: true }), @@ -214,7 +216,7 @@ async function buildMetadata(workspaceRoot: string): Promise { +async function artifact(path: string, kind: GoReleaseArtifactReport["kind"], baselineBytes: number): Promise { const contents = await readFile(path); return { name: path.split(sep).at(-1)!, kind, bytes: contents.length, sha256: sha256(contents), baselineBytes }; } @@ -227,7 +229,17 @@ function inspectExecutable(contents: Uint8Array): Target["executableFormat"] { if (cpu === 0x0100000c) return "mach-o-arm64"; if (cpu === 0x01000007) return "mach-o-x64"; } - throw new Error("Go spike executable format is invalid"); + throw new Error("Go release executable format is invalid"); +} + +async function readReleaseVersion(workspaceRoot: string): Promise { + const value = JSON.parse(await readFile(join(workspaceRoot, "package.json"), "utf8")) as { + readonly version?: unknown; + }; + if (typeof value.version !== "string" || !/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(value.version)) { + throw new Error("Release package version is invalid"); + } + return value.version; } async function zipStage(stageParent: string, output: string): Promise { @@ -239,18 +251,13 @@ async function zipStage(stageParent: string, output: string): Promise { await writeFile(output, zipSync(entries, { level: 9 })); } -async function tarStage(stageParent: string, output: string): Promise { - await execFileAsync("tar", ["-czf", output, "-C", stageParent, PRODUCT], { - windowsHide: true, - }); -} - async function stageTarget( workspaceRoot: string, outputDirectory: string, target: Target, - metadata: GoSpikeBuildMetadata, -): Promise { + metadata: GoReleaseBuildMetadata, + version: string, +): Promise { const stageParent = join(outputDirectory, "stages", target.target); const stageRoot = join(stageParent, PRODUCT); await rm(stageParent, { recursive: true, force: true }); @@ -261,7 +268,7 @@ async function stageTarget( "-buildvcs=false", "-trimpath", "-tags", "nodynamic", - "-ldflags", "-s -w", + "-ldflags", `-s -w -X github.com/u2bo/OpenChatGPTSkin/host/go/internal/app.goHostVersion=${version}`, "-o", executablePath, "./cmd/openchatgptskin", ], { @@ -279,15 +286,25 @@ async function stageTarget( cp(join(workspaceRoot, "apps", "theme-studio", "dist"), join(stageRoot, "apps", "theme-studio", "dist"), { recursive: true }), copyRuntimeThemes(workspaceRoot, join(stageRoot, "themes")), cp(join(workspaceRoot, "LICENSE"), join(stageRoot, "LICENSE")), + cp(join(workspaceRoot, "README.md"), join(stageRoot, "README.md")), + cp(join(workspaceRoot, "README.en.md"), join(stageRoot, "README.en.md")), ]); const executableContents = await readFile(executablePath); const executableFormat = inspectExecutable(executableContents); if (executableFormat !== target.executableFormat) { - throw new Error(`Go spike target format mismatch: ${target.target}`); + throw new Error(`Go release target format mismatch: ${target.target}`); } - await writeFile(join(stageRoot, "go-spike-manifest.json"), `${JSON.stringify({ - schemaVersion: 1, - version: SPIKE_VERSION, + const catalog = JSON.parse(await readFile(join(stageRoot, "themes", "catalog.json"), "utf8")) as ThemeCatalog & { + readonly schemaVersion?: unknown; + }; + const files = await Promise.all((await walkFiles(stageRoot)).map(async (path) => { + const contents = await readFile(join(stageRoot, ...path.split("/"))); + return { path, bytes: contents.length, sha256: sha256(contents) }; + })); + const manifest = ReleaseManifestSchema.parse({ + schemaVersion: 2, + product: PRODUCT, + version, target: target.target, roles: ["studio", "controller", "runtime"], host: { @@ -301,12 +318,23 @@ async function stageTarget( sha256: sha256(executableContents), }, }, - contractsSha256: metadata.contractsSha256, - cdpAdapterSha256: metadata.cdpAdapterSha256, - imageImplementation: IMAGE_IMPLEMENTATION, - cgo: false, + contracts: { + studio: 2, + runtime: 1, + theme: 4, + data: 1, + sha256: metadata.contractsSha256, + }, + cdpAdapter: { sha256: metadata.cdpAdapterSha256 }, + themes: { + catalogSchemaVersion: catalog.schemaVersion, + builtins: catalog.builtins.map(({ id }) => id), + }, + image: { implementation: IMAGE_IMPLEMENTATION, cgo: false }, sidecars: [], - }, null, 2)}\n`, "utf8"); + files, + }); + await writeFile(join(stageRoot, "release-manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`, "utf8"); await assertNodeFreeStage(stageRoot); return { target: target.target, @@ -322,27 +350,26 @@ async function assertNodeFreeStage(stageRoot: string): Promise { const parts = path.toLowerCase().split("/"); const name = parts.at(-1); if (parts.includes("node_modules") || name === "node" || name === "node.exe") { - throw new Error(`Go spike stage contains a Node Runtime entry: ${path}`); + throw new Error(`Go release stage contains a Node Runtime entry: ${path}`); } } } async function buildPortableArtifacts( + workspaceRoot: string, outputDirectory: string, targets: readonly Target[], -): Promise { - const artifacts: GoSpikeArtifactReport[] = []; + version: string, +): Promise { + const artifacts: GoReleaseArtifactReport[] = []; for (const target of targets) { const stageParent = join(outputDirectory, "stages", target.target); if (target.goos === "windows") { - const path = join(outputDirectory, `${PRODUCT}_${SPIKE_VERSION}_go-spike_windows_x64.zip`); + const path = join(outputDirectory, `${PRODUCT}_${version}_windows_x64.zip`); await zipStage(stageParent, path); artifacts.push(await artifact(path, target.archiveKind, target.archiveBaselineBytes)); } else { - const arch = target.goarch === "arm64" ? "arm64" : "x64"; - const path = join(outputDirectory, `${PRODUCT}_${SPIKE_VERSION}_go-spike_macos_${arch}.tar.gz`); - await tarStage(stageParent, path); - artifacts.push(await artifact(path, target.archiveKind, target.archiveBaselineBytes)); + artifacts.push(await buildMacTar(workspaceRoot, outputDirectory, target, version)); } } return artifacts; @@ -354,7 +381,7 @@ function currentNativeTarget(): Target { goarch === (process.arch === "x64" ? "amd64" : process.arch) ); if (!target) { - throw new Error(`Go spike native packaging does not support ${process.platform}/${process.arch}`); + throw new Error(`Go release packaging does not support ${process.platform}/${process.arch}`); } return target; } @@ -376,8 +403,8 @@ async function findInnoSetup(): Promise { const match = /^\s*InstallLocation\s+REG_\w+\s+(.+)$/im.exec(stdout); if (match?.[1]) candidates.push(join(match[1].trim(), "ISCC.exe")); } catch (error) { - const code = (error as NodeJS.ErrnoException & { readonly code?: unknown }).code; - if (code !== 1) throw error; + const code = (error as { readonly code?: string | number }).code; + if (code !== 1 && code !== "1") throw error; } } } @@ -387,20 +414,24 @@ async function findInnoSetup(): Promise { return null; } -async function buildWindowsSetup(outputDirectory: string): Promise { +async function buildWindowsSetup(outputDirectory: string, version: string): Promise { const compiler = await findInnoSetup(); - if (!compiler) throw new Error("Native Windows Go spike packaging requires Inno Setup 6"); + if (!compiler) throw new Error("Native Windows release packaging requires Inno Setup 6"); const stageRoot = join(outputDirectory, "stages", "windows-x64", PRODUCT); const buildRoot = await mkdtemp(join(tmpdir(), "openchatgptskin-go-inno-")); try { - const script = join(buildRoot, "go-spike.iss"); - const outputBase = `${PRODUCT}_${SPIKE_VERSION}_go-spike_windows_x64_Setup`; + const script = join(buildRoot, "go-release.iss"); + const outputBase = `${PRODUCT}_${version}_windows_x64_Setup`; const quote = (value: string) => value.replaceAll('"', '""'); await writeFile(script, [ "[Setup]", - `AppName=${PRODUCT} Go Host Spike`, - `AppVersion=${SPIKE_VERSION}`, - `DefaultDirName={localappdata}\\${PRODUCT}-Go-Spike`, + `AppId=${PRODUCT}`, + `AppName=${PRODUCT}`, + `AppVersion=${version}`, + "AppPublisher=OpenChatGPTSkin Contributors", + "AppPublisherURL=https://github.com/u2bo/OpenChatGPTSkin", + `DefaultDirName={localappdata}\\Programs\\${PRODUCT}`, + `DefaultGroupName=${PRODUCT}`, "PrivilegesRequired=lowest", "ArchitecturesAllowed=x64compatible", "ArchitecturesInstallIn64BitMode=x64compatible", @@ -412,7 +443,9 @@ async function buildWindowsSetup(outputDirectory: string): Promise, -): Promise { + target: Target, + version: string, +): Promise { const arch = target.goarch === "arm64" ? "arm64" : "x64"; const stageRoot = join(outputDirectory, "stages", target.target, PRODUCT); const buildRoot = await mkdtemp(join(tmpdir(), `openchatgptskin-go-dmg-${arch}-`)); try { const app = join(buildRoot, `${PRODUCT}.app`); - const contents = join(app, "Contents"); - const payload = join(contents, "Resources", "payload"); - await mkdir(join(contents, "MacOS"), { recursive: true }); - await mkdir(payload, { recursive: true }); - await cp(join(stageRoot, "OpenChatGPTSkin"), join(contents, "MacOS", "OpenChatGPTSkin")); - await chmod(join(contents, "MacOS", "OpenChatGPTSkin"), 0o755); - for (const path of await walkFiles(stageRoot)) { - if (path === "OpenChatGPTSkin") continue; - const destination = join(payload, ...path.split("/")); - await mkdir(dirname(destination), { recursive: true }); - await cp(join(stageRoot, ...path.split("/")), destination); - } - await writeFile(join(contents, "Info.plist"), [ - "", - "", - "", - "CFBundleExecutableOpenChatGPTSkin", - "CFBundleIdentifierio.github.u2bo.openchatgptskin", - `CFBundleShortVersionString${SPIKE_VERSION}`, - "CFBundleVersion0.3.0.1", - "CFBundlePackageTypeAPPL", - "LSUIElement", - "LSMultipleInstancesProhibited", - "", - "", - ].join("\n"), "utf8"); - await execFileAsync("plutil", ["-lint", join(contents, "Info.plist")]); - const health = await execFileAsync(join(contents, "MacOS", "OpenChatGPTSkin"), ["studio", "--health-once"]); - const healthResult = JSON.parse(health.stdout) as { readonly role?: unknown }; - if (healthResult.role !== "studio") { - throw new Error(`macOS ${arch} App Bundle host health failed`); - } - const output = join(outputDirectory, `${PRODUCT}_${SPIKE_VERSION}_go-spike_macos_${arch}.dmg`); + await stageMacApp(workspaceRoot, stageRoot, app, target, version); + const output = join(outputDirectory, `${PRODUCT}_${version}_macos_${arch}.dmg`); await execFileAsync("hdiutil", [ - "create", "-volname", `${PRODUCT} Go Spike`, "-srcfolder", app, + "create", "-volname", PRODUCT, "-srcfolder", app, "-format", "UDZO", "-ov", output, ]); await execFileAsync("hdiutil", ["verify", output]); @@ -475,6 +479,76 @@ async function buildMacDmg( } } +async function stageMacApp( + workspaceRoot: string, + stageRoot: string, + app: string, + target: Target, + version: string, +): Promise { + const arch = target.goarch === "arm64" ? "arm64" : "x64"; + const contents = join(app, "Contents"); + const resources = join(contents, "Resources"); + const payload = join(resources, "payload"); + await mkdir(join(contents, "MacOS"), { recursive: true }); + await mkdir(payload, { recursive: true }); + for (const path of await walkFiles(stageRoot)) { + const destination = join(payload, ...path.split("/")); + await mkdir(dirname(destination), { recursive: true }); + await cp(join(stageRoot, ...path.split("/")), destination); + } + const payloadExecutable = join(payload, "OpenChatGPTSkin"); + const bundleExecutable = join(contents, "MacOS", "OpenChatGPTSkin"); + await chmod(payloadExecutable, 0o755); + await link(payloadExecutable, bundleExecutable); + await chmod(bundleExecutable, 0o755); + await generateMacIcon( + join(workspaceRoot, "assets", "branding", "open-chatgpt-skin-icon.svg"), + join(resources, "AppIcon.icns"), + ); + await writeFile(join(contents, "Info.plist"), [ + "", + "", + "", + "CFBundleExecutableOpenChatGPTSkin", + "CFBundleIdentifierio.github.u2bo.openchatgptskin", + `CFBundleShortVersionString${version}`, + "CFBundleVersion0.3.0.1", + "CFBundlePackageTypeAPPL", + "CFBundleIconFileAppIcon", + "LSUIElement", + "LSMultipleInstancesProhibited", + "", + "", + ].join("\n"), "utf8"); + await execFileAsync("plutil", ["-lint", join(contents, "Info.plist")]); + const health = await execFileAsync(join(contents, "MacOS", "OpenChatGPTSkin"), ["studio", "--health-once"]); + const healthResult = JSON.parse(health.stdout) as { readonly role?: unknown }; + if (healthResult.role !== "studio") { + throw new Error(`macOS ${arch} App Bundle host health failed`); + } +} + +async function buildMacTar( + workspaceRoot: string, + outputDirectory: string, + target: Target, + version: string, +): Promise { + const arch = target.goarch === "arm64" ? "arm64" : "x64"; + const stageRoot = join(outputDirectory, "stages", target.target, PRODUCT); + const buildRoot = await mkdtemp(join(tmpdir(), `openchatgptskin-go-tar-${arch}-`)); + try { + const app = join(buildRoot, `${PRODUCT}.app`); + await stageMacApp(workspaceRoot, stageRoot, app, target, version); + const output = join(outputDirectory, `${PRODUCT}_${version}_macos_${arch}.tar.gz`); + await execFileAsync("tar", ["-czf", output, "-C", buildRoot, `${PRODUCT}.app`]); + return artifact(output, target.archiveKind, target.archiveBaselineBytes); + } finally { + await rm(buildRoot, { recursive: true, force: true }); + } +} + async function acceptWindowsSetup(setupPath: string): Promise { const installRoot = await mkdtemp(join(tmpdir(), "openchatgptskin-go-installed-")); await rm(installRoot, { recursive: true, force: true }); @@ -488,7 +562,7 @@ async function acceptWindowsSetup(setupPath: string): Promise { ], { windowsHide: true }); const health = await execFileAsync(join(installRoot, "OpenChatGPTSkin.exe"), ["studio", "--health-once"], { windowsHide: true }); const value = JSON.parse(health.stdout) as { readonly role?: unknown }; - if (value.role !== "studio") throw new Error("Installed Go spike host health failed"); + if (value.role !== "studio") throw new Error("Installed Go release host health failed"); await execFileAsync(join(installRoot, "unins000.exe"), [ "/VERYSILENT", "/SUPPRESSMSGBOXES", @@ -499,12 +573,13 @@ async function acceptWindowsSetup(setupPath: string): Promise { } } -export async function buildGoSpikePackages(options: BuildGoSpikeOptions): Promise { +export async function buildGoReleasePackages(options: BuildGoReleaseOptions): Promise { const workspaceRoot = resolve(options.workspaceRoot); const outputDirectory = resolve(options.outputDirectory); await mkdir(outputDirectory, { recursive: true }); const npmExecPath = process.env.npm_execpath; - if (!npmExecPath) throw new Error("npm_execpath is required to build the Go spike UI"); + if (!npmExecPath) throw new Error("npm_execpath is required to build the Go release UI"); + const version = await readReleaseVersion(workspaceRoot); options.onProgress?.("Building Theme Studio UI"); await execFileAsync(process.execPath, [npmExecPath, "run", "studio:build"], { cwd: workspaceRoot, @@ -512,44 +587,46 @@ export async function buildGoSpikePackages(options: BuildGoSpikeOptions): Promis }); const metadata = await buildMetadata(workspaceRoot); const selectedTargets = options.nativeArtifactsOnly ? [currentNativeTarget()] : TARGETS; - const targets: GoSpikeTargetReport[] = []; + const targets: GoReleaseTargetReport[] = []; for (const target of selectedTargets) { options.onProgress?.(`Building and staging ${target.target}`); - targets.push(await stageTarget(workspaceRoot, outputDirectory, target, metadata)); + targets.push(await stageTarget(workspaceRoot, outputDirectory, target, metadata, version)); } const nativeTarget = options.nativeInstallers || options.nativeArtifactsOnly ? currentNativeTarget() : undefined; options.onProgress?.("Building portable artifacts"); - const artifacts = await buildPortableArtifacts(outputDirectory, selectedTargets); + const artifacts = await buildPortableArtifacts(workspaceRoot, outputDirectory, selectedTargets, version); if (options.nativeInstallers) { if (process.platform === "win32") { - options.onProgress?.("Building and accepting Windows Setup"); - artifacts.push(await buildWindowsSetup(outputDirectory)); + options.onProgress?.("Building Windows Setup"); + artifacts.push(await buildWindowsSetup(outputDirectory, version)); } if (process.platform === "darwin") { options.onProgress?.(`Building macOS ${nativeTarget!.goarch === "arm64" ? "ARM64" : "x64"} DMG`); artifacts.push(await buildMacDmg( + workspaceRoot, outputDirectory, - nativeTarget as Extract, + nativeTarget!, + version, )); } } - const report: GoSpikeReport = { + const report: GoReleaseReport = { schemaVersion: 1, - version: SPIKE_VERSION, + version, imageImplementation: IMAGE_IMPLEMENTATION, cgo: false, sidecars: [], targets, artifacts, }; - await writeFile(join(outputDirectory, "go-spike-report.json"), `${JSON.stringify(report, null, 2)}\n`, "utf8"); - options.onProgress?.("Go package build complete"); + await writeFile(join(outputDirectory, "go-release-report.json"), `${JSON.stringify(report, null, 2)}\n`, "utf8"); + options.onProgress?.("Go release build complete"); return report; } -export async function acceptGoSpikePackages( +export async function acceptGoReleasePackages( outputDirectoryInput: string, requireAllNative: boolean, ): Promise<{ @@ -558,45 +635,42 @@ export async function acceptGoSpikePackages( readonly nativeEvidenceComplete: boolean; }> { const outputDirectory = resolve(outputDirectoryInput); - const report = JSON.parse(await readFile(join(outputDirectory, "go-spike-report.json"), "utf8")) as GoSpikeReport; + const report = JSON.parse(await readFile(join(outputDirectory, "go-release-report.json"), "utf8")) as GoReleaseReport; if (report.schemaVersion !== 1 || report.targets.length < 1 || report.targets.length > TARGETS.length || new Set(report.targets.map(({ target }) => target)).size !== report.targets.length) { - throw new Error("Go spike report identity is invalid"); + throw new Error("Go release report identity is invalid"); } for (const target of report.targets) { if (target.stageBytes >= target.baselineStageBytes) { - throw new Error(`Go spike stage exceeds its baseline: ${target.target}`); + throw new Error(`Go release stage exceeds its baseline: ${target.target}`); } const definition = TARGETS.find((value) => value.target === target.target); - if (!definition) throw new Error(`Unknown Go spike target: ${target.target}`); + if (!definition) throw new Error(`Unknown Go release target: ${target.target}`); const stageRoot = join(outputDirectory, "stages", target.target, PRODUCT); await assertNodeFreeStage(stageRoot); const executable = await readFile(join(stageRoot, definition.executable)); if (inspectExecutable(executable) !== target.executableFormat) { - throw new Error(`Go spike executable format changed: ${target.target}`); + throw new Error(`Go release executable format changed: ${target.target}`); } - const manifest = JSON.parse(await readFile(join(stageRoot, "go-spike-manifest.json"), "utf8")) as { - readonly schemaVersion?: unknown; - readonly target?: unknown; - readonly host?: { - readonly language?: unknown; - readonly goVersion?: unknown; - readonly commit?: unknown; - readonly entry?: { readonly path?: unknown; readonly bytes?: unknown; readonly sha256?: unknown }; - }; - readonly contractsSha256?: unknown; - readonly cdpAdapterSha256?: unknown; - readonly sidecars?: unknown; - }; - if (manifest.schemaVersion !== 1 || manifest.target !== target.target || manifest.host?.language !== "go" || - typeof manifest.host.goVersion !== "string" || !/^go\d+\.\d+/.test(manifest.host.goVersion) || - typeof manifest.host.commit !== "string" || !/^[a-f0-9]{40}$/.test(manifest.host.commit) || - manifest.host.entry?.path !== definition.executable || manifest.host.entry.bytes !== executable.length || + const manifest = await readReleaseManifest(stageRoot); + if (manifest.version !== report.version || manifest.target !== target.target || + manifest.host.entry.path !== definition.executable || manifest.host.entry.bytes !== executable.length || manifest.host.entry.sha256 !== sha256(executable) || - typeof manifest.contractsSha256 !== "string" || !/^[a-f0-9]{64}$/.test(manifest.contractsSha256) || - typeof manifest.cdpAdapterSha256 !== "string" || !/^[a-f0-9]{64}$/.test(manifest.cdpAdapterSha256) || - !Array.isArray(manifest.sidecars) || manifest.sidecars.length !== 0) { - throw new Error(`Go spike manifest is invalid: ${target.target}`); + manifest.sidecars.length !== 0) { + throw new Error(`Go release manifest is invalid: ${target.target}`); + } + const declaredFiles = new Map(manifest.files.map((file) => [file.path, file])); + for (const path of await walkFiles(stageRoot)) { + if (path === "release-manifest.json") continue; + const contents = await readFile(join(stageRoot, ...path.split("/"))); + const declared = declaredFiles.get(path); + if (declared?.bytes !== contents.length || declared.sha256 !== sha256(contents)) { + throw new Error(`Go release file metadata is invalid: ${target.target}/${path}`); + } + declaredFiles.delete(path); + } + if (declaredFiles.size !== 0) { + throw new Error(`Go release manifest declares missing files: ${target.target}`); } for (const required of [ "apps/theme-studio/dist/index.html", @@ -606,7 +680,9 @@ export async function acceptGoSpikePackages( "themes/builtin/mountain-mist/theme.json", "themes/builtin/rose-carpet-star/theme.json", "themes/builtin/yua-mikami-starlight/theme.json", - "go-spike-manifest.json", + "release-manifest.json", + "README.md", + "README.en.md", ]) { await access(join(outputDirectory, "stages", target.target, PRODUCT, ...required.split("/"))); } @@ -615,10 +691,10 @@ export async function acceptGoSpikePackages( const artifactPath = join(outputDirectory, expected.name); const contents = await readFile(artifactPath); if (contents.length !== expected.bytes || sha256(contents) !== expected.sha256) { - throw new Error(`Go spike artifact hash changed: ${expected.name}`); + throw new Error(`Go release artifact hash changed: ${expected.name}`); } if (expected.bytes >= expected.baselineBytes) { - throw new Error(`Go spike artifact exceeds its baseline: ${expected.name}`); + throw new Error(`Go release artifact exceeds its baseline: ${expected.name}`); } if (expected.kind === "setup-x64" && process.platform === "win32") { await acceptWindowsSetup(artifactPath); @@ -627,19 +703,19 @@ export async function acceptGoSpikePackages( const kinds = new Set(report.artifacts.map(({ kind }) => kind)); const nativeEvidenceComplete = TARGETS.every(({ target }) => report.targets.some((value) => value.target === target)) && [ "zip-x64", "setup-x64", "tar.gz-arm64", "dmg-arm64", "tar.gz-x64", "dmg-x64", - ].every((kind) => kinds.has(kind as GoSpikeArtifactReport["kind"])); + ].every((kind) => kinds.has(kind as GoReleaseArtifactReport["kind"])); if (requireAllNative && !nativeEvidenceComplete) { - throw new Error("Go spike native package evidence is incomplete"); + throw new Error("Go release native package evidence is incomplete"); } return { accepted: true, artifactCount: report.artifacts.length, nativeEvidenceComplete }; } -export async function mergeGoSpikePackages( +export async function mergeGoReleasePackages( inputDirectories: readonly string[], outputDirectoryInput: string, -): Promise { +): Promise { if (inputDirectories.length < 2) { - throw new Error("Go spike merge requires Windows and macOS inputs"); + throw new Error("Go release merge requires Windows and macOS inputs"); } const outputDirectory = resolve(outputDirectoryInput); await rm(outputDirectory, { recursive: true, force: true }); @@ -647,7 +723,7 @@ export async function mergeGoSpikePackages( const inputs = inputDirectories.map((directory) => resolve(directory)); const reports = await Promise.all(inputs.map(async (directory) => ({ directory, - report: JSON.parse(await readFile(join(directory, "go-spike-report.json"), "utf8")) as GoSpikeReport, + report: JSON.parse(await readFile(join(directory, "go-release-report.json"), "utf8")) as GoReleaseReport, }))); const foundation = reports[0]!.report; if (reports.some(({ report }) => @@ -657,23 +733,23 @@ export async function mergeGoSpikePackages( report.cgo !== foundation.cgo || JSON.stringify(report.sidecars) !== JSON.stringify(foundation.sidecars) )) { - throw new Error("Go spike reports do not describe the same source build"); + throw new Error("Go release reports do not describe the same source build"); } const selectedTargets = new Map(); for (const input of reports) { for (const target of input.report.targets) { const existing = selectedTargets.get(target.target); if (existing && JSON.stringify(existing.target) !== JSON.stringify(target)) { - throw new Error(`Go spike target reports conflict: ${target.target}`); + throw new Error(`Go release target reports conflict: ${target.target}`); } selectedTargets.set(target.target, { directory: input.directory, target }); } } if (!TARGETS.every(({ target }) => selectedTargets.has(target))) { - throw new Error("Go spike merge is missing a native target"); + throw new Error("Go release merge is missing a native target"); } for (const [target, value] of selectedTargets) { await cp( @@ -682,9 +758,9 @@ export async function mergeGoSpikePackages( { recursive: true }, ); } - const selected = new Map(); for (const input of reports) { for (const value of input.report.artifacts) { @@ -701,7 +777,7 @@ export async function mergeGoSpikePackages( ); } const targets = TARGETS.map(({ target }) => selectedTargets.get(target)!.target); - const report: GoSpikeReport = { ...foundation, targets, artifacts }; - await writeFile(join(outputDirectory, "go-spike-report.json"), `${JSON.stringify(report, null, 2)}\n`, "utf8"); + const report: GoReleaseReport = { ...foundation, targets, artifacts }; + await writeFile(join(outputDirectory, "go-release-report.json"), `${JSON.stringify(report, null, 2)}\n`, "utf8"); return report; } diff --git a/scripts/release/macos-acceptance.ts b/scripts/release/macos-acceptance.ts deleted file mode 100644 index 7df4c21..0000000 --- a/scripts/release/macos-acceptance.ts +++ /dev/null @@ -1,348 +0,0 @@ -import { spawn } from "node:child_process"; -import { - access, - cp, - lstat, - mkdir, - mkdtemp, - readFile, - readlink, - readdir, - rm, - writeFile, -} from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { - basename, - join, - relative, - resolve, -} from "node:path"; -import { createInterface } from "node:readline"; -import { - acceptReleasePayload, - type ReleaseAcceptanceReport, -} from "./acceptance.js"; -import { macDmgArtifactName } from "./artifact-names.js"; -import { verifyMacAppBundleLayout } from "./macos-app.js"; -import { - assertMacBinaryArchitecture, - MAC_APPLICATIONS_LINK_NAME, - MAC_FIRST_LAUNCH_NOTICE, - MAC_FIRST_LAUNCH_NOTICE_NAME, -} from "./macos-dmg.js"; -import { readReleaseManifest } from "./payload.js"; -import { runReleaseCommand } from "./release-command.js"; - -const LAUNCHER_START_TIMEOUT_MS = 15_000; -const LAUNCHER_STOP_TIMEOUT_MS = 5_000; - -export class MacDmgCleanupError extends Error { - constructor(cause: unknown) { - super("macOS DMG resources could not be cleaned up safely", { cause }); - this.name = "MacDmgCleanupError"; - } -} - -async function collectNativeModules(root: string): Promise { - const files: string[] = []; - async function walk(directory: string): Promise { - for (const entry of await readdir(directory, { withFileTypes: true })) { - const path = join(directory, entry.name); - if (entry.isDirectory()) await walk(path); - else if (entry.isFile() && entry.name.endsWith(".node")) files.push(path); - } - } - await walk(root); - return files.sort(); -} - -async function assertUnsigned(appPath: string): Promise { - try { - await access(join(appPath, "Contents", "_CodeSignature")); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") return; - throw error; - } - throw new Error( - "Unsigned macOS preview unexpectedly contains a bundle signature", - ); -} - -async function smokeLauncher( - launcher: string, - home: string, -): Promise { - await mkdir(home, { recursive: true }); - const environment: NodeJS.ProcessEnv = { ...process.env, HOME: home }; - delete environment.NODE_PATH; - delete environment.NODE_OPTIONS; - await new Promise((resolveSmoke, rejectSmoke) => { - const child = spawn(launcher, ["--no-open"], { - env: environment, - stdio: ["ignore", "pipe", "pipe"], - }); - child.stderr.resume(); - const lines = createInterface({ input: child.stdout }); - let ready = false; - let settled = false; - let stopRequested = false; - let failure: Error | undefined; - let startTimer: NodeJS.Timeout | undefined; - let forceTimer: NodeJS.Timeout | undefined; - - const finish = (error?: Error): void => { - if (settled) return; - settled = true; - if (startTimer) clearTimeout(startTimer); - if (forceTimer) clearTimeout(forceTimer); - lines.close(); - if (error) rejectSmoke(error); - else resolveSmoke(); - }; - - const requestStop = (error?: Error): void => { - if (error && !failure) failure = error; - if (stopRequested) return; - stopRequested = true; - child.kill("SIGTERM"); - forceTimer = setTimeout(() => { - if (child.exitCode === null) child.kill("SIGKILL"); - }, LAUNCHER_STOP_TIMEOUT_MS); - }; - - startTimer = setTimeout(() => { - requestStop(new Error("macOS app launcher did not become ready")); - }, LAUNCHER_START_TIMEOUT_MS); - - lines.once("line", (line) => { - try { - const startup = JSON.parse(line) as { readonly url?: string }; - if (!startup.url?.startsWith("http://127.0.0.1:")) { - throw new Error( - "macOS app launcher returned an invalid startup URL", - ); - } - ready = true; - requestStop(); - } catch (error) { - requestStop(error instanceof Error - ? error - : new Error("macOS app launcher output is invalid")); - } - }); - child.once("error", (error) => finish(error)); - child.once("exit", (code) => { - if (failure) { - finish(failure); - } else if (!ready) { - finish(new Error( - `macOS app launcher exited before readiness with code ${String(code)}`, - )); - } else if (code !== 0) { - finish(new Error( - `macOS app launcher exited with code ${String(code)}`, - )); - } else { - finish(); - } - }); - }); -} - -export interface MacDmgAcceptanceReport { - readonly scenario: "macos-dmg"; - readonly version: string; - readonly target: { - readonly platform: "darwin"; - readonly arch: "x64" | "arm64"; - }; - readonly durationMs: number; - readonly appBundleVerified: true; - readonly installerLayoutVerified: true; - readonly launcherVerified: true; - readonly unsignedPreviewVerified: true; - readonly userDataIsolationVerified: true; - readonly nativeBinariesVerified: number; - readonly payloadAcceptance: ReleaseAcceptanceReport; -} - -export async function acceptMacDmg( - dmgPathInput: string, -): Promise { - if (process.platform !== "darwin") { - throw new Error("macOS DMG acceptance requires Darwin"); - } - const startedAt = Date.now(); - const dmgPath = resolve(dmgPathInput); - await runReleaseCommand( - "/usr/bin/hdiutil", - ["verify", dmgPath], - { captureOutput: true }, - ); - const temporary = await mkdtemp( - join(tmpdir(), "open-chatgpt-skin-dmg-accept-"), - ); - const mountPoint = join(temporary, "mount"); - await mkdir(mountPoint); - let mounted = false; - let report: MacDmgAcceptanceReport | undefined; - let failure: unknown; - try { - await runReleaseCommand( - "/usr/bin/hdiutil", - [ - "attach", - "-readonly", - "-nobrowse", - "-noautoopen", - "-mountpoint", - mountPoint, - dmgPath, - ], - { captureOutput: true }, - ); - mounted = true; - const applicationsLink = join( - mountPoint, - MAC_APPLICATIONS_LINK_NAME, - ); - if (!(await lstat(applicationsLink)).isSymbolicLink() || - await readlink(applicationsLink) !== "/Applications") { - throw new Error("macOS DMG Applications link is invalid"); - } - if (await readFile( - join(mountPoint, MAC_FIRST_LAUNCH_NOTICE_NAME), - "utf8", - ) !== MAC_FIRST_LAUNCH_NOTICE) { - throw new Error("macOS DMG first-launch notice is invalid"); - } - const mountedAppPath = join(mountPoint, "OpenChatGPTSkin.app"); - const mountedBundle = await verifyMacAppBundleLayout(mountedAppPath); - if (basename(dmgPath) !== macDmgArtifactName( - mountedBundle.version, - mountedBundle.arch, - )) { - throw new Error("macOS DMG filename does not match the app bundle"); - } - const installedAppPath = join( - temporary, - "installed", - "OpenChatGPTSkin.app", - ); - await mkdir(join(temporary, "installed")); - await cp(mountedAppPath, installedAppPath, { - recursive: true, - force: false, - errorOnExist: true, - dereference: false, - }); - const bundle = await verifyMacAppBundleLayout(installedAppPath); - if (bundle.version !== mountedBundle.version || - bundle.arch !== mountedBundle.arch) { - throw new Error("Installed macOS app does not match the DMG"); - } - await runReleaseCommand( - "/usr/bin/plutil", - ["-lint", join(installedAppPath, "Contents", "Info.plist")], - { captureOutput: true }, - ); - await assertUnsigned(installedAppPath); - - const payloadRoot = join( - installedAppPath, - "Contents", - "Resources", - "payload", - ); - const manifest = await readReleaseManifest(payloadRoot); - const nativeModules = await collectNativeModules( - join(payloadRoot, "node_modules"), - ); - if (nativeModules.length === 0) { - throw new Error("macOS payload contains no native modules"); - } - const binaries = [ - join(payloadRoot, "runtime", "node"), - ...nativeModules, - ]; - for (const binary of binaries) { - const result = await runReleaseCommand( - "/usr/bin/file", - ["-b", binary], - { captureOutput: true }, - ); - assertMacBinaryArchitecture( - result.stdout, - manifest.target.arch, - relative(payloadRoot, binary).split("\\").join("/"), - ); - } - - const launcherHome = join(temporary, "launcher-home"); - await smokeLauncher( - join(installedAppPath, "Contents", "MacOS", "OpenChatGPTSkin"), - launcherHome, - ); - const userDataRoot = join( - launcherHome, - "Library", - "Application Support", - "OpenChatGPTSkin", - ); - await access(userDataRoot); - const userDataMarker = join( - userDataRoot, - "acceptance-user-data.marker", - ); - await writeFile(userDataMarker, "preserve\n", "utf8"); - const payloadAcceptance = await acceptReleasePayload( - payloadRoot, - "macos-app-bundle", - ); - await rm(installedAppPath, { recursive: true, force: false }); - await access(userDataMarker); - report = { - scenario: "macos-dmg", - version: bundle.version, - target: { platform: "darwin", arch: bundle.arch }, - durationMs: Date.now() - startedAt, - appBundleVerified: true, - installerLayoutVerified: true, - launcherVerified: true, - unsignedPreviewVerified: true, - userDataIsolationVerified: true, - nativeBinariesVerified: binaries.length, - payloadAcceptance, - }; - } catch (error) { - failure = error; - } - - if (mounted) { - try { - await runReleaseCommand( - "/usr/bin/hdiutil", - ["detach", mountPoint], - { captureOutput: true }, - ); - mounted = false; - } catch (error) { - failure = new MacDmgCleanupError( - failure ? new AggregateError([failure, error]) : error, - ); - } - } - if (!mounted) { - try { - await rm(temporary, { recursive: true, force: true }); - } catch (error) { - failure = new MacDmgCleanupError( - failure ? new AggregateError([failure, error]) : error, - ); - } - } - if (failure) throw failure; - if (!report) throw new Error("macOS DMG acceptance produced no report"); - return report; -} diff --git a/scripts/release/macos-app.ts b/scripts/release/macos-app.ts deleted file mode 100644 index 192d90d..0000000 --- a/scripts/release/macos-app.ts +++ /dev/null @@ -1,152 +0,0 @@ -import { - access, - chmod, - copyFile, - cp, - mkdir, - readFile, - stat, - writeFile, -} from "node:fs/promises"; -import { - basename, - isAbsolute, - join, - relative, - resolve, -} from "node:path"; -import { verifyReleasePayload } from "./acceptance.js"; -import { - renderMacInfoPlist, - renderMacLauncher, -} from "./macos-metadata.js"; -import { - readReleaseManifest, - type ReleaseArch, -} from "./payload.js"; -import { - assertPathMissing, - rethrowAfterRemoving, -} from "./release-files.js"; - -export interface MacAppBundleResult { - readonly appPath: string; - readonly payloadRoot: string; - readonly version: string; - readonly arch: ReleaseArch; -} - -function isWithin(parent: string, child: string): boolean { - const nested = relative(parent, child); - return nested === "" || - (!isAbsolute(nested) && nested !== ".." && !nested.startsWith("../") && - !nested.startsWith("..\\")); -} - -export async function buildMacAppBundle(options: { - readonly releaseRoot: string; - readonly outputDirectory: string; - readonly iconPath: string; -}): Promise { - const releaseRoot = resolve(options.releaseRoot); - const outputDirectory = resolve(options.outputDirectory); - const iconPath = resolve(options.iconPath); - if (basename(releaseRoot) !== "OpenChatGPTSkin") { - throw new Error( - "macOS app payload directory must be named OpenChatGPTSkin", - ); - } - await verifyReleasePayload(releaseRoot); - const manifest = await readReleaseManifest(releaseRoot); - if (manifest.target.platform !== "darwin") { - throw new Error("macOS app payload must target darwin"); - } - await access(iconPath); - - const appPath = join(outputDirectory, "OpenChatGPTSkin.app"); - if (isWithin(releaseRoot, appPath)) { - throw new Error("macOS app output must be outside the release payload"); - } - await assertPathMissing(appPath, "macOS app output"); - await mkdir(outputDirectory, { recursive: true }); - const contents = join(appPath, "Contents"); - const payloadRoot = join(contents, "Resources", "payload"); - try { - await mkdir(join(contents, "MacOS"), { recursive: true }); - await mkdir(join(contents, "Resources"), { recursive: true }); - await cp(releaseRoot, payloadRoot, { - recursive: true, - force: false, - errorOnExist: true, - dereference: false, - }); - await copyFile(iconPath, join(contents, "Resources", "AppIcon.icns")); - await writeFile( - join(contents, "Info.plist"), - renderMacInfoPlist({ - productVersion: manifest.version, - arch: manifest.target.arch, - }), - "utf8", - ); - const launcher = join(contents, "MacOS", "OpenChatGPTSkin"); - await writeFile( - launcher, - renderMacLauncher(manifest.version), - "utf8", - ); - await chmod(launcher, 0o755); - await verifyMacAppBundleLayout(appPath); - } catch (error) { - return await rethrowAfterRemoving( - appPath, - error, - "macOS app assembly and cleanup failed", - true, - ); - } - return { - appPath, - payloadRoot, - version: manifest.version, - arch: manifest.target.arch, - }; -} - -export async function verifyMacAppBundleLayout( - appPathInput: string, -): Promise<{ readonly version: string; readonly arch: ReleaseArch }> { - const appPath = resolve(appPathInput); - if (basename(appPath) !== "OpenChatGPTSkin.app") { - throw new Error("macOS app bundle name is invalid"); - } - const contents = join(appPath, "Contents"); - const payloadRoot = join(contents, "Resources", "payload"); - await verifyReleasePayload(payloadRoot); - const manifest = await readReleaseManifest(payloadRoot); - if (manifest.target.platform !== "darwin") { - throw new Error("macOS app bundle contains a non-Darwin payload"); - } - const launcherPath = join(contents, "MacOS", "OpenChatGPTSkin"); - const launcher = await stat(launcherPath); - if (process.platform !== "win32" && (launcher.mode & 0o111) === 0) { - throw new Error("macOS app launcher is not executable"); - } - const expectedLauncher = renderMacLauncher(manifest.version); - if (await readFile(launcherPath, "utf8") !== expectedLauncher) { - throw new Error("macOS app launcher does not match the payload"); - } - const expectedPlist = renderMacInfoPlist({ - productVersion: manifest.version, - arch: manifest.target.arch, - }); - const actualPlist = await readFile( - join(contents, "Info.plist"), - "utf8", - ); - if (actualPlist !== expectedPlist) { - throw new Error("macOS app Info.plist does not match the payload"); - } - await access(join(contents, "Resources", "AppIcon.icns")); - return { version: manifest.version, arch: manifest.target.arch }; -} diff --git a/scripts/release/macos-dmg.ts b/scripts/release/macos-dmg.ts deleted file mode 100644 index 92197cc..0000000 --- a/scripts/release/macos-dmg.ts +++ /dev/null @@ -1,209 +0,0 @@ -import { createHash } from "node:crypto"; -import { - access, - cp, - mkdir, - mkdtemp, - readFile, - rm, - symlink, - writeFile, -} from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { - basename, - join, - resolve, -} from "node:path"; -import sharp from "sharp"; -import { macDmgArtifactName } from "./artifact-names.js"; -import { verifyMacAppBundleLayout } from "./macos-app.js"; -import type { ReleaseArch } from "./payload.js"; -import { - assertPathMissing, - rethrowAfterRemoving, -} from "./release-files.js"; -import { runReleaseCommand } from "./release-command.js"; - -const MAC_ICON_ENTRIES = [ - ["icon_16x16.png", 16], - ["icon_16x16@2x.png", 32], - ["icon_32x32.png", 32], - ["icon_32x32@2x.png", 64], - ["icon_128x128.png", 128], - ["icon_128x128@2x.png", 256], - ["icon_256x256.png", 256], - ["icon_256x256@2x.png", 512], - ["icon_512x512.png", 512], - ["icon_512x512@2x.png", 1024], -] as const; - -export const MAC_APPLICATIONS_LINK_NAME = "Applications"; -export const MAC_FIRST_LAUNCH_NOTICE_NAME = - "首次打开说明 - First Launch.txt"; -export const MAC_FIRST_LAUNCH_NOTICE = [ - "未签名开发者预览:校验 SHA-256 后,将应用拖入 Applications,再右键选择“打开”。", - "Unsigned developer preview: verify SHA-256, drag the app to Applications, then Control-click and choose Open.", - "", -].join("\n"); - -export function macIconEntries(): typeof MAC_ICON_ENTRIES { - return MAC_ICON_ENTRIES; -} - -export function assertMacBinaryArchitecture( - description: string, - arch: ReleaseArch, - label: string, -): void { - const expected = arch === "arm64" ? "arm64" : "x86_64"; - const other = arch === "arm64" ? "x86_64" : "arm64"; - if (!description.includes(expected) || description.includes(other)) { - throw new Error(`${label} does not target ${arch}`); - } -} - -export async function generateMacIcon( - svgPathInput: string, - icnsPathInput: string, -): Promise { - if (process.platform !== "darwin") { - throw new Error("macOS icon generation requires Darwin"); - } - const svgPath = resolve(svgPathInput); - const icnsPath = resolve(icnsPathInput); - await access(svgPath); - await assertPathMissing(icnsPath, "Release artifact"); - const iconset = `${icnsPath}.iconset`; - await mkdir(iconset, { recursive: false }); - let failure: unknown; - try { - const source = await readFile(svgPath); - for (const [name, size] of macIconEntries()) { - await sharp(source) - .resize(size, size) - .png() - .toFile(join(iconset, name)); - } - await runReleaseCommand( - "/usr/bin/iconutil", - ["-c", "icns", iconset, "-o", icnsPath], - { captureOutput: true }, - ); - await access(icnsPath); - } catch (error) { - failure = error; - } - try { - await rm(iconset, { recursive: true, force: true }); - } catch (error) { - failure = failure - ? new AggregateError([failure, error], "macOS icon cleanup failed") - : error; - } - if (failure) { - return await rethrowAfterRemoving( - icnsPath, - failure, - "macOS icon generation and cleanup failed", - ); - } -} - -export interface MacDmgArtifact { - readonly path: string; - readonly name: string; - readonly bytes: number; - readonly sha256: string; -} - -export async function buildMacDmg(options: { - readonly appPath: string; - readonly outputDirectory: string; - readonly version: string; - readonly arch: ReleaseArch; -}): Promise { - if (process.platform !== "darwin") { - throw new Error("DMG packaging requires Darwin"); - } - const appPath = resolve(options.appPath); - if (basename(appPath) !== "OpenChatGPTSkin.app") { - throw new Error("DMG input app must be named OpenChatGPTSkin.app"); - } - const bundle = await verifyMacAppBundleLayout(appPath); - if (bundle.version !== options.version || bundle.arch !== options.arch) { - throw new Error("DMG target does not match the app bundle"); - } - const outputDirectory = resolve(options.outputDirectory); - await mkdir(outputDirectory, { recursive: true }); - const name = macDmgArtifactName(options.version, options.arch); - const output = join(outputDirectory, name); - await assertPathMissing(output, "Release artifact"); - - const source = await mkdtemp( - join(tmpdir(), "open-chatgpt-skin-dmg-"), - ); - let artifact: MacDmgArtifact | undefined; - let failure: unknown; - try { - await cp(appPath, join(source, "OpenChatGPTSkin.app"), { - recursive: true, - force: false, - errorOnExist: true, - dereference: false, - }); - await symlink( - "/Applications", - join(source, MAC_APPLICATIONS_LINK_NAME), - ); - await writeFile( - join(source, MAC_FIRST_LAUNCH_NOTICE_NAME), - MAC_FIRST_LAUNCH_NOTICE, - "utf8", - ); - await runReleaseCommand( - "/usr/bin/hdiutil", - [ - "create", - "-volname", - "OpenChatGPTSkin", - "-srcfolder", - source, - "-format", - "UDZO", - output, - ], - { captureOutput: true }, - ); - await runReleaseCommand( - "/usr/bin/hdiutil", - ["verify", output], - { captureOutput: true }, - ); - const bytes = await readFile(output); - artifact = { - path: output, - name, - bytes: bytes.length, - sha256: createHash("sha256").update(bytes).digest("hex"), - }; - } catch (error) { - failure = error; - } - try { - await rm(source, { recursive: true, force: true }); - } catch (error) { - failure = failure - ? new AggregateError([failure, error], "DMG staging cleanup failed") - : error; - } - if (failure) { - return await rethrowAfterRemoving( - output, - failure, - "DMG packaging and cleanup failed", - ); - } - if (!artifact) throw new Error("DMG packaging produced no artifact"); - return artifact; -} diff --git a/scripts/release/macos-icon.ts b/scripts/release/macos-icon.ts new file mode 100644 index 0000000..f4db800 --- /dev/null +++ b/scripts/release/macos-icon.ts @@ -0,0 +1,54 @@ +import { execFile } from "node:child_process"; +import { access, mkdir, readFile, rm } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { promisify } from "node:util"; +import sharp from "sharp"; + +const execFileAsync = promisify(execFile); +const MAC_ICON_ENTRIES = [ + ["icon_16x16.png", 16], ["icon_16x16@2x.png", 32], + ["icon_32x32.png", 32], ["icon_32x32@2x.png", 64], + ["icon_128x128.png", 128], ["icon_128x128@2x.png", 256], + ["icon_256x256.png", 256], ["icon_256x256@2x.png", 512], + ["icon_512x512.png", 512], ["icon_512x512@2x.png", 1024], +] as const; + +async function assertMissing(path: string): Promise { + try { + await access(path); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return; + throw error; + } + throw new Error(`macOS icon output already exists: ${path}`); +} + +export async function generateMacIcon(svgPathInput: string, icnsPathInput: string): Promise { + if (process.platform !== "darwin") throw new Error("macOS icon generation requires Darwin"); + const svgPath = resolve(svgPathInput); + const icnsPath = resolve(icnsPathInput); + const iconset = `${icnsPath}.iconset`; + await access(svgPath); + await assertMissing(icnsPath); + await mkdir(iconset); + let failure: unknown; + try { + const source = await readFile(svgPath); + for (const [name, size] of MAC_ICON_ENTRIES) { + await sharp(source).resize(size, size).png().toFile(join(iconset, name)); + } + await execFileAsync("/usr/bin/iconutil", ["-c", "icns", iconset, "-o", icnsPath]); + await access(icnsPath); + } catch (error) { + failure = error; + } + try { + await rm(iconset, { recursive: true, force: true }); + if (failure) await rm(icnsPath, { force: true }); + } catch (cleanupError) { + failure = failure + ? new AggregateError([failure, cleanupError], "macOS icon generation and cleanup failed") + : cleanupError; + } + if (failure) throw failure; +} diff --git a/scripts/release/macos-metadata.ts b/scripts/release/macos-metadata.ts deleted file mode 100644 index 80b7f84..0000000 --- a/scripts/release/macos-metadata.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { - assertReleaseVersion, - type ReleaseArch, -} from "./payload.js"; - -function escapeXml(value: string): string { - return value - .replaceAll("&", "&") - .replaceAll("<", "<") - .replaceAll(">", ">") - .replaceAll('"', """) - .replaceAll("'", "'"); -} - -interface ProductVersionParts { - readonly major: string; - readonly minor: string; - readonly patch: string; - readonly prerelease?: "alpha" | "beta" | "rc"; - readonly prereleaseNumber?: string; -} - -function parseProductVersion(productVersion: string): ProductVersionParts { - try { - assertReleaseVersion(productVersion); - } catch { - throw new Error(`Unsupported macOS product version: ${productVersion}`); - } - const [core, prereleaseText] = productVersion.split("-"); - const [major, minor, patch] = core!.split(".") as [string, string, string]; - if (!prereleaseText) return { major, minor, patch }; - const [prerelease, prereleaseNumber] = - prereleaseText.split(".") as ["alpha" | "beta" | "rc", string]; - return { major, minor, patch, prerelease, prereleaseNumber }; -} - -export function toAppleBundleVersion(productVersion: string): string { - const { - major, - minor, - patch, - prerelease, - prereleaseNumber, - } = parseProductVersion(productVersion); - if (!prerelease) return `${major}.${minor}.${patch}`; - const suffix = prerelease === "alpha" - ? "a" - : prerelease === "beta" - ? "b" - : "fc"; - return `${major}.${minor}.${patch}${suffix}${prereleaseNumber}`; -} - -export function renderMacInfoPlist(input: { - readonly productVersion: string; - readonly arch: ReleaseArch; -}): string { - const { major, minor, patch } = parseProductVersion(input.productVersion); - const shortVersion = `${major}.${minor}.${patch}`; - return ` - - - - CFBundleIdentifier - io.github.u2bo.openchatgptskin - CFBundleName - OpenChatGPTSkin - CFBundleDisplayName - OpenChatGPTSkin - CFBundlePackageType - APPL - CFBundleExecutable - OpenChatGPTSkin - CFBundleIconFile - AppIcon - CFBundleShortVersionString - ${escapeXml(shortVersion)} - CFBundleVersion - ${escapeXml(toAppleBundleVersion(input.productVersion))} - OpenChatGPTSkinProductVersion - ${escapeXml(input.productVersion)} - OpenChatGPTSkinArchitecture - ${escapeXml(input.arch)} - LSUIElement - - LSMultipleInstancesProhibited - - - -`; -} - -export function renderMacLauncher(productVersion: string): string { - parseProductVersion(productVersion); - return `#!/bin/sh -set -eu -contents="$(CDPATH= cd "$(dirname "$0")/.." && pwd -P)" -payload="$contents/Resources/payload" -export OPEN_CHATGPT_SKIN_INSTALL_ROOT="$payload" -export OPEN_CHATGPT_SKIN_VERSION="${productVersion}" -exec "$payload/runtime/node" "$payload/node_modules/@open-chatgpt-skin/theme-studio-service/dist/cli.js" "$@" -`; -} diff --git a/scripts/release/manifest.ts b/scripts/release/manifest.ts new file mode 100644 index 0000000..9034cd7 --- /dev/null +++ b/scripts/release/manifest.ts @@ -0,0 +1,72 @@ +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { z } from "zod"; + +const SHA256_PATTERN = /^[0-9a-f]{64}$/; +const PRODUCT_VERSION_PATTERN = /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?$/; + +const ReleaseFileSchema = z.object({ + path: z.string().min(1).max(500).refine((path) => + !path.startsWith("/") && !path.startsWith("\\") && + !path.includes("\\") && !path.split("/").includes(".."), + "release paths must be portable relative paths"), + bytes: z.number().int().positive(), + sha256: z.string().regex(SHA256_PATTERN), +}).strict(); + +export const ReleaseManifestSchema = z.object({ + schemaVersion: z.literal(2), + product: z.literal("OpenChatGPTSkin"), + version: z.string().regex(PRODUCT_VERSION_PATTERN), + target: z.enum(["windows-x64", "macos-arm64", "macos-x64"]), + roles: z.tuple([z.literal("studio"), z.literal("controller"), z.literal("runtime")]), + host: z.object({ + language: z.literal("go"), + goVersion: z.string().regex(/^go\d+\.\d+(?:\.\d+)?$/), + commit: z.string().regex(/^[0-9a-f]{40}$/), + dirty: z.boolean(), + entry: ReleaseFileSchema, + }).strict(), + contracts: z.object({ + studio: z.literal(2), + runtime: z.literal(1), + theme: z.literal(4), + data: z.literal(1), + sha256: z.string().regex(SHA256_PATTERN), + }).strict(), + cdpAdapter: z.object({ + sha256: z.string().regex(SHA256_PATTERN), + }).strict(), + themes: z.object({ + catalogSchemaVersion: z.literal(1), + builtins: z.array(z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/)).min(1), + }).strict(), + image: z.object({ + implementation: z.string().min(1).max(160), + cgo: z.literal(false), + }).strict(), + sidecars: z.array(ReleaseFileSchema).max(0), + files: z.array(ReleaseFileSchema).min(1), +}).strict().superRefine((manifest, context) => { + const builtins = new Set(manifest.themes.builtins); + if (builtins.size !== manifest.themes.builtins.length) { + context.addIssue({ code: z.ZodIssueCode.custom, path: ["themes", "builtins"], message: "built-in theme IDs must be unique" }); + } + const files = new Set(); + for (const [index, file] of manifest.files.entries()) { + if (file.path === "release-manifest.json" || files.has(file.path)) { + context.addIssue({ code: z.ZodIssueCode.custom, path: ["files", index, "path"], message: "release file path is invalid or duplicated" }); + } + files.add(file.path); + } + if (!files.has(manifest.host.entry.path)) { + context.addIssue({ code: z.ZodIssueCode.custom, path: ["host", "entry", "path"], message: "host entry must be declared in files" }); + } +}); + +export type ReleaseManifest = z.infer; + +export async function readReleaseManifest(releaseRoot: string): Promise { + const value = JSON.parse(await readFile(join(releaseRoot, "release-manifest.json"), "utf8")) as unknown; + return ReleaseManifestSchema.parse(value); +} diff --git a/scripts/release/merge-go-spike.ts b/scripts/release/merge-go-release.ts similarity index 52% rename from scripts/release/merge-go-spike.ts rename to scripts/release/merge-go-release.ts index 35a3c02..765ee9a 100644 --- a/scripts/release/merge-go-spike.ts +++ b/scripts/release/merge-go-release.ts @@ -1,5 +1,5 @@ import { resolve } from "node:path"; -import { acceptGoSpikePackages, mergeGoSpikePackages } from "./go-spike.js"; +import { acceptGoReleasePackages, mergeGoReleasePackages } from "./go-release.js"; import { releaseOption } from "./options.js"; try { @@ -7,11 +7,11 @@ try { const inputs = args.flatMap((argument, index) => argument === "--input" && args[index + 1] ? [resolve(args[index + 1]!)] : [] ); - const output = resolve(releaseOption(args, "--output") ?? "artifacts/go-spike-combined"); - const report = await mergeGoSpikePackages(inputs, output); - const acceptance = await acceptGoSpikePackages(output, true); + const output = resolve(releaseOption(args, "--output") ?? "artifacts/release-combined"); + const report = await mergeGoReleasePackages(inputs, output); + const acceptance = await acceptGoReleasePackages(output, true); process.stdout.write(`${JSON.stringify({ report, acceptance }, null, 2)}\n`); } catch (error) { - process.stderr.write(`${JSON.stringify({ error: { code: "GO_SPIKE_MERGE_FAILED", message: error instanceof Error ? error.message : String(error) } }, null, 2)}\n`); + process.stderr.write(`${JSON.stringify({ error: { code: "GO_RELEASE_MERGE_FAILED", message: error instanceof Error ? error.message : String(error) } }, null, 2)}\n`); process.exitCode = 1; } diff --git a/scripts/release/package-portable.ts b/scripts/release/package-portable.ts deleted file mode 100644 index aed3a16..0000000 --- a/scripts/release/package-portable.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { createHash } from "node:crypto"; -import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises"; -import { basename, dirname, join, resolve } from "node:path"; -import { verifyReleasePayload } from "./acceptance.js"; -import { portableArtifactName } from "./artifact-names.js"; -import { readReleaseManifest } from "./payload.js"; -import { assertPathMissing } from "./release-files.js"; -import { runReleaseCommand } from "./release-command.js"; - -export interface PortablePackageResult { - readonly path: string; - readonly name: string; - readonly bytes: number; - readonly sha256: string; -} - -export async function packagePortableRelease( - releaseRootInput: string, - outputDirectoryInput: string, -): Promise { - const releaseRoot = resolve(releaseRootInput); - const outputDirectory = resolve(outputDirectoryInput); - if (basename(releaseRoot) !== "OpenChatGPTSkin") { - throw new Error("Release staging directory must be named OpenChatGPTSkin"); - } - await verifyReleasePayload(releaseRoot); - const manifest = await readReleaseManifest(releaseRoot); - const name = portableArtifactName( - manifest.version, - manifest.target.platform, - manifest.target.arch, - ); - await mkdir(outputDirectory, { recursive: true }); - const output = join(outputDirectory, name); - await assertPathMissing(output, "Release artifact"); - const parent = dirname(releaseRoot); - const directory = basename(releaseRoot); - if (manifest.target.platform === "win32") { - await runReleaseCommand( - "7z", - ["a", "-tzip", "-mx=9", output, directory], - { cwd: parent }, - ); - } else { - await runReleaseCommand( - "tar", - ["-czf", output, "-C", parent, directory], - ); - } - const bytes = await readFile(output); - return { - path: output, - name, - bytes: bytes.length, - sha256: createHash("sha256").update(bytes).digest("hex"), - }; -} - -export async function writeReleaseChecksums( - outputDirectoryInput: string, -): Promise { - const outputDirectory = resolve(outputDirectoryInput); - const releaseSuffixes = [".zip", ".tar.gz", ".exe", ".dmg"] as const; - const files = (await readdir(outputDirectory, { withFileTypes: true })) - .filter((entry) => - entry.isFile() && - releaseSuffixes.some((suffix) => entry.name.endsWith(suffix)) - ) - .map((entry) => entry.name) - .sort(); - if (files.length === 0) throw new Error("No Release artifacts are available for checksums"); - const lines: string[] = []; - for (const file of files) { - const path = join(outputDirectory, file); - const info = await stat(path); - if (!info.isFile()) throw new Error(`Release artifact is not a file: ${file}`); - const digest = createHash("sha256").update(await readFile(path)).digest("hex"); - lines.push(`${digest} ${file}`); - } - const checksums = join(outputDirectory, "checksums.txt"); - await writeFile(checksums, `${lines.join("\n")}\n`, "utf8"); - return checksums; -} diff --git a/scripts/release/package-release.ts b/scripts/release/package-release.ts deleted file mode 100644 index 1fd9c68..0000000 --- a/scripts/release/package-release.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { resolve } from "node:path"; -import { - packagePortableRelease, - writeReleaseChecksums, -} from "./package-portable.js"; -import { requiredReleaseOption } from "./options.js"; - -async function main(): Promise { - const args = process.argv.slice(2); - const outputDirectory = resolve(requiredReleaseOption(args, "--output")); - const artifact = await packagePortableRelease( - resolve(requiredReleaseOption(args, "--release-root")), - outputDirectory, - ); - const checksums = await writeReleaseChecksums(outputDirectory); - process.stdout.write(`${JSON.stringify({ artifact, checksums })}\n`); -} - -void main().catch((error: unknown) => { - process.stderr.write(`${JSON.stringify({ - error: { - code: "RELEASE_PACKAGE_FAILED", - message: error instanceof Error ? error.message : "Release packaging failed", - }, - })}\n`); - process.exitCode = 1; -}); diff --git a/scripts/release/payload.ts b/scripts/release/payload.ts deleted file mode 100644 index fcf2ae4..0000000 --- a/scripts/release/payload.ts +++ /dev/null @@ -1,578 +0,0 @@ -import { createHash } from "node:crypto"; -import { - access, - chmod, - copyFile, - cp, - mkdir, - readFile, - readdir, - stat, - writeFile, -} from "node:fs/promises"; -import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; -import { z } from "zod"; -import { PRODUCT_VERSION_PATTERN } from - "../../packages/theme-studio-core/src/security.js"; - -const PRODUCT = "OpenChatGPTSkin"; -const SERVICE_PACKAGE = "@open-chatgpt-skin/theme-studio-service"; -const INTERNAL_PACKAGE_PREFIX = "@open-chatgpt-skin/"; -const RELEASE_MANIFEST_FILE = "release-manifest.json"; -const BUILTIN_THEME_IDS = [ - "future-idol-cyan", - "rose-carpet-star", - "mountain-mist", - "glacier-aurora", - "yua-mikami-starlight", -] as const; - -export type ReleasePlatform = "win32" | "darwin"; -export type ReleaseArch = "x64" | "arm64"; - -interface PackageJson { - readonly name?: string; - readonly version?: string; - readonly workspaces?: readonly string[]; - readonly dependencies?: Readonly>; - readonly optionalDependencies?: Readonly>; -} - -interface ThemeCatalog { - readonly schemaVersion: number; - readonly builtins: readonly { - readonly id: string; - readonly path: string; - }[]; - readonly recipes?: readonly unknown[]; -} - -interface ThemeManifest { - readonly schemaVersion: number; - readonly themeId: string; - readonly themeVersion: string; - readonly files: Readonly>; -} - -export const ReleaseManifestSchema = z.object({ - schemaVersion: z.literal(1), - product: z.literal(PRODUCT), - version: z.string().regex(/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/), - target: z.object({ - platform: z.enum(["win32", "darwin"]), - arch: z.enum(["x64", "arm64"]), - }).strict(), - runtime: z.object({ nodeVersion: z.string().regex(/^\d+\.\d+\.\d+$/) }).strict(), - build: z.object({ commit: z.string().regex(/^[0-9a-f]{40}$/i) }).strict(), - themeCatalog: z.object({ schemaVersion: z.number().int().positive() }).strict(), - entry: z.enum(["OpenChatGPTSkin.cmd", "OpenChatGPTSkin"]), - themes: z.array(z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/)).min(1), - files: z.record(z.object({ - bytes: z.number().int().nonnegative(), - sha256: z.string().regex(/^[0-9a-f]{64}$/i), - }).strict()), -}).strict(); - -export type ReleaseManifest = z.infer; - -export interface StageReleasePayloadOptions { - readonly workspaceRoot: string; - readonly releaseRoot: string; - readonly version: string; - readonly platform: ReleasePlatform; - readonly arch: ReleaseArch; - readonly nodeVersion: string; - readonly buildCommit: string; - readonly nodeExecutablePath: string; - readonly nodeLicensePath: string; -} - -function sha256(bytes: Uint8Array): string { - return createHash("sha256").update(bytes).digest("hex"); -} - -async function readJson(path: string): Promise { - return JSON.parse(await readFile(path, "utf8")) as T; -} - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -export function assertReleaseVersion( - version: unknown, -): asserts version is string { - if (typeof version !== "string" || - !PRODUCT_VERSION_PATTERN.test(version)) { - throw new Error(`Release version is invalid: ${String(version)}`); - } -} - -function assertBuildCommit(commit: unknown): asserts commit is string { - if (typeof commit !== "string" || !/^[0-9a-f]{40}$/i.test(commit)) { - throw new Error(`Release build commit is invalid: ${String(commit)}`); - } -} - -function assertNodeVersion(version: unknown): asserts version is string { - if (typeof version !== "string" || !/^\d+\.\d+\.\d+$/.test(version)) { - throw new Error( - `Release Node Runtime version is invalid: ${String(version)}`, - ); - } -} - -export function assertReleaseTarget(platform: unknown, arch: unknown): void { - if (platform !== "win32" && platform !== "darwin") { - throw new Error(`Unsupported release platform: ${platform}`); - } - if (arch !== "x64" && arch !== "arm64") { - throw new Error(`Unsupported release architecture: ${arch}`); - } -} - -function releaseRelativePath(path: string): string { - return path.split(sep).join("/"); -} - -function assertSafeRelativePath(path: string): void { - if (!path || isAbsolute(path) || path.includes("\\")) { - throw new Error(`Release path is invalid: ${path}`); - } - const normalized = path.split("/"); - if (normalized.some((part) => !part || part === "." || part === "..")) { - throw new Error(`Release path is invalid: ${path}`); - } -} - -async function copyIntoRelease( - source: string, - releaseRoot: string, - target: string, -): Promise { - assertSafeRelativePath(target); - const destination = join(releaseRoot, ...target.split("/")); - await mkdir(dirname(destination), { recursive: true }); - await copyFile(source, destination); -} - -async function pathExists(path: string): Promise { - try { - await access(path); - return true; - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") return false; - throw error; - } -} - -async function workspacePackages( - workspaceRoot: string, -): Promise> { - const rootPackage = await readJson(join(workspaceRoot, "package.json")); - const packages = new Map(); - for (const pattern of rootPackage.workspaces ?? []) { - if (!pattern.endsWith("/*")) { - throw new Error(`Unsupported workspace pattern in release build: ${pattern}`); - } - const base = join(workspaceRoot, ...pattern.slice(0, -2).split("/")); - if (!await pathExists(base)) continue; - for (const entry of await readdir(base, { withFileTypes: true })) { - if (!entry.isDirectory()) continue; - const directory = join(base, entry.name); - const packagePath = join(directory, "package.json"); - if (!await pathExists(packagePath)) continue; - const json = await readJson(packagePath); - if (!json.name) throw new Error(`Workspace package has no name: ${packagePath}`); - packages.set(json.name, { directory, json }); - } - } - return packages; -} - -function packageTarget(name: string): string { - return `node_modules/${name}`; -} - -async function copyPackageDirectory( - source: string, - destination: string, -): Promise { - await cp(source, destination, { - recursive: true, - dereference: false, - filter: (path) => { - const relativePath = relative(source, path); - if (!relativePath) return true; - const first = relativePath.split(sep)[0]; - return first !== "node_modules" && first !== ".git" && - !relativePath.endsWith(".map") && - !/\.d\.(?:c|m)?ts$/.test(relativePath) && - !relativePath.endsWith(".tsbuildinfo"); - }, - }); -} - -async function findExternalPackage( - workspaceRoot: string, - requester: string, - name: string, -): Promise { - const segments = name.split("/"); - const candidates = [ - join(requester, "node_modules", ...segments), - join(workspaceRoot, "node_modules", ...segments), - ]; - for (const candidate of candidates) { - if (await pathExists(join(candidate, "package.json"))) return candidate; - } - return undefined; -} - -async function copyRuntimePackages( - workspaceRoot: string, - releaseRoot: string, - version: string, -): Promise { - const packages = await workspacePackages(workspaceRoot); - if (!packages.has(SERVICE_PACKAGE)) { - throw new Error(`Release entry package is missing: ${SERVICE_PACKAGE}`); - } - - const internalQueue = [SERVICE_PACKAGE]; - const externalQueue: { name: string; requester: string }[] = []; - const copiedInternal = new Set(); - while (internalQueue.length > 0) { - const name = internalQueue.shift()!; - if (copiedInternal.has(name)) continue; - const workspacePackage = packages.get(name); - if (!workspacePackage) throw new Error(`Internal dependency is missing: ${name}`); - if (workspacePackage.json.version !== version) { - throw new Error( - `Workspace package version mismatch for ${name}: ${workspacePackage.json.version ?? "missing"}`, - ); - } - if (!await pathExists(join(workspacePackage.directory, "dist"))) { - throw new Error(`Workspace package is not built: ${name}`); - } - const target = join(releaseRoot, ...packageTarget(name).split("/")); - await mkdir(dirname(target), { recursive: true }); - await mkdir(target); - await copyFile( - join(workspacePackage.directory, "package.json"), - join(target, "package.json"), - ); - await copyPackageDirectory( - join(workspacePackage.directory, "dist"), - join(target, "dist"), - ); - copiedInternal.add(name); - - for (const dependency of Object.keys(workspacePackage.json.dependencies ?? {})) { - if (dependency.startsWith(INTERNAL_PACKAGE_PREFIX)) { - internalQueue.push(dependency); - } else { - externalQueue.push({ name: dependency, requester: workspacePackage.directory }); - } - } - } - - const copiedExternal = new Map(); - while (externalQueue.length > 0) { - const dependency = externalQueue.shift()!; - const source = await findExternalPackage( - workspaceRoot, - dependency.requester, - dependency.name, - ); - if (!source) { - throw new Error(`Production dependency is not installed: ${dependency.name}`); - } - const json = await readJson(join(source, "package.json")); - const identity = `${dependency.name}@${json.version ?? "missing"}`; - const previous = copiedExternal.get(dependency.name); - if (previous) { - if (previous !== identity) { - throw new Error(`Conflicting production dependency versions for ${dependency.name}`); - } - continue; - } - const target = join(releaseRoot, ...packageTarget(dependency.name).split("/")); - await mkdir(dirname(target), { recursive: true }); - await copyPackageDirectory(source, target); - copiedExternal.set(dependency.name, identity); - for (const child of Object.keys({ - ...json.dependencies, - ...json.optionalDependencies, - })) { - const childSource = await findExternalPackage(workspaceRoot, source, child); - if (!childSource) { - if (json.optionalDependencies?.[child] !== undefined) continue; - throw new Error(`Production dependency is not installed: ${child}`); - } - externalQueue.push({ name: child, requester: source }); - } - } -} - -async function copyThemes( - workspaceRoot: string, - releaseRoot: string, -): Promise<{ - readonly ids: readonly string[]; - readonly catalogSchemaVersion: number; -}> { - const themesRoot = join(workspaceRoot, "themes"); - const catalog = await readJson(join(themesRoot, "catalog.json")); - if (!Number.isSafeInteger(catalog.schemaVersion) || catalog.schemaVersion < 1) { - throw new Error("Release theme catalog schema version is invalid"); - } - const actualIds = catalog.builtins.map((theme) => theme.id); - if (actualIds.length !== BUILTIN_THEME_IDS.length || - BUILTIN_THEME_IDS.some((id) => !actualIds.includes(id))) { - throw new Error(`Release catalog must contain the five built-in themes`); - } - if ((catalog.recipes?.length ?? 0) !== 0) { - throw new Error("Release catalog must not contain local recipes"); - } - await copyIntoRelease( - join(themesRoot, "catalog.json"), - releaseRoot, - "themes/catalog.json", - ); - - for (const theme of catalog.builtins) { - assertSafeRelativePath(theme.path); - if (!theme.path.startsWith("builtin/")) { - throw new Error(`Release theme path is not built-in: ${theme.path}`); - } - const sourceDirectory = join(themesRoot, ...theme.path.split("/")); - const manifest = await readJson(join(sourceDirectory, "manifest.json")); - if (manifest.themeId !== theme.id) { - throw new Error(`Theme manifest identity mismatch: ${theme.id}`); - } - await copyIntoRelease( - join(sourceDirectory, "manifest.json"), - releaseRoot, - `themes/${theme.path}/manifest.json`, - ); - await copyIntoRelease( - join(sourceDirectory, "LICENSE.md"), - releaseRoot, - `themes/${theme.path}/LICENSE.md`, - ); - for (const [file, expected] of Object.entries(manifest.files)) { - assertSafeRelativePath(file); - if (/source\.png$/i.test(file)) { - throw new Error(`Authoring-only theme asset is present in a runtime manifest: ${file}`); - } - const bytes = await readFile(join(sourceDirectory, ...file.split("/"))); - if (bytes.length !== expected.bytes || sha256(bytes) !== expected.sha256) { - throw new Error(`Theme asset failed manifest verification: ${theme.id}/${file}`); - } - await copyIntoRelease( - join(sourceDirectory, ...file.split("/")), - releaseRoot, - `themes/${theme.path}/${file}`, - ); - } - } - return { - ids: actualIds, - catalogSchemaVersion: catalog.schemaVersion, - }; -} - -async function walkFiles(root: string, current: string = root): Promise { - const files: string[] = []; - for (const entry of await readdir(current, { withFileTypes: true })) { - const path = join(current, entry.name); - if (entry.isSymbolicLink()) { - throw new Error(`Release payload must not contain symbolic links: ${path}`); - } - if (entry.isDirectory()) { - files.push(...await walkFiles(root, path)); - } else if (entry.isFile()) { - files.push(releaseRelativePath(relative(root, path))); - } - } - return files.sort(); -} - -async function writeLaunchers( - releaseRoot: string, - version: string, - platform: ReleasePlatform, -): Promise { - if (platform === "win32") { - const entry = "OpenChatGPTSkin.cmd" as const; - await writeFile(join(releaseRoot, entry), [ - "@echo off", - "setlocal", - 'set "OPEN_CHATGPT_SKIN_INSTALL_ROOT=%~dp0"', - `set "OPEN_CHATGPT_SKIN_VERSION=${version}"`, - '"%~dp0runtime\\node.exe" "%~dp0node_modules\\@open-chatgpt-skin\\theme-studio-service\\dist\\cli.js" %*', - "", - ].join("\r\n"), "utf8"); - return entry; - } - - const entry = "OpenChatGPTSkin" as const; - await writeFile(join(releaseRoot, entry), [ - "#!/usr/bin/env bash", - "set -euo pipefail", - 'root="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"', - 'export OPEN_CHATGPT_SKIN_INSTALL_ROOT="$root"', - `export OPEN_CHATGPT_SKIN_VERSION="${version}"`, - 'exec "$root/runtime/node" "$root/node_modules/@open-chatgpt-skin/theme-studio-service/dist/cli.js" "$@"', - "", - ].join("\n"), "utf8"); - await chmod(join(releaseRoot, entry), 0o755); - return entry; -} - -export async function stageReleasePayload( - options: StageReleasePayloadOptions, -): Promise { - assertReleaseVersion(options.version); - assertBuildCommit(options.buildCommit); - assertNodeVersion(options.nodeVersion); - assertReleaseTarget(options.platform, options.arch); - const workspaceRoot = resolve(options.workspaceRoot); - const releaseRoot = resolve(options.releaseRoot); - const outputWithinWorkspace = relative(workspaceRoot, releaseRoot); - if (outputWithinWorkspace === "" || ( - !isAbsolute(outputWithinWorkspace) && - outputWithinWorkspace !== ".." && - !outputWithinWorkspace.startsWith(`..${sep}`) - )) { - throw new Error("Release output must be outside the source workspace"); - } - const rootPackage = await readJson(join(workspaceRoot, "package.json")); - if (rootPackage.version !== options.version) { - throw new Error( - `Root package version does not match release version: ${rootPackage.version ?? "missing"}`, - ); - } - - await mkdir(dirname(releaseRoot), { recursive: true }); - await mkdir(releaseRoot); - await Promise.all([ - copyIntoRelease( - join(workspaceRoot, "apps", "theme-studio", "dist", "index.html"), - releaseRoot, - "apps/theme-studio/dist/index.html", - ), - copyIntoRelease(join(workspaceRoot, "README.md"), releaseRoot, "README.md"), - copyIntoRelease(join(workspaceRoot, "README.en.md"), releaseRoot, "README.en.md"), - copyIntoRelease(join(workspaceRoot, "LICENSE"), releaseRoot, "LICENSE"), - copyIntoRelease( - options.nodeExecutablePath, - releaseRoot, - options.platform === "win32" ? "runtime/node.exe" : "runtime/node", - ), - copyIntoRelease( - options.nodeLicensePath, - releaseRoot, - "runtime/LICENSE", - ), - ]); - if (options.platform === "darwin") { - await chmod(join(releaseRoot, "runtime", "node"), 0o755); - } - - await copyRuntimePackages(workspaceRoot, releaseRoot, options.version); - const themes = await copyThemes(workspaceRoot, releaseRoot); - const entry = await writeLaunchers( - releaseRoot, - options.version, - options.platform, - ); - - const files: Record = {}; - for (const file of await walkFiles(releaseRoot)) { - if (file === RELEASE_MANIFEST_FILE) continue; - const bytes = await readFile(join(releaseRoot, ...file.split("/"))); - files[file] = { bytes: bytes.length, sha256: sha256(bytes) }; - } - const manifest: ReleaseManifest = { - schemaVersion: 1, - product: PRODUCT, - version: options.version, - target: { platform: options.platform, arch: options.arch }, - runtime: { nodeVersion: options.nodeVersion }, - build: { commit: options.buildCommit.toLowerCase() }, - themeCatalog: { schemaVersion: themes.catalogSchemaVersion }, - entry, - themes: themes.ids, - files, - }; - await writeFile( - join(releaseRoot, RELEASE_MANIFEST_FILE), - `${JSON.stringify(manifest, null, 2)}\n`, - "utf8", - ); - return manifest; -} - -export async function readReleaseManifest( - releaseRoot: string, -): Promise { - const manifest = await readJson( - join(releaseRoot, RELEASE_MANIFEST_FILE), - ); - if (!isRecord(manifest) || - manifest.schemaVersion !== 1 || - manifest.product !== PRODUCT) { - throw new Error("Release manifest identity is invalid"); - } - assertReleaseVersion(manifest.version); - if (!isRecord(manifest.target)) { - throw new Error("Release manifest target is missing"); - } - assertReleaseTarget(manifest.target.platform, manifest.target.arch); - if (!isRecord(manifest.runtime)) { - throw new Error("Release manifest Runtime metadata is missing"); - } - assertNodeVersion(manifest.runtime.nodeVersion); - if (!isRecord(manifest.build)) { - throw new Error("Release manifest build metadata is missing"); - } - assertBuildCommit(manifest.build.commit); - const expectedEntry = manifest.target.platform === "win32" - ? "OpenChatGPTSkin.cmd" - : "OpenChatGPTSkin"; - if (manifest.entry !== expectedEntry) { - throw new Error("Release manifest entry does not match its target"); - } - if (!isRecord(manifest.themeCatalog) || - !Number.isSafeInteger(manifest.themeCatalog.schemaVersion) || - (manifest.themeCatalog.schemaVersion as number) < 1) { - throw new Error("Release manifest theme catalog is invalid"); - } - if (!Array.isArray(manifest.themes) || - manifest.themes.length === 0 || - manifest.themes.some((theme) => - typeof theme !== "string" || - !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(theme) - ) || - new Set(manifest.themes).size !== manifest.themes.length || - !isRecord(manifest.files) || - Object.keys(manifest.files).length === 0) { - throw new Error("Release manifest contents are invalid"); - } - for (const [path, metadata] of Object.entries(manifest.files)) { - assertSafeRelativePath(path); - if (!isRecord(metadata) || - !Number.isSafeInteger(metadata.bytes) || - (metadata.bytes as number) < 0 || - typeof metadata.sha256 !== "string" || - !/^[0-9a-f]{64}$/i.test(metadata.sha256)) { - throw new Error(`Release manifest file metadata is invalid: ${path}`); - } - } - return ReleaseManifestSchema.parse(manifest); -} diff --git a/scripts/release/release-command.ts b/scripts/release/release-command.ts deleted file mode 100644 index 7f44645..0000000 --- a/scripts/release/release-command.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { spawn } from "node:child_process"; - -export interface ReleaseCommandResult { - readonly stdout: string; - readonly stderr: string; -} - -export async function runReleaseCommand( - command: string, - args: readonly string[], - options: { - readonly cwd?: string; - readonly captureOutput?: boolean; - } = {}, -): Promise { - const captureOutput = options.captureOutput ?? false; - return await new Promise((resolveRun, rejectRun) => { - const child = spawn(command, args, { - ...(options.cwd ? { cwd: options.cwd } : {}), - stdio: captureOutput ? ["ignore", "pipe", "pipe"] : "inherit", - windowsHide: true, - }); - const stdout: Buffer[] = []; - const stderr: Buffer[] = []; - child.stdout?.on("data", (chunk: Buffer) => stdout.push(chunk)); - child.stderr?.on("data", (chunk: Buffer) => stderr.push(chunk)); - child.once("error", rejectRun); - child.once("exit", (code) => { - const result = { - stdout: Buffer.concat(stdout).toString("utf8"), - stderr: Buffer.concat(stderr).toString("utf8"), - }; - if (code === 0) { - resolveRun(result); - return; - } - const detail = result.stderr.trim(); - rejectRun(new Error( - `${command} exited with code ${String(code)}${detail ? `: ${detail}` : ""}`, - )); - }); - }); -} diff --git a/scripts/release/release-files.ts b/scripts/release/release-files.ts deleted file mode 100644 index 76fd32e..0000000 --- a/scripts/release/release-files.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { - access, - rm, -} from "node:fs/promises"; - -export async function assertPathMissing( - path: string, - label: string, -): Promise { - try { - await access(path); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") return; - throw error; - } - throw new Error(`${label} already exists: ${path}`); -} - -export async function rethrowAfterRemoving( - path: string, - failure: unknown, - message: string, - recursive = false, -): Promise { - try { - await rm(path, { recursive, force: true }); - } catch (cleanupError) { - throw new AggregateError([failure, cleanupError], message); - } - throw failure; -} diff --git a/scripts/release/report.ts b/scripts/release/report.ts deleted file mode 100644 index eb0510f..0000000 --- a/scripts/release/report.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { mkdir, writeFile } from "node:fs/promises"; -import { dirname, resolve } from "node:path"; - -export async function writeReleaseReport( - outputPath: string | undefined, - report: unknown, -): Promise { - if (!outputPath) return; - const path = resolve(outputPath); - await mkdir(dirname(path), { recursive: true }); - await writeFile(path, `${JSON.stringify(report, null, 2)}\n`, "utf8"); -} diff --git a/scripts/release/stage-release.ts b/scripts/release/stage-release.ts deleted file mode 100644 index 4af60ae..0000000 --- a/scripts/release/stage-release.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { readFile } from "node:fs/promises"; -import { resolve } from "node:path"; -import { releaseOption, requiredReleaseOption } from "./options.js"; -import { stageReleasePayload } from "./payload.js"; - -async function main(): Promise { - const args = process.argv.slice(2); - const workspaceRoot = resolve(releaseOption(args, "--workspace") ?? "."); - const rootPackage = JSON.parse( - await readFile(resolve(workspaceRoot, "package.json"), "utf8"), - ) as { version?: string }; - const version = releaseOption(args, "--version") ?? rootPackage.version; - if (!version) throw new Error("Root package version is missing"); - const platform = (releaseOption(args, "--platform") ?? process.platform) as - "win32" | "darwin"; - const arch = (releaseOption(args, "--arch") ?? process.arch) as "x64" | "arm64"; - if (platform !== "win32" && platform !== "darwin") { - throw new Error(`Unsupported release platform: ${platform}`); - } - if (arch !== "x64" && arch !== "arm64") { - throw new Error(`Unsupported release architecture: ${arch}`); - } - - const manifest = await stageReleasePayload({ - workspaceRoot, - releaseRoot: resolve(requiredReleaseOption(args, "--output")), - version, - platform, - arch, - nodeVersion: releaseOption(args, "--node-version") ?? process.versions.node, - buildCommit: requiredReleaseOption(args, "--build-commit"), - nodeExecutablePath: resolve(releaseOption(args, "--node-executable") ?? process.execPath), - nodeLicensePath: resolve(requiredReleaseOption(args, "--node-license")), - }); - process.stdout.write(`${JSON.stringify({ - product: manifest.product, - version: manifest.version, - target: manifest.target, - files: Object.keys(manifest.files).length, - })}\n`); -} - -void main().catch((error: unknown) => { - process.stderr.write(`${JSON.stringify({ - error: { - code: "RELEASE_STAGE_FAILED", - message: error instanceof Error ? error.message : "Release staging failed", - }, - })}\n`); - process.exitCode = 1; -}); diff --git a/scripts/release/windows-installer.iss b/scripts/release/windows-installer.iss deleted file mode 100644 index 6ccd48b..0000000 --- a/scripts/release/windows-installer.iss +++ /dev/null @@ -1,74 +0,0 @@ -#ifndef AppVersion - #error AppVersion must be provided with /DAppVersion=x.y.z -#endif -#ifndef ReleaseRoot - #error ReleaseRoot must be provided with /DReleaseRoot=path -#endif -#ifndef OutputDirectory - #error OutputDirectory must be provided with /DOutputDirectory=path -#endif - -#define AppName "OpenChatGPTSkin" -#define AppPublisher "OpenChatGPTSkin Contributors" -#define AppUrl "https://github.com/u2bo/OpenChatGPTSkin" - -[Setup] -AppId={{A7E2825E-2E95-4AF1-B0B7-CC5D7482AF36} -AppName={#AppName} -AppVersion={#AppVersion} -AppPublisher={#AppPublisher} -AppPublisherURL={#AppUrl} -AppSupportURL={#AppUrl}/issues -DefaultDirName={localappdata}\Programs\OpenChatGPTSkin -DefaultGroupName=OpenChatGPTSkin -DisableProgramGroupPage=yes -PrivilegesRequired=lowest -ArchitecturesAllowed=x64os -ArchitecturesInstallIn64BitMode=x64os -Compression=lzma2/ultra64 -SolidCompression=yes -WizardStyle=modern -OutputDir={#OutputDirectory} -OutputBaseFilename=OpenChatGPTSkin_{#AppVersion}_windows_x64_Setup -UninstallDisplayIcon={app}\OpenChatGPTSkin.cmd -CloseApplications=no -RestartApplications=no -SetupLogging=yes - -[Files] -Source: "{#ReleaseRoot}\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs - -[Icons] -Name: "{group}\OpenChatGPTSkin"; Filename: "{app}\OpenChatGPTSkin.cmd"; WorkingDir: "{app}" -Name: "{group}\卸载 OpenChatGPTSkin"; Filename: "{uninstallexe}" - -[Run] -Filename: "{app}\OpenChatGPTSkin.cmd"; Description: "启动 OpenChatGPTSkin"; Flags: nowait postinstall skipifsilent - -[Code] -procedure CurUninstallStepChanged(CurUninstallStep: TUninstallStep); -var - DataRoot: String; -begin - if (CurUninstallStep <> usPostUninstall) or UninstallSilent then - Exit; - - DataRoot := ExpandConstant('{localappdata}\OpenChatGPTSkin'); - if not DirExists(DataRoot) then - Exit; - - if MsgBox( - '是否同时删除个人主题、草稿、版本和 Runtime 状态?' + #13#10 + #13#10 + - '此操作不可恢复。选择“否”将保留全部个人数据。', - mbConfirmation, - MB_YESNO or MB_DEFBUTTON2 - ) <> IDYES then - Exit; - - if not DelTree(DataRoot, True, True, True) then - MsgBox( - '个人数据未能全部删除。请关闭 OpenChatGPTSkin 和 Codex 后,手动删除:' + #13#10 + DataRoot, - mbError, - MB_OK - ); -end; diff --git a/scripts/release/write-checksums.ts b/scripts/release/write-checksums.ts index 897be02..105cda8 100644 --- a/scripts/release/write-checksums.ts +++ b/scripts/release/write-checksums.ts @@ -1,6 +1,6 @@ import { resolve } from "node:path"; import { requiredReleaseOption } from "./options.js"; -import { writeReleaseChecksums } from "./package-portable.js"; +import { writeReleaseChecksums } from "./checksums.js"; const args = process.argv.slice(2); const output = requiredReleaseOption(args, "--output"); diff --git a/tests/docs.test.ts b/tests/docs.test.ts index e61ca1a..830e7de 100644 --- a/tests/docs.test.ts +++ b/tests/docs.test.ts @@ -3,299 +3,105 @@ import { describe, expect, it } from "vitest"; describe("project documentation", () => { it("does not publish links to local-only design artifacts", async () => { - const readmes = await Promise.all([ - readFile("README.md", "utf8"), - readFile("README.en.md", "utf8"), - ]); - - for (const readme of readmes) { - expect(readme).not.toMatch( - /docs\/superpowers\/|docs\/assets\/design-qa\/|design-qa\.md/, - ); - } - }); - - it("documents format limits, rights, and all catalog IDs", async () => { - const [readme, text] = await Promise.all([ - readFile("README.md", "utf8"), - readFile("docs/theme-format.md", "utf8"), - ]); - - for (const required of [ - ".ocskin", - "50 MB", - "32 MB", - "16 MB", - "5 MB", - "localOnly", - "manifest.json", - "sidebar", - "topbar", - "composer", - "content-layer", - "future-idol-cyan", - "rose-carpet-star", - "mountain-mist", - "glacier-aurora", - ]) { - expect(text).toContain(required); + for (const readme of await Promise.all([readFile("README.md", "utf8"), readFile("README.en.md", "utf8")])) { + expect(readme).not.toMatch(/docs\/superpowers\/|docs\/assets\/design-qa\/|design-qa\.md/); } - expect(readme).toContain("Windows x64 正式版"); - expect(readme).toContain("当前 Runtime 能识别的全部 Codex UI 表面"); - expect(readme).toContain("npm run verify:foundation"); - expect(text).not.toContain("hatsune-miku-local"); - expect(text).not.toContain("dilraba-local"); - expect(readme).not.toContain("初音未来或迪丽热巴"); - expect(readme).toContain("npm run runtime:probe -- --record-evidence"); - expect(readme).toContain("npm run runtime:probe -- --finalize"); - expect(readme).toContain("不会强制结束已有 ChatGPT"); }); - it("documents the Windows compatibility gate without overstating runtime availability", async () => { - const [readme, runtime, evidenceRaw] = await Promise.all([ - readFile("README.md", "utf8"), - readFile("docs/runtime-windows.md", "utf8"), - readFile("docs/runtime-probes/codex-26.707.12708.0.json", "utf8"), - ]); - const documented = `${readme}\n${runtime}`; - + it("documents format limits, rights, and all five catalog IDs", async () => { + const format = await readFile("docs/theme-format.md", "utf8"); for (const required of [ - "Windows 兼容性门已通过", - "npm run runtime -- list-themes", - "npm run runtime -- launch --theme mountain-mist", - "npm run runtime -- switch --theme glacier-aurora", - "npm run runtime -- import --theme-file", - "npm run runtime -- pause", - "npm run runtime -- resume", - "npm run runtime -- restore", - "npm run runtime:acceptance -- --begin", - "npm run runtime:acceptance -- --finalize", - "future-idol-cyan", - "rose-carpet-star", - "mountain-mist", - "glacier-aurora", - "npm run runtime:probe -- --record-evidence", - "npm run runtime:probe -- --finalize", - "127.0.0.1", - "不会修改 WindowsApps、`app.asar`", - "不要使用任务管理器强制结束", - "Theme Studio 完整本地闭环", - "Windows x64 便携 ZIP 与用户级 Setup", + ".ocskin", "50 MB", "32 MB", "16 MB", "5 MB", "localOnly", "manifest.json", + "sidebar", "topbar", "composer", "content-layer", "future-idol-cyan", "rose-carpet-star", + "mountain-mist", "glacier-aurora", "yua-mikami-starlight", ]) { - expect(documented).toContain(required); + expect(format).toContain(required); } - const evidence = JSON.parse(evidenceRaw) as Record; - expect(evidence).toMatchObject({ - schemaVersion: 2, - packageIdentity: "OpenAI.Codex", - markerRoundTrip: true, - loopbackOnly: true, - managedExitVerified: true, - cdpClosedVerified: true, - }); - expect(JSON.stringify(evidence)).not.toMatch( - /127\.0\.0\.1|Program Files|Users|ws:\/\/|project|title|text|pid|port/i, - ); + expect(format).not.toContain("hatsune-miku-local"); + expect(format).not.toContain("dilraba-local"); }); - it("documents the delivered Theme Studio editing and Runtime application closure", async () => { - const [readme, studio] = await Promise.all([ - readFile("README.md", "utf8"), + it("documents the Go-only v0.3 release and current developer commands", async () => { + const [readme, readmeEn, notes, windows, mac, macEn, studio] = await Promise.all([ + readFile("README.md", "utf8"), readFile("README.en.md", "utf8"), + readFile("docs/releases/v0.3.0-alpha.1.md", "utf8"), readFile("docs/runtime-windows.md", "utf8"), + readFile("docs/runtime-macos.md", "utf8"), readFile("docs/runtime-macos.en.md", "utf8"), readFile("docs/theme-studio.md", "utf8"), ]); - - expect(readme).toContain("docs/theme-studio.md"); - expect(studio).toContain("npm run studio:dev"); - expect(studio).toContain("创建草稿 → 隔离预览 → 校验 → 保存不可变版本 → 导入导出 → 应用到真实 Codex"); - expect(studio).toContain("完整语义字体颜色"); - expect(studio).toContain("精确 `{id, version}` Runtime 应用"); - expect(studio).toContain("首页与任务工作区双视图预览"); - expect(studio).toContain("真实 Codex 会持续识别首页、任务路由、设置页、工作台面板、终端、应用菜单和弹层"); - expect(studio).toContain("刷新或重启 Studio 后自动打开最近草稿"); - expect(studio).toContain("点击主题后会立即加载并在成功后自动切换到编辑工具"); - expect(studio).toContain("取消则保持主题库不变"); - expect(studio).toContain("只有点击“保存版本”才会写入个人主题版本"); - expect(studio).toContain("加载已有草稿或覆盖现有草稿"); - expect(studio).toContain("单版本或整个个人主题删除"); + for (const document of [readme, readmeEn]) { + expect(document).toContain("v0.3.0-alpha.1"); + expect(document).toContain("OpenChatGPTSkin_0.3.0-alpha.1_windows_x64_Setup.exe"); + expect(document).toContain("OpenChatGPTSkin_0.3.0-alpha.1_macos_arm64.dmg"); + expect(document).toContain("OpenChatGPTSkin_0.3.0-alpha.1_macos_x64.dmg"); + expect(document).toContain("npm run runtime -- list-themes"); + expect(document).toContain("npm run runtime -- import --theme-file"); + expect(document).toContain("npm run runtime -- restore"); + expect(document).not.toContain("npm run runtime:probe"); + expect(document).not.toContain("npm run runtime:acceptance"); + expect(document).not.toContain("OpenChatGPTSkin.cmd"); + } + expect(`${windows}\n${mac}\n${macEn}`).not.toContain("@open-chatgpt-skin/windows-runtime"); + expect(windows).toContain("release-manifest.json"); + expect(windows).toContain("Node/Go 双写"); + expect(mac).toContain("五主题结果"); + expect(macEn).toContain("five-theme results"); + expect(studio).toContain("单一 Go Host"); + expect(studio).toContain("OpenChatGPTSkin.exe"); + expect(notes).toContain("跨平台 Go Host"); + expect(notes).toContain("Node Runtime"); }); - it("ships bilingual release documentation, custom-theme prompts, and screenshots", async () => { - const [readme, readmeEn, guide, guideEn, contributing, license, macRuntime, macRuntimeEn, releaseNotes] = await Promise.all([ - readFile("README.md", "utf8"), - readFile("README.en.md", "utf8"), - readFile("docs/custom-theme-guide.md", "utf8"), - readFile("docs/custom-theme-guide.en.md", "utf8"), - readFile("CONTRIBUTING.md", "utf8"), - readFile("LICENSE", "utf8"), - readFile("docs/runtime-macos.md", "utf8"), - readFile("docs/runtime-macos.en.md", "utf8"), - readFile("docs/releases/v0.1.0.md", "utf8"), + it("ships bilingual custom-theme guidance, contribution rules, and screenshots", async () => { + const [readme, readmeEn, guide, guideEn, contributing, license] = await Promise.all([ + readFile("README.md", "utf8"), readFile("README.en.md", "utf8"), + readFile("docs/custom-theme-guide.md", "utf8"), readFile("docs/custom-theme-guide.en.md", "utf8"), + readFile("CONTRIBUTING.md", "utf8"), readFile("LICENSE", "utf8"), ]); - for (const required of [ - "README.en.md", - "status-stable", - "docs/custom-theme-guide.md", - "docs/assets/screenshots/theme-studio.webp", - "docs/assets/screenshots/index1.webp", - "docs/assets/screenshots/index2.webp", - "docs/assets/concepts/ichigo-hoshimiya.png", - "docs/assets/concepts/super-saiyan-goku.png", - "CONTRIBUTING.md", - "MIT License", - ]) { - expect(readme).toContain(required); - } + "README.en.md", "docs/custom-theme-guide.md", "docs/assets/screenshots/theme-studio.webp", + "docs/assets/screenshots/index1.webp", "docs/assets/screenshots/index2.webp", + "docs/assets/concepts/ichigo-hoshimiya.png", "docs/assets/concepts/super-saiyan-goku.png", + "CONTRIBUTING.md", "MIT License", + ]) expect(readme).toContain(required); expect(readmeEn).toContain("README.md"); - expect(readme).toContain("[![LINUX DO 社区]"); - expect(readmeEn).toContain("[![LINUX DO Community]"); expect(readme).toContain("https://linux.do/"); expect(readmeEn).toContain("https://linux.do/"); - expect(readme).toContain("# OpenChatGPTSkin"); - expect(readmeEn).toContain("# OpenChatGPTSkin"); - expect(contributing).toContain("OpenChatGPTSkin"); - expect(guide).toContain("OpenChatGPTSkin 自定义主题指南"); - expect(guideEn).toContain("OpenChatGPTSkin Custom Theme Guide"); - expect(readmeEn).toContain("docs/custom-theme-guide.en.md"); - expect(readme).toContain("docs/runtime-macos.md"); - expect(readmeEn).toContain("docs/runtime-macos.en.md"); - expect(guide).toContain("可复制的 AI 封装提示词"); expect(guide).toContain("Theme Schema v4"); - expect(guide).toContain("npm run runtime -- import --theme-file"); expect(guideEn).toContain("Copy-ready packaging prompt"); for (const required of [ - "{projectName}", - "profile-avatar.webp", - "suggestion-card1.webp", - "project-icon1.webp", - "display.woff2", - "themes/builtin/yua-mikami-starlight/theme.json", - "viewport/main/home-hero/suggestions", - "future-idol-cyan", - "yua-mikami-starlight", + "{projectName}", "profile-avatar.webp", "suggestion-card1.webp", "project-icon1.webp", + "display.woff2", "themes/builtin/yua-mikami-starlight/theme.json", + "viewport/main/home-hero/suggestions", "future-idol-cyan", "yua-mikami-starlight", ]) { expect(guide).toContain(required); expect(guideEn).toContain(required); } - for (const required of [ - "界面素材", - "动态欢迎语", - "项目图标", - "展示字体", - "组合图层", - "应用到 ChatGPT", - ]) { - expect(guide).toContain(required); - } - for (const required of [ - "Interface imagery", - "Welcome layout", - "project icons", - "Display typography", - "Composition layers", - "Apply to ChatGPT", - ]) { - expect(guideEn).toContain(required); - } - expect(contributing).toContain("UI surface 适配必须包含确定性的 HTML fixture/测试"); + expect(contributing).toContain("workflow_dispatch"); expect(contributing).toContain("UI surface changes must include deterministic HTML fixtures/tests"); expect(license).toContain("Asset notice"); - expect(macRuntime).toContain("/Applications/Codex.app/Contents/Resources/codex --version"); - expect(macRuntime).toContain("当前 UID、权限 `0600` 的 Unix socket"); - expect(macRuntime).toContain("尚未在真实 Mac 上完成 Codex 视觉闭环验收"); - expect(macRuntimeEn).toContain("real Mac"); - expect(macRuntimeEn).toContain("RUNTIME_ENVIRONMENT_INVALID"); - expect(readme).toContain("OpenChatGPTSkin_0.2.0_windows_x64_Setup.exe"); - expect(readme).toContain("docs/releases/v0.2.0.md"); - expect(readme).not.toContain("docs/assets/concepts/yua-mikami.png"); - expect(readmeEn).not.toContain("docs/assets/concepts/yua-mikami.png"); - expect(readme).toContain("checksums.txt"); - expect(readme).toContain("SmartScreen"); - expect(readme).toContain("默认保留个人主题"); - expect(readmeEn).toContain("Windows x64 portable ZIP"); - expect(releaseNotes).toContain("Windows x64"); - expect(releaseNotes).toContain("SHA-256"); - expect(releaseNotes).toContain("SmartScreen"); - expect(releaseNotes).toContain("macOS"); - expect(releaseNotes).toContain("English"); for (const name of [ - "theme-studio.webp", - "index1.webp", - "index2.webp", - "future-idol-cyan.webp", - "rose-carpet-star.webp", - "mountain-mist.webp", - "glacier-aurora.webp", - "yua-mikami-starlight.webp", - "surface-chatgpt-work.webp", - "surface-plugins.webp", - "surface-settings.webp", + "theme-studio.webp", "index1.webp", "index2.webp", "future-idol-cyan.webp", + "rose-carpet-star.webp", "mountain-mist.webp", "glacier-aurora.webp", + "yua-mikami-starlight.webp", "surface-chatgpt-work.webp", "surface-plugins.webp", "surface-settings.webp", ]) { const info = await stat(`docs/assets/screenshots/${name}`); expect(info.isFile()).toBe(true); expect(info.size).toBeGreaterThan(0); expect(info.size).toBeLessThan(6_000_000); } - - for (const name of [ - "yua-mikami.png", - "ichigo-hoshimiya.png", - "super-saiyan-goku.png", - ]) { - const info = await stat(`docs/assets/concepts/${name}`); - expect(info.isFile()).toBe(true); - expect(info.size).toBeGreaterThan(0); - expect(info.size).toBeLessThan(3_000_000); - } }); - it("documents the unsigned macOS preview without overstating compatibility", async () => { - const [ - readme, - readmeEn, - runtime, - runtimeEn, - notes, - contributing, - ] = await Promise.all([ - readFile("README.md", "utf8"), - readFile("README.en.md", "utf8"), - readFile("docs/runtime-macos.md", "utf8"), - readFile("docs/runtime-macos.en.md", "utf8"), - readFile("docs/releases/v0.2.0.md", "utf8"), - readFile("CONTRIBUTING.md", "utf8"), + it("states unsigned macOS limits and requires real-device revalidation", async () => { + const [readme, readmeEn, mac, macEn] = await Promise.all([ + readFile("README.md", "utf8"), readFile("README.en.md", "utf8"), + readFile("docs/runtime-macos.md", "utf8"), readFile("docs/runtime-macos.en.md", "utf8"), ]); - for (const name of [ - "OpenChatGPTSkin_0.2.0_macos_arm64.dmg", - "OpenChatGPTSkin_0.2.0_macos_x64.dmg", - ]) { - expect(readme).toContain(name); - expect(notes).toContain(name); - } - expect(readme).toContain("右键"); - expect(readme).toContain("未签名"); + expect(readme).toContain("未签名开发者预览"); expect(readmeEn).toContain("unsigned developer preview"); - expect(runtime).toContain( - "~/Library/Application Support/OpenChatGPTSkin", - ); - expect(runtimeEn).toContain("Control-click"); - expect(contributing).toContain("workflow_dispatch"); - expect(notes).toContain("Intel x64"); - expect(notes).toContain("macOS 真实 ChatGPT 视觉闭环仍待实机验收"); - expect(readme).toContain( - "Windows x64、macOS ARM64 和 macOS x64", - ); - expect(readmeEn).toContain( - "Windows x64, macOS ARM64, and macOS x64", - ); - expect(contributing).toContain( - "手动 `workflow_dispatch` 构建并上传 Windows 和两套 macOS 测试产物", - ); - expect(notes).toContain("手动 `workflow_dispatch` 只生成全平台测试产物"); - expect(`${readme}\n${readmeEn}\n${runtime}\n${runtimeEn}\n${notes}`) - .not.toContain("macOS x64/ARM64 只生成 CI contract artifact"); - expect(`${readme}\n${readmeEn}\n${runtime}\n${runtimeEn}\n${notes}`) - .not.toContain("macOS x64/ARM64 builds remain CI contract artifacts"); + expect(mac).toContain("Codex 升级后必须重新执行本清单"); + expect(macEn).toContain("Repeat this checklist after every Codex update"); + expect(mac).toContain("~/Library/Application Support/OpenChatGPTSkin"); + expect(macEn).toContain("Control-click"); }); }); diff --git a/tests/go-cutover.test.ts b/tests/go-cutover.test.ts new file mode 100644 index 0000000..5c19cac --- /dev/null +++ b/tests/go-cutover.test.ts @@ -0,0 +1,52 @@ +import { access, readFile } from "node:fs/promises"; +import { describe, expect, it } from "vitest"; + +describe("Go production cutover", () => { + it("makes Go the only default business host", async () => { + const packageJson = JSON.parse(await readFile("package.json", "utf8")) as { + readonly version: string; + readonly scripts: Readonly>; + }; + expect(packageJson.version).toBe("0.3.0-alpha.1"); + expect(packageJson.scripts["studio:dev"]).toBe("tsx scripts/dev/start-go-studio.ts"); + expect(packageJson.scripts.runtime).toContain("host/go"); + expect(packageJson.scripts["release:build"]).toContain("build-go-release.ts"); + expect(packageJson.scripts["release:acceptance"]).toContain("accept-go-release.ts"); + expect(packageJson.scripts["release:merge"]).toContain("merge-go-release.ts"); + expect(packageJson.scripts["release:node"]).toBeUndefined(); + }); + + it("documents and publishes Node-free Go artifacts", async () => { + const [workflow, readme, readmeEn] = await Promise.all([ + readFile(".github/workflows/release.yml", "utf8"), + readFile("README.md", "utf8"), + readFile("README.en.md", "utf8"), + ]); + for (const text of [workflow, readme, readmeEn]) { + expect(text).not.toContain("release:node"); + expect(text).not.toContain("OpenChatGPTSkin.cmd"); + } + expect(readme).toContain("OpenChatGPTSkin_0.3.0-alpha.1_windows_x64_Setup.exe"); + expect(readmeEn).toContain("OpenChatGPTSkin_0.3.0-alpha.1_windows_x64_Setup.exe"); + expect(workflow).toContain("npm run release:build -- --native-only"); + }); + + it("removes the Node production hosts and release graph", async () => { + for (const path of [ + "runtime/windows/package.json", + "runtime/theme-studio-service/package.json", + "scripts/release/payload.ts", + "scripts/release/fetch-node-runtime.ts", + ".github/workflows/go-host-spike.yml", + ]) { + await expect(access(path)).rejects.toMatchObject({ code: "ENOENT" }); + } + const graph = await Promise.all([ + readFile("package.json", "utf8"), readFile("package-lock.json", "utf8"), + readFile("tsconfig.json", "utf8"), readFile("vitest.config.ts", "utf8"), + ]).then((documents) => documents.join("\n")); + expect(graph).not.toContain("runtime/theme-studio-service"); + expect(graph).not.toContain("runtime/windows"); + expect(graph).not.toContain("@open-chatgpt-skin/windows-runtime"); + }); +}); diff --git a/tests/go-spike.test.ts b/tests/go-release.test.ts similarity index 64% rename from tests/go-spike.test.ts rename to tests/go-release.test.ts index c64b5d5..139531c 100644 --- a/tests/go-spike.test.ts +++ b/tests/go-release.test.ts @@ -3,10 +3,10 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { - acceptGoSpikePackages, - buildGoSpikePackages, - mergeGoSpikePackages, -} from "../scripts/release/go-spike.js"; + acceptGoReleasePackages, + buildGoReleasePackages, + mergeGoReleasePackages, +} from "../scripts/release/go-release.js"; const temporaryRoots: string[] = []; @@ -16,45 +16,25 @@ afterEach(async () => { )); }); -describe("Go host feasibility packages", () => { - it("records the incomplete native and security evidence without claiming Gate A", async () => { - const evidence = JSON.parse(await readFile( - "contracts/baseline/v0.2.0/go-spike-sizes.json", - "utf8", - )) as { - readonly targets: readonly { readonly stageBytes: number; readonly baselineStageBytes: number }[]; - readonly artifacts: readonly { readonly bytes: number; readonly baselineBytes: number }[]; - readonly nativeEvidenceComplete: boolean; - readonly blockingEvidence: readonly string[]; - readonly security: { readonly reachableStandardLibraryVulnerabilities: number }; - }; - expect(evidence.targets).toHaveLength(3); - expect(evidence.targets.every(({ stageBytes, baselineStageBytes }) => stageBytes < baselineStageBytes)).toBe(true); - expect(evidence.artifacts).toHaveLength(4); - expect(evidence.artifacts.every(({ bytes, baselineBytes }) => bytes < baselineBytes)).toBe(true); - expect(evidence.nativeEvidenceComplete).toBe(false); - expect(evidence.blockingEvidence).toHaveLength(3); - expect(evidence.security.reachableStandardLibraryVulnerabilities).toBe(10); - }); - - it("keeps the native spike workflow manual and separate from publishing", async () => { - const workflow = await readFile(".github/workflows/go-host-spike.yml", "utf8"); - expect(workflow).toContain("workflow_dispatch:"); +describe("Go host production packages", () => { + it("uses the native Go release workflow for publishing", async () => { + const workflow = await readFile(".github/workflows/release.yml", "utf8"); expect(workflow).toContain("windows-latest"); expect(workflow).toContain("macos-15"); expect(workflow).toContain("macos-15-intel"); expect(workflow).toContain("--native-only"); - expect(workflow).toContain("go-host-spike-macos-arm64"); - expect(workflow).toContain("go-host-spike-macos-x64"); - expect(workflow).toContain("go:spike:merge"); - expect(workflow).not.toContain("gh release create"); + expect(workflow).toContain("release:build"); + expect(workflow).toContain("release:acceptance"); + expect(workflow).toContain("gh release create"); + expect(workflow).not.toContain("release:node"); + expect(workflow).not.toContain("runtime/node"); }); it.runIf(process.platform === "win32" || process.platform === "darwin")( "builds only the current native host when native-only is requested", async () => { - const output = await mkdtemp(join(tmpdir(), "openchatgptskin-go-spike-")); + const output = await mkdtemp(join(tmpdir(), "openchatgptskin-go-release-")); temporaryRoots.push(output); - const report = await buildGoSpikePackages({ + const report = await buildGoReleasePackages({ workspaceRoot: process.cwd(), outputDirectory: output, nativeInstallers: false, @@ -72,8 +52,12 @@ describe("Go host feasibility packages", () => { stageBytes < baselineStageBytes )).toBe(true); expect(report.artifacts.map(({ kind }) => kind)).toEqual([expectedTarget.artifact]); + expect(report.artifacts[0]?.name).not.toContain("go-spike"); expect(report.artifacts.every(({ bytes, baselineBytes }) => bytes < baselineBytes)).toBe(true); - await expect(acceptGoSpikePackages(output, false)).resolves.toMatchObject({ + await expect(readFile(join( + output, "stages", expectedTarget.target, "OpenChatGPTSkin", "release-manifest.json", + ), "utf8")).resolves.toContain('"language": "go"'); + await expect(acceptGoReleasePackages(output, false)).resolves.toMatchObject({ accepted: true, artifactCount: 1, nativeEvidenceComplete: false, @@ -81,7 +65,7 @@ describe("Go host feasibility packages", () => { }, 60_000); it("merges three single-target native reports without cross-building", async () => { - const root = await mkdtemp(join(tmpdir(), "openchatgptskin-go-spike-merge-")); + const root = await mkdtemp(join(tmpdir(), "openchatgptskin-go-release-merge-")); temporaryRoots.push(root); const definitions = [ { target: "windows-x64", format: "pe-x64", kinds: ["zip-x64", "setup-x64"] }, @@ -99,7 +83,7 @@ describe("Go host feasibility packages", () => { await writeFile(join(input, name), kind); return { name, kind, bytes: kind.length, sha256: "0".repeat(64), baselineBytes: 1_000_000 }; })); - await writeFile(join(input, "go-spike-report.json"), JSON.stringify({ + await writeFile(join(input, "go-release-report.json"), JSON.stringify({ schemaVersion: 1, version: "0.3.0-alpha.1", imageImplementation: "gen2brain-webp-wasm2go-nodynamic-plus-internal-pipeline", @@ -115,7 +99,7 @@ describe("Go host feasibility packages", () => { artifacts, })); } - const merged = await mergeGoSpikePackages(inputs, join(root, "combined")); + const merged = await mergeGoReleasePackages(inputs, join(root, "combined")); expect(merged.targets.map(({ target }) => target)).toEqual(definitions.map(({ target }) => target)); expect(merged.artifacts).toHaveLength(6); }); diff --git a/tests/helpers/release-payload-fixture.ts b/tests/helpers/release-payload-fixture.ts deleted file mode 100644 index 3695808..0000000 --- a/tests/helpers/release-payload-fixture.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { createHash } from "node:crypto"; -import { mkdir, readFile, writeFile } from "node:fs/promises"; -import { join } from "node:path"; -import type { - ReleaseArch, - ReleaseManifest, -} from "../../scripts/release/payload.js"; - -export async function createMacPayloadFixture( - root: string, - arch: ReleaseArch, -): Promise { - const releaseRoot = join(root, "OpenChatGPTSkin"); - await mkdir(join(releaseRoot, "runtime"), { recursive: true }); - await mkdir(join( - releaseRoot, - "node_modules", - "@open-chatgpt-skin", - "theme-studio-service", - "dist", - ), { recursive: true }); - await writeFile(join(releaseRoot, "runtime", "node"), "node", "utf8"); - await writeFile(join(releaseRoot, "OpenChatGPTSkin"), "#!/bin/sh\n", "utf8"); - await writeFile(join( - releaseRoot, - "node_modules", - "@open-chatgpt-skin", - "theme-studio-service", - "dist", - "cli.js", - ), "process.exit(0);\n", "utf8"); - - const relativeFiles = [ - "OpenChatGPTSkin", - "runtime/node", - "node_modules/@open-chatgpt-skin/theme-studio-service/dist/cli.js", - ]; - const files: Record = {}; - for (const relativePath of relativeFiles) { - const bytes = await readFile(join(releaseRoot, ...relativePath.split("/"))); - files[relativePath] = { - bytes: bytes.length, - sha256: createHash("sha256").update(bytes).digest("hex"), - }; - } - const manifest: ReleaseManifest = { - schemaVersion: 1, - product: "OpenChatGPTSkin", - version: "0.1.0-alpha.1", - target: { platform: "darwin", arch }, - runtime: { nodeVersion: "22.18.0" }, - build: { commit: "0123456789abcdef0123456789abcdef01234567" }, - themeCatalog: { schemaVersion: 1 }, - entry: "OpenChatGPTSkin", - themes: [ - "future-idol-cyan", - "rose-carpet-star", - "mountain-mist", - "glacier-aurora", - ], - files, - }; - await writeFile( - join(releaseRoot, "release-manifest.json"), - `${JSON.stringify(manifest, null, 2)}\n`, - "utf8", - ); - return releaseRoot; -} diff --git a/tests/helpers/runtime-fixture.ts b/tests/helpers/runtime-fixture.ts deleted file mode 100644 index 6bcc878..0000000 --- a/tests/helpers/runtime-fixture.ts +++ /dev/null @@ -1,954 +0,0 @@ -import { createConnection } from "node:net"; -import { mkdtemp, mkdir, readFile, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join, resolve } from "node:path"; -import { parseHTML } from "linkedom"; -import { vi } from "vitest"; -import { - CurrentCodexAdapter, - type CdpRuntimeClient, - type CompiledTheme, - type RuntimeThemeAdapter, -} from "@open-chatgpt-skin/cdp-adapter"; -import { - CONTROL_MAX_FRAME_BYTES, - ControlRequestSchema, - ControlResponseSchema, - RuntimeControlDispatcher, - RuntimeController, - ExitMonitor, - PowerShellWindowsProvider, - SecurePipeServer, - sendControlRequest, - pipeNameForSid, - type RecoverRuntimeControllerDependencies, - type ControlDispatchResult, - type ControlRequest, - type ControlResponse, - RuntimeError, - RuntimeSessionStateSchema, - RuntimeStateStore, - RuntimeThemeRepository, - type RuntimeThemeLookup, - ThemeEngine, - createRuntimePaths, - type LaunchReceipt, - type LoadedRuntimeTheme, - type ProcessIdentity, - type RuntimeBuiltinThemeId, - type RuntimeControllerDependencies, - type RuntimeErrorCode, - type RuntimePageSession, - type RuntimeSessionState, - type WindowsRuntimeProvider, -} from "@open-chatgpt-skin/windows-runtime"; - -export interface RuntimeControllerFixtureOptions { - readonly preflightError?: RuntimeErrorCode; - readonly applyError?: RuntimeErrorCode; - readonly cleanupError?: RuntimeErrorCode; - readonly failApplyFor?: readonly RuntimeBuiltinThemeId[]; - readonly failApplyAfterLaunch?: boolean; - readonly blockApply?: boolean; - readonly blockCleanup?: boolean; - readonly initialRecoveryRequired?: boolean; - readonly initialPendingOperation?: "launch" | "switch" | "pause" | "resume" | "restore"; - readonly terminalStatus?: "restored-awaiting-exit" | "restored-cleanup-required"; - readonly failExitMonitorStart?: boolean; - readonly failAppendRecentRequest?: RuntimeErrorCode; - readonly previousThemeUnavailableAfterLaunch?: boolean; -} - -export interface RuntimeControllerFixture { - readonly controller: RuntimeController; - readonly state: RuntimeStateStore; - readonly provider: WindowsRuntimeProvider; - readonly page: RuntimePageSession & { readonly adapter: RuntimeThemeAdapter }; - readonly launchManaged: ReturnType; - readonly waitForPort: ReturnType; - readonly activateWindow: ReturnType; - readonly onStopped: ReturnType; - calls(): readonly string[]; - exitRoot(): void; - closePort(): void; - replaceRootIdentity(): void; - simulateControllerCrash(): void; - runExitMonitor(): Promise; - runInitialPortWait(): Promise; - waitUntilApplyBlocked(): Promise; - releaseApply(): void; - waitUntilCleanupBlocked(): Promise; - releaseCleanup(): void; - failNextResume(error: RuntimeErrorCode): void; - emitExecutionContextsCleared(): void; - failReconnect(error: RuntimeErrorCode): void; - disconnectPage(): void; - recoveryDependencies(): RecoverRuntimeControllerDependencies; -} - -interface Deferred { - readonly promise: Promise; - resolve(): void; -} - -function deferred(): Deferred { - let resolvePromise!: () => void; - return { - promise: new Promise((resolve) => { resolvePromise = resolve; }), - resolve: () => resolvePromise(), - }; -} - -const themeVersion = "1.0.0"; -const timestamp = "2026-07-17T00:00:00.000Z"; - -const install = { - packageRoot: "C:/Program Files/WindowsApps/OpenAI.Codex_26.707.12708.0_x64__2p2nqsd0c76g0", - entryPath: "C:/Program Files/WindowsApps/OpenAI.Codex_26.707.12708.0_x64__2p2nqsd0c76g0/app/ChatGPT.exe", - identityName: "OpenAI.Codex", - packageVersion: "26.707.12708.0", - packagePublisher: "CN=50BDFD77-8903-4850-9FFE-6E8522F64D5B", - appId: "App", - entryRelativePath: "app/ChatGPT.exe", - entryPoint: "Windows.FullTrustApplication", - packageSignatureStatus: "Valid", - packageSignerCommonName: "50BDFD77-8903-4850-9FFE-6E8522F64D5B", - catalogSignatureStatus: "Valid", - catalogSignerCommonName: "50BDFD77-8903-4850-9FFE-6E8522F64D5B", - entryBlockMapValid: true, - resourceSignatureStatus: "Valid", - resourceSignerCommonName: "OpenAI OpCo, LLC", -}; - -export function makeRuntimeState( - overrides: Partial = {}, -): RuntimeSessionState { - const selectedTheme = { id: "mountain-mist" as const, version: themeVersion }; - return RuntimeSessionStateSchema.parse({ - schemaVersion: 2, - sessionId: "00000000-0000-4000-8000-000000000010", - status: "active", - runtime: { pid: 100, startedAt: timestamp }, - codex: { - rootPid: 200, - startedAt: timestamp, - executablePath: install.entryPath, - packageRoot: install.packageRoot, - packageVersion: install.packageVersion, - }, - cdp: { host: "127.0.0.1", port: 55123 }, - adapter: { id: "current-2026-07", version: 1 }, - selectedTheme, - appliedTheme: selectedTheme, - skinApplied: true, - pendingOperation: null, - recentRequests: [], - createdAt: timestamp, - updatedAt: timestamp, - ...overrides, - }); -} - -function loadedTheme( - id: RuntimeBuiltinThemeId, - version = themeVersion, -): LoadedRuntimeTheme { - return { - descriptor: { id, name: id, version, ready: true }, - bundle: {} as LoadedRuntimeTheme["bundle"], - compiled: { themeId: id } as LoadedRuntimeTheme["compiled"], - }; -} - -export async function createRuntimeControllerFixture( - options: RuntimeControllerFixtureOptions = {}, -): Promise { - const calls: string[] = []; - const paths = createRuntimePaths("D:/OpenChatGPTSkin-data", "D:/OpenChatGPTSkin"); - let stored: RuntimeSessionState | null = null; - let lastWrittenStatus: string | null = null; - const state = { - read: vi.fn(async () => stored), - write: vi.fn(async (value: RuntimeSessionState) => { - const incoming = RuntimeSessionStateSchema.parse(value); - const byRequestId = new Map(); - for (const record of [...(stored?.recentRequests ?? []), ...incoming.recentRequests]) { - if (!byRequestId.has(record.requestId)) byRequestId.set(record.requestId, record); - } - const validated = RuntimeSessionStateSchema.parse({ - ...incoming, - recentRequests: [...byRequestId.values()].slice(-32), - }); - if (validated.status !== lastWrittenStatus) calls.push(`write-${validated.status}`); - lastWrittenStatus = validated.status; - stored = validated; - }), - appendRecentRequest: vi.fn(async (record: RuntimeSessionState["recentRequests"][number]) => { - if (options.failAppendRecentRequest) { - throw new RuntimeError(options.failAppendRecentRequest, "Configured response persistence failure"); - } - if (!stored) return false; - const existing = stored.recentRequests.find((entry) => entry.requestId === record.requestId); - if (existing && existing.command !== record.command) { - throw new RuntimeError("RUNTIME_SESSION_STALE", "Recent request command changed"); - } - if (existing) return true; - stored = RuntimeSessionStateSchema.parse({ - ...stored, - recentRequests: [...stored.recentRequests, record].slice(-32), - updatedAt: record.completedAt, - }); - return true; - }), - clear: vi.fn(async () => { - calls.push("clear-state"); - lastWrittenStatus = null; - stored = null; - }), - } as unknown as RuntimeStateStore; - let root: ProcessIdentity = { - pid: 200, - parentPid: 1, - startedAt: timestamp, - executablePath: install.entryPath, - }; - let rootExists = true; - let portOpen = true; - let applyCount = 0; - let nextResumeError: RuntimeErrorCode | null = null; - let nextReconnectError: RuntimeErrorCode | null = null; - const applyBlocked = options.blockApply ? deferred() : null; - const applyStarted = options.blockApply ? deferred() : null; - const cleanupBlocked = options.blockCleanup ? deferred() : null; - const cleanupStarted = options.blockCleanup ? deferred() : null; - const listeners = new Map void>>(); - const closeListeners = new Set<() => void>(); - - const adapter = { - preflight: vi.fn(async () => { - calls.push("preflight-theme"); - if (options.preflightError) { - throw new RuntimeError(options.preflightError, "Configured preflight failure"); - } - return { - valid: true, - welcomeSupported: true, - requiredLayersResolved: true, - }; - }), - apply: vi.fn(async (compiled: { readonly themeId?: RuntimeBuiltinThemeId }) => { - calls.push("apply-theme"); - applyCount += 1; - applyStarted?.resolve(); - if (options.blockApply) await applyBlocked!.promise; - if (nextResumeError) { - const error = nextResumeError; - nextResumeError = null; - throw new RuntimeError(error, "Configured resume failure"); - } - if (options.applyError || - options.failApplyFor?.includes(compiled.themeId ?? "mountain-mist") || - (options.failApplyAfterLaunch && applyCount > 1)) { - throw new RuntimeError( - options.applyError ?? "THEME_APPLY_FAILED", - "Configured apply failure", - ); - } - }), - verify: vi.fn(async () => ({ valid: true })), - remove: vi.fn(async () => { - calls.push("cleanup-theme"); - cleanupStarted?.resolve(); - if (options.blockCleanup) await cleanupBlocked!.promise; - if (options.cleanupError) { - throw new RuntimeError(options.cleanupError, "Configured cleanup failure"); - } - }), - verifyOfficialAppearance: vi.fn(async () => ({ valid: true })), - } as unknown as RuntimeThemeAdapter; - - const connection = { - evaluate: vi.fn(async (): Promise => undefined as T), - close: vi.fn(() => {}), - on: vi.fn((method: string, listener: (params: unknown) => void) => { - const subscriptions = listeners.get(method) ?? new Set<(params: unknown) => void>(); - subscriptions.add(listener); - listeners.set(method, subscriptions); - return () => subscriptions.delete(listener); - }), - onClose: vi.fn((listener: () => void) => { - closeListeners.add(listener); - return () => closeListeners.delete(listener); - }), - } as unknown as RuntimePageSession["connection"]; - const page = { - endpoint: { host: "127.0.0.1" as const, port: 55123 }, - target: { - id: "codex", - type: "page", - title: "Codex", - url: "app://-/index.html", - webSocketDebuggerUrl: "ws://127.0.0.1:55123/devtools/page/codex", - }, - adapterId: "current-2026-07", - connection, - adapter, - close: () => connection.close(), - } as RuntimePageSession & { readonly adapter: RuntimeThemeAdapter }; - - const provider: WindowsRuntimeProvider = { - listCodexRoots: vi.fn(async () => { - calls.push("revalidate-root"); - return rootExists ? [root] : []; - }), - currentUserPackageRoots: vi.fn(async () => [install.packageRoot]), - inspectInstall: vi.fn(async () => { - calls.push("revalidate-install"); - return install; - }), - inspectPort: vi.fn(async () => { - calls.push("revalidate-port"); - return portOpen ? { - host: "127.0.0.1", - port: 55123, - owningPid: 201, - ancestors: [201, 200], - } : null; - }), - activateCodexApplication: vi.fn(async () => {}), - inspectManagedWindows: vi.fn(async () => ({ - rootExists, - visibleWindowCount: rootExists ? 1 : 0, - activationReady: rootExists, - })), - launch: vi.fn(async (executablePath) => ({ ...root, executablePath })), - waitForExit: vi.fn(async () => !rootExists), - inspectProcessStartedAt: vi.fn(async (pid) => pid === 100 ? timestamp : null), - inspectRemoteDebuggingArguments: vi.fn(async () => ({ - hasRemoteDebuggingAddress: false, - hasRemoteDebuggingPort: false, - })), - measureProcessCpuPercent: vi.fn(async () => 0.25), - currentUserSid: vi.fn(async () => "S-1-5-21-test"), - secureDirectory: vi.fn(async () => {}), - }; - - const repository = { - load: vi.fn(async (value: RuntimeThemeLookup) => { - calls.push("load-theme"); - const id = typeof value === "string" ? value : value.id; - const version = typeof value === "string" ? undefined : value.version; - if (options.previousThemeUnavailableAfterLaunch && applyCount > 0 && - version === themeVersion) { - throw new RuntimeError("THEME_NOT_READY", "Configured previous theme is unavailable"); - } - return loadedTheme(id as RuntimeBuiltinThemeId, version ?? themeVersion); - }), - } as unknown as RuntimeThemeRepository; - const themes = new ThemeEngine(repository); - const launchManaged = vi.fn(async (): Promise => { - calls.push("launch-codex"); - rootExists = true; - portOpen = true; - return { - install, - root, - cdp: { host: "127.0.0.1", port: 55123 }, - launchedAt: timestamp, - }; - }); - const waitForPort = vi.fn(async (): Promise<{ readonly host: string; readonly port: number; readonly owningPid: number; readonly ancestors: readonly number[] }> => { - calls.push("wait-port"); - if (!portOpen) throw new RuntimeError("CDP_NOT_READY", "Configured port is closed"); - return { host: "127.0.0.1", port: 55123, owningPid: 201, ancestors: [201, 200] }; - }); - const activateWindow = vi.fn(async (receipt: LaunchReceipt) => { - calls.push("activate-window"); - return receipt; - }); - const connectPage = vi.fn(async () => { - calls.push("connect-page"); - if (nextReconnectError) { - const error = nextReconnectError; - nextReconnectError = null; - throw new RuntimeError(error, "Configured reconnect failure"); - } - return page; - }); - const stopped = deferred(); - const onStopped = vi.fn(async () => { stopped.resolve(); }); - - if (options.initialRecoveryRequired) { - stored = makeRuntimeState({ - status: "recovery-required", - appliedTheme: null, - skinApplied: null, - pendingOperation: null, - }); - } else if (options.terminalStatus) { - stored = makeRuntimeState({ - status: options.terminalStatus, - appliedTheme: null, - skinApplied: false, - pendingOperation: null, - }); - } else if (options.initialPendingOperation) { - const kind = options.initialPendingOperation; - const previousStatus = kind === "launch" - ? null - : kind === "resume" - ? "paused" - : "active"; - const status = kind === "launch" - ? "launching" - : kind === "restore" - ? "restoring" - : kind === "resume" - ? "paused" - : "active"; - const priorTheme = { id: "mountain-mist" as const, version: themeVersion }; - stored = makeRuntimeState({ - status, - appliedTheme: kind === "launch" || kind === "resume" ? null : priorTheme, - skinApplied: kind === "launch" || kind === "resume" ? false : true, - pendingOperation: { - kind, - requestId: "00000000-0000-4000-8000-000000000020", - startedAt: timestamp, - previousStatus, - previousSelectedTheme: kind === "launch" ? null : priorTheme, - previousAppliedTheme: kind === "launch" || kind === "resume" ? null : priorTheme, - candidateTheme: kind === "switch" - ? { id: "glacier-aurora", version: themeVersion } - : kind === "launch" || kind === "resume" - ? priorTheme - : null, - }, - }); - } - lastWrittenStatus = stored?.status ?? null; - const exitMonitor = new ExitMonitor({ - provider, - state, - onStopped, - initialPortWaitMs: 0, - initialIntervalMs: 1, - cleanupIntervalMs: 1, - }); - - const dependencies: RuntimeControllerDependencies = { - paths, - provider, - state, - themes, - launchManaged, - waitForPort, - activateWindow, - connectPage, - secureRuntimeDirectories: vi.fn(async () => { calls.push("secure"); }), - now: () => timestamp, - runtimeIdentity: { pid: 100, startedAt: timestamp }, - newSessionId: () => "00000000-0000-4000-8000-000000000010", - onStopped, - createExitMonitor: () => ({ - start: () => { - calls.push("exit-monitor-started"); - if (options.failExitMonitorStart) { - throw new RuntimeError("RUNTIME_CONTROL_UNAVAILABLE", "Configured exit monitor failure"); - } - }, - stop: () => { calls.push("exit-monitor-stopped"); }, - }), - }; - const cache = { - read: vi.fn(async () => null), - write: vi.fn(async () => {}), - } as unknown as RecoverRuntimeControllerDependencies["cache"]; - - return { - controller: new RuntimeController(dependencies), - state, - provider, - page, - launchManaged, - waitForPort, - activateWindow, - onStopped, - calls: () => [...calls], - exitRoot: () => { rootExists = false; }, - closePort: () => { portOpen = false; }, - replaceRootIdentity: () => { - root = { ...root, pid: 300, startedAt: "2026-07-17T00:00:01.000Z" }; - }, - simulateControllerCrash: () => page.close(), - runExitMonitor: async () => { - const session = await state.read(); - if (!session) throw new Error("Runtime session is missing"); - exitMonitor.start(session); - await stopped.promise; - }, - runInitialPortWait: async () => { - const session = await state.read(); - if (!session) throw new Error("Runtime session is missing"); - exitMonitor.start(session); - await waitForCleanupRequired(state); - exitMonitor.stop(); - }, - waitUntilApplyBlocked: async () => { - if (!applyStarted) throw new Error("Apply is not configured to block"); - await applyStarted.promise; - }, - releaseApply: () => applyBlocked?.resolve(), - waitUntilCleanupBlocked: async () => { - if (!cleanupStarted) throw new Error("Cleanup is not configured to block"); - await cleanupStarted.promise; - }, - releaseCleanup: () => cleanupBlocked?.resolve(), - failNextResume: (error) => { nextResumeError = error; }, - emitExecutionContextsCleared: () => { - for (const listener of listeners.get("Runtime.executionContextsCleared") ?? []) listener({}); - }, - failReconnect: (error) => { nextReconnectError = error; }, - disconnectPage: () => { - for (const listener of closeListeners) listener(); - }, - recoveryDependencies: () => ({ ...dependencies, cache }), - }; -} - -async function waitForCleanupRequired(state: RuntimeStateStore): Promise { - for (let attempt = 0; attempt < 50; attempt += 1) { - if ((await state.read())?.status === "restored-cleanup-required") return; - await new Promise((resolve) => setTimeout(resolve, 0)); - } - throw new Error("Exit monitor did not persist cleanup-required"); -} - -export type IntegratedRuntimeMode = - | "normal" - | "candidate-fails" - | "rollback-fails" - | "ambiguous-reconnect"; - -export interface IntegratedRuntimeFixture { - startPipe(): Promise; - send( - command: ControlRequest["command"], - params: Readonly>, - requestId?: string, - ): Promise; - status(): Promise>; - disconnectAndReadResult(): Promise; - sendRawOversizedFrame(): Promise; - dispatchCount(): number; - launchCount(): number; - events(): readonly string[]; - exitRoot(): void; - runInitialPortWait(): Promise; - close(): Promise; -} - -interface LinkedomRuntimePage { - readonly session: RuntimePageSession; - disconnect(): void; -} - -interface IntegratedAdapterControl { - candidateFailed: boolean; -} - -const integratedEndpoint = { host: "127.0.0.1" as const, port: 55123 }; - -function pageClient(html: string): { - readonly client: CdpRuntimeClient; - readonly disconnect: () => void; - readonly on: (method: string, listener: (params: unknown) => void) => () => void; - readonly onClose: (listener: () => void) => () => void; -} { - const { document, window } = parseHTML(html); - const runtimeWindow = Object.create(window) as Window; - const eventListeners = new Map void>>(); - const closeListeners = new Set<() => void>(); - let closed = false; - - Object.defineProperty(runtimeWindow, "location", { - configurable: false, - value: { href: "app://-/index.html" }, - }); - Object.defineProperty(runtimeWindow, "getComputedStyle", { - configurable: false, - value: (node: HTMLElement) => ({ pointerEvents: node.style.pointerEvents }), - }); - class TestImage { - src = ""; - - decode(): Promise { - return Promise.resolve(); - } - } - Object.defineProperty(runtimeWindow, "Image", { - configurable: false, - value: TestImage, - }); - for (const node of document.querySelectorAll("nav,main,textarea")) { - Object.defineProperty(node, "getBoundingClientRect", { - value: () => ({ - width: 100, - height: 40, - top: 0, - left: 0, - right: 100, - bottom: 40, - }), - }); - } - - const disconnect = () => { - if (closed) return; - closed = true; - for (const listener of closeListeners) listener(); - closeListeners.clear(); - }; - return { - client: { - evaluate: async (expression: string): Promise => await Function( - "document", - "window", - `return (${expression});`, - )(document, runtimeWindow) as T, - }, - disconnect, - on: (method, listener) => { - const listeners = eventListeners.get(method) ?? new Set<(params: unknown) => void>(); - listeners.add(listener); - eventListeners.set(method, listeners); - return () => listeners.delete(listener); - }, - onClose: (listener) => { - closeListeners.add(listener); - return () => closeListeners.delete(listener); - }, - }; -} - -function controlledAdapter( - adapter: RuntimeThemeAdapter, - mode: IntegratedRuntimeMode, - control: IntegratedAdapterControl, -): RuntimeThemeAdapter { - return { - probe: () => adapter.probe(), - preflight: (theme) => adapter.preflight(theme), - verify: () => adapter.verify(), - verifyOfficialAppearance: () => adapter.verifyOfficialAppearance(), - remove: () => adapter.remove(), - async apply(theme: CompiledTheme): Promise { - if ((mode === "candidate-fails" || mode === "rollback-fails") && - theme.themeId === "glacier-aurora") { - control.candidateFailed = true; - throw new RuntimeError("THEME_APPLY_FAILED", "Configured candidate failure"); - } - if (mode === "rollback-fails" && control.candidateFailed && - theme.themeId === "mountain-mist") { - throw new RuntimeError("THEME_APPLY_FAILED", "Configured rollback failure"); - } - await adapter.apply(theme); - }, - }; -} - -async function createLinkedomRuntimePage( - mode: IntegratedRuntimeMode, - control: IntegratedAdapterControl, -): Promise { - const html = await readFile("tests/fixtures/runtime/codex-page.html", "utf8"); - const page = pageClient(html); - const connection = { - evaluate: page.client.evaluate, - close: page.disconnect, - on: page.on, - onClose: page.onClose, - }; - const adapter = controlledAdapter(new CurrentCodexAdapter(connection), mode, control); - return { - session: { - endpoint: integratedEndpoint, - target: { - id: "codex", - type: "page", - title: "Codex", - url: "app://-/index.html", - webSocketDebuggerUrl: "ws://127.0.0.1:55123/devtools/page/codex", - }, - adapterId: "current-2026-07", - connection, - adapter, - close: page.disconnect, - }, - disconnect: page.disconnect, - }; -} - -function integratedRequest( - command: ControlRequest["command"], - params: Readonly>, - requestId: string, -): ControlRequest { - let raw: unknown; - switch (command) { - case "launch": - case "switch": - raw = { protocolVersion: 1, requestId, command, params }; - break; - case "status": - case "pause": - case "resume": - case "restore": - raw = { protocolVersion: 1, requestId, command, params }; - break; - } - const parsed = ControlRequestSchema.safeParse(raw); - if (!parsed.success) { - throw new RuntimeError("RUNTIME_ENVIRONMENT_INVALID", "Integrated fixture command is invalid"); - } - return parsed.data; -} - -function safeRendererFailureResponse( - requestId: string, - error: RuntimeError, -): ControlResponse { - return ControlResponseSchema.parse({ - protocolVersion: 1, - requestId, - ok: false, - error: { - code: error.code, - message: "Renderer reconciliation failed safely.", - nextAction: "Review Runtime status and restore Codex if required.", - }, - }); -} - -async function waitForState( - state: RuntimeStateStore, - predicate: (value: RuntimeSessionState | null) => boolean, -): Promise { - for (let attempt = 0; attempt < 500; attempt += 1) { - const current = await state.read(); - if (predicate(current)) return current; - await new Promise((resolveWait) => setTimeout(resolveWait, 10)); - } - throw new Error("Integrated Runtime fixture did not reach the expected state"); -} - -export async function createIntegratedRuntimeFixture(options: { - readonly mode?: IntegratedRuntimeMode; - readonly reuseOldPort?: boolean; -} = {}): Promise { - const mode = options.mode ?? "normal"; - const root = await mkdtemp(join(tmpdir(), "ocs-integrated-runtime-")); - const paths = createRuntimePaths(root, resolve(".")); - const state = new RuntimeStateStore(paths.sessionFile); - const events: string[] = []; - const adapterControl: IntegratedAdapterControl = { candidateFailed: false }; - let rootExists = false; - let portOpen = false; - let launches = 0; - let dispatches = 0; - let requestNumber = 0; - let page: LinkedomRuntimePage | null = null; - let reconnectFailure: RuntimeError | null = null; - let connectionCount = 0; - let pipe: SecurePipeServer | null = null; - let pipeSid: string | null = null; - const rootIdentity: ProcessIdentity = { - pid: 200, - parentPid: 1, - startedAt: timestamp, - executablePath: install.entryPath, - }; - - const inspection = () => portOpen - ? { - host: "127.0.0.1", - port: integratedEndpoint.port, - owningPid: 201, - ancestors: [201, rootIdentity.pid], - } - : null; - const provider: WindowsRuntimeProvider = { - listCodexRoots: async () => rootExists ? [rootIdentity] : [], - currentUserPackageRoots: async () => [install.packageRoot], - inspectInstall: async () => install, - inspectPort: async () => inspection(), - inspectProcessStartedAt: async (pid) => pid === 100 ? timestamp : null, - activateCodexApplication: async () => {}, - inspectManagedWindows: async () => ({ - rootExists, - visibleWindowCount: rootExists ? 1 : 0, - activationReady: rootExists, - }), - launch: async () => rootIdentity, - waitForExit: async () => !rootExists, - inspectRemoteDebuggingArguments: async () => ({ - hasRemoteDebuggingAddress: false, - hasRemoteDebuggingPort: false, - }), - measureProcessCpuPercent: async () => 0.25, - currentUserSid: async () => "S-1-5-21-integrated", - secureDirectory: async () => {}, - }; - const repository = new RuntimeThemeRepository(paths.themesRoot); - const themes = new ThemeEngine(repository); - const exitMonitor = new ExitMonitor({ - provider, - state, - onStopped: async () => { events.push("controller-stopped"); }, - initialPortWaitMs: 0, - initialIntervalMs: 1, - cleanupIntervalMs: 1, - }); - const connectPage = async (): Promise => { - connectionCount += 1; - if (mode === "ambiguous-reconnect" && connectionCount > 1) { - reconnectFailure = new RuntimeError( - "CDP_TARGET_AMBIGUOUS", - "Configured ambiguous renderer target", - ); - throw reconnectFailure; - } - page = await createLinkedomRuntimePage(mode, adapterControl); - return page.session; - }; - const dependencies: RuntimeControllerDependencies = { - paths, - provider, - state, - themes, - launchManaged: async (): Promise => { - launches += 1; - rootExists = true; - portOpen = true; - return { - install, - root: rootIdentity, - cdp: integratedEndpoint, - launchedAt: timestamp, - }; - }, - waitForPort: async () => { - const value = inspection(); - if (!value) throw new RuntimeError("CDP_NOT_READY", "Integrated port is closed"); - return value; - }, - activateWindow: async (receipt) => receipt, - connectPage, - secureRuntimeDirectories: async () => { - await mkdir(paths.runtimeDirectory, { recursive: true }); - await mkdir(paths.installDirectory, { recursive: true }); - await mkdir(paths.themeStoreDirectory, { recursive: true }); - }, - now: () => timestamp, - runtimeIdentity: { pid: 100, startedAt: timestamp }, - newSessionId: () => "00000000-0000-4000-8000-000000000040", - onStopped: async () => { events.push("controller-stopped"); }, - createExitMonitor: () => ({ - start(session) { - events.push("exit-monitor-started"); - exitMonitor.start(session); - }, - stop() { - exitMonitor.stop(); - }, - }), - }; - const controller = new RuntimeController(dependencies); - const dispatcher = new RuntimeControlDispatcher(controller, state); - const dispatch = async (request: ControlRequest): Promise => { - dispatches += 1; - const result = await dispatcher.dispatch(request); - const afterResponse = result.afterResponse; - if (!afterResponse) return result; - return { - response: result.response, - afterResponse: async () => { - events.push("response-flushed"); - await afterResponse(); - }, - }; - }; - const nextRequestId = () => { - requestNumber += 1; - return `00000000-0000-4000-8000-${String(requestNumber).padStart(12, "0")}`; - }; - const waitForEvent = async (event: string) => { - for (let attempt = 0; attempt < 500; attempt += 1) { - if (events.includes(event)) return; - await new Promise((resolveWait) => setTimeout(resolveWait, 10)); - } - throw new Error(`Integrated Runtime fixture did not record ${event}`); - }; - - return { - async startPipe(): Promise { - if (pipe || process.platform !== "win32") return; - pipeSid = await new PowerShellWindowsProvider().currentUserSid(); - pipe = await SecurePipeServer.start({ sid: pipeSid, dispatch }); - }, - async send(command, params, requestId = nextRequestId()): Promise { - const request = integratedRequest(command, params, requestId); - if (pipe && pipeSid) { - const response = await sendControlRequest({ sid: pipeSid, request }); - if (command === "restore" && response.ok) await waitForEvent("exit-monitor-started"); - return response; - } - const result = await dispatch(request); - if (result.afterResponse) await result.afterResponse(); - return result.response; - }, - async status(): Promise> { - const response = await this.send("status", {}); - if (!response.ok) throw new Error("Integrated Runtime status unexpectedly failed"); - return response; - }, - async disconnectAndReadResult(): Promise { - if (!page) throw new Error("Integrated Runtime page is unavailable"); - page.disconnect(); - await waitForState(state, (value) => value?.status === "recovery-required"); - if (!reconnectFailure) throw new Error("Integrated Runtime reconnect did not fail"); - return safeRendererFailureResponse(nextRequestId(), reconnectFailure); - }, - async sendRawOversizedFrame(): Promise { - if (!pipeSid) throw new Error("Secured Pipe was not started"); - await new Promise((resolveClosed, rejectClosed) => { - const socket = createConnection(pipeNameForSid(pipeSid!)); - socket.once("error", (error) => { - if ((error as NodeJS.ErrnoException).code === "EPIPE") resolveClosed(); - else rejectClosed(error); - }); - socket.once("close", () => resolveClosed()); - socket.once("connect", () => { - const header = Buffer.alloc(4); - header.writeUInt32LE(CONTROL_MAX_FRAME_BYTES + 1, 0); - socket.write(header); - }); - }); - }, - dispatchCount: () => dispatches, - launchCount: () => launches, - events: () => [...events], - exitRoot: () => { - rootExists = false; - if (!options.reuseOldPort) portOpen = false; - }, - async runInitialPortWait(): Promise { - await waitForState(state, (value) => value?.status === "restored-cleanup-required"); - }, - async close(): Promise { - await controller.close(); - await pipe?.close(); - await rm(root, { recursive: true, force: true }); - }, - }; -} diff --git a/tests/macos-release.test.ts b/tests/macos-release.test.ts deleted file mode 100644 index 7c11cd5..0000000 --- a/tests/macos-release.test.ts +++ /dev/null @@ -1,381 +0,0 @@ -import { - access, - mkdtemp, - readFile, - rm, - stat, - writeFile, -} from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { describe, expect, it } from "vitest"; -import { - macDmgArtifactName, - portableArtifactName, - releasePlatformLabel, -} from "../scripts/release/artifact-names.js"; -import { - renderMacInfoPlist, - renderMacLauncher, - toAppleBundleVersion, -} from "../scripts/release/macos-metadata.js"; -import { - buildMacAppBundle, - verifyMacAppBundleLayout, -} from "../scripts/release/macos-app.js"; -import { - assertMacBinaryArchitecture, - buildMacDmg, - generateMacIcon, - MAC_APPLICATIONS_LINK_NAME, - MAC_FIRST_LAUNCH_NOTICE, - MAC_FIRST_LAUNCH_NOTICE_NAME, - macIconEntries, -} from "../scripts/release/macos-dmg.js"; -import { acceptMacDmg } from "../scripts/release/macos-acceptance.js"; -import { RELEASE_ACCEPTANCE_SCENARIOS } from - "../scripts/release/acceptance.js"; -import { readReleaseManifest } from "../scripts/release/payload.js"; -import { - packagePortableRelease, - writeReleaseChecksums, -} from "../scripts/release/package-portable.js"; -import { createMacPayloadFixture } from "./helpers/release-payload-fixture.js"; - -describe("macOS release metadata", () => { - it("uses macos in user-facing names while preserving platform input", () => { - expect(releasePlatformLabel("darwin")).toBe("macos"); - expect(portableArtifactName("0.1.0-alpha.1", "darwin", "arm64")) - .toBe("OpenChatGPTSkin_0.1.0-alpha.1_macos_arm64.tar.gz"); - expect(macDmgArtifactName("0.1.0-alpha.1", "x64")) - .toBe("OpenChatGPTSkin_0.1.0-alpha.1_macos_x64.dmg"); - expect(() => portableArtifactName( - "../escape", - "darwin", - "arm64", - )).toThrow("Release version is invalid"); - }); - - it("converts supported prerelease versions to Apple bundle versions", () => { - expect(toAppleBundleVersion("0.1.0-alpha.1")).toBe("0.1.0a1"); - expect(toAppleBundleVersion("1.2.3-beta.4")).toBe("1.2.3b4"); - expect(toAppleBundleVersion("2.0.0-rc.2")).toBe("2.0.0fc2"); - expect(toAppleBundleVersion("2.0.0")).toBe("2.0.0"); - expect(() => toAppleBundleVersion("2.0.0-preview.1")) - .toThrow("Unsupported macOS product version"); - }); - - it("renders fixed bundle identity, exact product version, and safe launcher paths", () => { - const plist = renderMacInfoPlist({ - productVersion: "0.1.0-alpha.1", - arch: "arm64", - }); - expect(plist).toContain("io.github.u2bo.openchatgptskin"); - expect(plist).toContain("0.1.0a1"); - expect(plist).toContain("0.1.0-alpha.1"); - expect(plist).toContain("LSUIElement\n "); - - const launcher = renderMacLauncher("0.1.0-alpha.1"); - expect(launcher).toContain('OPEN_CHATGPT_SKIN_INSTALL_ROOT="$payload"'); - expect(launcher).toContain('exec "$payload/runtime/node"'); - expect(launcher).not.toContain("process.cwd"); - }); -}); - -describe("macOS app bundle", () => { - it("wraps the accepted payload without changing its manifest", async () => { - const root = await mkdtemp(join(tmpdir(), "ocs-mac-app-")); - try { - const releaseRoot = await createMacPayloadFixture(root, "arm64"); - const originalManifest = await readFile( - join(releaseRoot, "release-manifest.json"), - "utf8", - ); - const iconPath = join(root, "AppIcon.icns"); - await writeFile(iconPath, "icon", "utf8"); - - const result = await buildMacAppBundle({ - releaseRoot, - outputDirectory: join(root, "bundle"), - iconPath, - }); - - expect(result).toMatchObject({ - version: "0.1.0-alpha.1", - arch: "arm64", - }); - expect(await readFile( - join(result.payloadRoot, "release-manifest.json"), - "utf8", - )).toBe(originalManifest); - expect(await readFile( - join(result.appPath, "Contents", "MacOS", "OpenChatGPTSkin"), - "utf8", - )).toContain('payload="$contents/Resources/payload"'); - if (process.platform !== "win32") { - expect((await stat( - join(result.appPath, "Contents", "MacOS", "OpenChatGPTSkin"), - )).mode & 0o111).not.toBe(0); - } - await expect(verifyMacAppBundleLayout(result.appPath)).resolves - .toMatchObject({ - version: "0.1.0-alpha.1", - arch: "arm64", - }); - await expect(buildMacAppBundle({ - releaseRoot, - outputDirectory: join(root, "bundle"), - iconPath, - })).rejects.toThrow("macOS app output already exists"); - await expect(access( - join(result.appPath, "Contents", "Resources", "AppIcon.icns"), - )).resolves.toBeUndefined(); - - const manifestPath = join( - result.payloadRoot, - "release-manifest.json", - ); - const invalidManifest = JSON.parse( - await readFile(manifestPath, "utf8"), - ) as { target: { arch: string } }; - invalidManifest.target.arch = "universal"; - await writeFile( - manifestPath, - `${JSON.stringify(invalidManifest, null, 2)}\n`, - "utf8", - ); - await expect(verifyMacAppBundleLayout(result.appPath)) - .rejects.toThrow("Unsupported release architecture: universal"); - } finally { - await rm(root, { recursive: true, force: true }); - } - }); - - it("rejects malformed Release manifest fields at the read boundary", async () => { - const root = await mkdtemp(join(tmpdir(), "ocs-mac-manifest-")); - try { - const releaseRoot = await createMacPayloadFixture(root, "arm64"); - const manifestPath = join(releaseRoot, "release-manifest.json"); - const invalid = JSON.parse( - await readFile(manifestPath, "utf8"), - ) as { - files: Record; - }; - invalid.files["runtime/node"]!.sha256 = "invalid"; - await writeFile( - manifestPath, - `${JSON.stringify(invalid, null, 2)}\n`, - "utf8", - ); - await expect(readReleaseManifest(releaseRoot)).rejects.toThrow( - "Release manifest file metadata is invalid: runtime/node", - ); - - await writeFile(manifestPath, "null\n", "utf8"); - await expect(readReleaseManifest(releaseRoot)).rejects.toThrow( - "Release manifest identity is invalid", - ); - } finally { - await rm(root, { recursive: true, force: true }); - } - }); -}); - -describe("macOS native distribution contract", () => { - it("requires the complete Apple iconset", () => { - expect(macIconEntries()).toEqual([ - ["icon_16x16.png", 16], - ["icon_16x16@2x.png", 32], - ["icon_32x32.png", 32], - ["icon_32x32@2x.png", 64], - ["icon_128x128.png", 128], - ["icon_128x128@2x.png", 256], - ["icon_256x256.png", 256], - ["icon_256x256@2x.png", 512], - ["icon_512x512.png", 512], - ["icon_512x512@2x.png", 1024], - ]); - }); - - it("defines the drag-install link and bilingual first-launch notice", () => { - expect(MAC_APPLICATIONS_LINK_NAME).toBe("Applications"); - expect(MAC_FIRST_LAUNCH_NOTICE_NAME).toContain("First Launch"); - expect(MAC_FIRST_LAUNCH_NOTICE).toContain("未签名开发者预览"); - expect(MAC_FIRST_LAUNCH_NOTICE).toContain( - "Unsigned developer preview", - ); - }); - - it("accepts only the requested Mach-O architecture", () => { - expect(() => assertMacBinaryArchitecture( - "Mach-O 64-bit executable arm64", - "arm64", - "runtime/node", - )).not.toThrow(); - expect(() => assertMacBinaryArchitecture( - "Mach-O 64-bit executable x86_64", - "x64", - "runtime/node", - )).not.toThrow(); - expect(() => assertMacBinaryArchitecture( - "Mach-O 64-bit executable x86_64", - "arm64", - "runtime/node", - )).toThrow("runtime/node does not target arm64"); - expect(() => assertMacBinaryArchitecture( - "Mach-O universal binary with 2 architectures: [x86_64] [arm64]", - "arm64", - "runtime/node", - )).toThrow("runtime/node does not target arm64"); - }); - - it("registers the app bundle acceptance scenario", () => { - expect(RELEASE_ACCEPTANCE_SCENARIOS).toContain("macos-app-bundle"); - }); - - it.skipIf(process.platform === "darwin")( - "rejects native packaging outside macOS", - async () => { - await expect(generateMacIcon("source.svg", "AppIcon.icns")) - .rejects.toThrow("macOS icon generation requires Darwin"); - await expect(buildMacDmg({ - appPath: "OpenChatGPTSkin.app", - outputDirectory: "artifacts", - version: "0.1.0-alpha.1", - arch: "arm64", - })).rejects.toThrow("DMG packaging requires Darwin"); - await expect(acceptMacDmg("OpenChatGPTSkin.dmg")) - .rejects.toThrow("macOS DMG acceptance requires Darwin"); - }, - ); - - it.skipIf(process.platform !== "darwin")( - "creates an icns with the native macOS toolchain", - async () => { - const root = await mkdtemp(join(tmpdir(), "ocs-mac-icon-")); - try { - const output = join(root, "AppIcon.icns"); - await generateMacIcon( - "assets/branding/open-chatgpt-skin-icon.svg", - output, - ); - expect((await stat(output)).size).toBeGreaterThan(0); - } finally { - await rm(root, { recursive: true, force: true }); - } - }, - ); -}); - -describe("macOS release integration", () => { - it("packages Darwin with macos names and checksums the DMG", async () => { - const root = await mkdtemp(join(tmpdir(), "ocs-mac-package-")); - try { - const releaseRoot = await createMacPayloadFixture(root, "arm64"); - const output = join(root, "artifacts"); - const portable = await packagePortableRelease(releaseRoot, output); - expect(portable.name) - .toBe("OpenChatGPTSkin_0.1.0-alpha.1_macos_arm64.tar.gz"); - await writeFile( - join(output, "OpenChatGPTSkin_0.1.0-alpha.1_macos_arm64.dmg"), - "dmg", - "utf8", - ); - const checksumsPath = await writeReleaseChecksums(output); - const checksums = await readFile(checksumsPath, "utf8"); - expect(checksums).toContain( - "OpenChatGPTSkin_0.1.0-alpha.1_macos_arm64.tar.gz", - ); - expect(checksums).toContain( - "OpenChatGPTSkin_0.1.0-alpha.1_macos_arm64.dmg", - ); - } finally { - await rm(root, { recursive: true, force: true }); - } - }); - - it("exposes the macOS build and acceptance commands", async () => { - const packageJson = JSON.parse( - await readFile("package.json", "utf8"), - ) as { readonly scripts?: Readonly> }; - expect(packageJson.scripts?.["release:macos"]) - .toBe("tsx scripts/release/build-macos-distribution.ts"); - expect(packageJson.scripts?.["release:acceptance:macos"]) - .toBe("tsx scripts/release/accept-macos-distribution.ts"); - }); - - it("builds all platforms manually while keeping publishing tag-only", async () => { - const workflow = await readFile( - ".github/workflows/release.yml", - "utf8", - ); - expect(workflow).toContain("workflow_dispatch:"); - expect(workflow).toMatch( - /verify:\r?\n\s+runs-on: windows-latest/, - ); - expect(workflow).not.toMatch( - /verify:\r?\n\s+runs-on: ubuntu-latest/, - ); - expect(workflow).toContain( - 'run: npm run release:version -- --tag "$env:GITHUB_REF_NAME"', - ); - expect(workflow).not.toContain( - 'run: npm run release:version -- --tag "${GITHUB_REF_NAME}"', - ); - expect(workflow).toMatch( - /windows-x64:\r?\n\s+needs: verify/, - ); - expect(workflow).not.toMatch( - /windows-x64:\r?\n\s+if: github\.event_name/, - ); - expect(workflow).toMatch( - /publish:\r?\n\s+if: github\.event_name == 'push'/, - ); - expect(workflow).toContain( - "if: github.event_name == 'workflow_dispatch'", - ); - expect(workflow).toContain( - "$releaseVersion = (Get-Content -Raw -LiteralPath package.json | ConvertFrom-Json).version", - ); - expect(workflow).toContain("-Version $releaseVersion"); - expect(workflow).not.toContain( - "-Version ($env:GITHUB_REF_NAME -replace '^v','')", - ); - expect(workflow).toContain("windows-checksums.txt"); - expect(workflow).toContain("npm run release:macos --"); - expect(workflow).toContain("npm run release:acceptance:macos --"); - expect(workflow).toContain("name: macos-${{ matrix.arch }}-release"); - expect(workflow).toContain("OpenChatGPTSkin_*.dmg"); - expect(workflow).toContain("OpenChatGPTSkin_*.tar.gz"); - expect(workflow).toContain( - "macos-${{ matrix.arch }}-checksums.txt", - ); - for (const directory of [ - "downloads/windows", - "downloads/macos-arm64", - "downloads/macos-x64", - ]) { - expect(workflow).toContain(directory); - } - expect(workflow).toContain('test ! -e "$destination"'); - expect(workflow).toContain('--title "$version"'); - expect(workflow).not.toContain('--title "OpenChatGPTSkin'); - }); - - it("pins Inno Setup deterministically and streams compiler diagnostics", async () => { - const [workflow, installerScript, installerDefinition] = await Promise.all([ - readFile(".github/workflows/release.yml", "utf8"), - readFile("scripts/release/build-windows-installer.ps1", "utf8"), - readFile("scripts/release/windows-installer.iss", "utf8"), - ]); - - expect(workflow).toContain('INNO_SETUP_VERSION: "6.7.1"'); - expect(workflow).toContain( - "choco install innosetup --version $env:INNO_SETUP_VERSION --allow-downgrade --no-progress --yes", - ); - expect(workflow).toContain("if ($LASTEXITCODE -ne 0) {"); - expect(installerScript).toContain("& $iscc @arguments"); - expect(installerScript).not.toContain("Start-Process -FilePath $iscc"); - expect(installerDefinition).not.toMatch(/^\s*#13#10/m); - }); -}); diff --git a/tests/release-acceptance.test.ts b/tests/release-acceptance.test.ts deleted file mode 100644 index ad95b26..0000000 --- a/tests/release-acceptance.test.ts +++ /dev/null @@ -1,510 +0,0 @@ -import { createHash } from "node:crypto"; -import { - access, - copyFile, - mkdir, - mkdtemp, - readFile, - rm, - writeFile, -} from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; -import { - acceptReleasePayload, - verifyReleasePayload, -} from "../scripts/release/acceptance.js"; -import { - readReleaseManifest, - stageReleasePayload, - type StageReleasePayloadOptions, -} from "../scripts/release/payload.js"; - -const roots: string[] = []; -const themeIds = [ - "future-idol-cyan", - "rose-carpet-star", - "mountain-mist", - "glacier-aurora", - "yua-mikami-starlight", -] as const; -const buildCommit = "0123456789abcdef0123456789abcdef01234567"; - -afterEach(async () => { - await Promise.all(roots.splice(0).map((root) => - rm(root, { recursive: true, force: true }) - )); -}); - -async function writeJson(path: string, value: unknown): Promise { - await writeFile(path, `${JSON.stringify(value, null, 2)}\n`, "utf8"); -} - -function sha256(bytes: Uint8Array): string { - return createHash("sha256").update(bytes).digest("hex"); -} - -async function createReleaseFixture(): Promise<{ - readonly workspaceRoot: string; - readonly nodeExecutablePath: string; - readonly nodeLicensePath: string; -}> { - const workspaceRoot = await mkdtemp(join(tmpdir(), "ocs-release-source-")); - roots.push(workspaceRoot); - await Promise.all([ - mkdir(join(workspaceRoot, "apps", "theme-studio", "dist"), { recursive: true }), - mkdir(join(workspaceRoot, "runtime", "theme-studio-service", "dist"), { recursive: true }), - mkdir(join(workspaceRoot, "themes", "builtin"), { recursive: true }), - mkdir(join(workspaceRoot, "node-runtime"), { recursive: true }), - ]); - - await writeJson(join(workspaceRoot, "package.json"), { - name: "open-chatgpt-skin", - version: "0.1.0-alpha.1", - private: true, - workspaces: ["runtime/*"], - }); - await writeJson(join(workspaceRoot, "runtime", "theme-studio-service", "package.json"), { - name: "@open-chatgpt-skin/theme-studio-service", - version: "0.1.0-alpha.1", - type: "module", - dependencies: {}, - }); - await writeFile( - join(workspaceRoot, "runtime", "theme-studio-service", "dist", "cli.js"), - "process.stdout.write('fixture');\n", - "utf8", - ); - await Promise.all([ - writeFile( - join(workspaceRoot, "runtime", "theme-studio-service", "dist", "cli.js.map"), - '{"version":3,"sources":["../src/cli.ts"]}\n', - "utf8", - ), - writeFile( - join(workspaceRoot, "runtime", "theme-studio-service", "dist", "cli.d.ts"), - "export {};\n", - "utf8", - ), - ]); - await writeFile( - join(workspaceRoot, "apps", "theme-studio", "dist", "index.html"), - '', - "utf8", - ); - await Promise.all([ - writeFile(join(workspaceRoot, "README.md"), "# OpenChatGPTSkin\n", "utf8"), - writeFile(join(workspaceRoot, "README.en.md"), "# OpenChatGPTSkin\n", "utf8"), - writeFile(join(workspaceRoot, "LICENSE"), "MIT\n", "utf8"), - writeFile(join(workspaceRoot, "node-runtime", "LICENSE"), "Node.js license\n", "utf8"), - ]); - await copyFile(process.execPath, join(workspaceRoot, "node-runtime", "node.exe")); - await writeFile( - join(workspaceRoot, "runtime", "theme-studio-service", "dist", "cli.js"), - `import { createServer } from "node:http"; -if (process.env.NODE_PATH || process.env.NODE_OPTIONS) { - process.stderr.write("Release acceptance inherited a global Node environment"); - process.exit(19); -} -const themes = ${JSON.stringify(themeIds)}.map((id) => ({ source: "builtin", ref: { id, version: "1.0.0" } })); -const token = "a".repeat(64); -const server = createServer((request, response) => { - const url = new URL(request.url ?? "/", "http://127.0.0.1"); - response.setHeader("Content-Security-Policy", "default-src 'self'; style-src 'self' 'nonce-fixture'; script-src 'self' 'nonce-fixture'"); - response.setHeader("Referrer-Policy", "no-referrer"); - response.setHeader("X-Frame-Options", "DENY"); - if (request.method === "GET" && url.pathname === "/") { - response.writeHead(200, { "Content-Type": "text/html" }); - response.end(''); - return; - } - if (request.method === "POST" && url.pathname === "/api/session") { - response.writeHead(204, { "Set-Cookie": "ocs_studio=fixture; HttpOnly; SameSite=Strict; Path=/" }); - response.end(); - return; - } - response.setHeader("Content-Type", "application/json"); - if (request.method === "GET" && url.pathname === "/api/bootstrap") { - response.end(JSON.stringify({ protocolVersion: 2, studioVersion: process.env.OPEN_CHATGPT_SKIN_VERSION, capabilities: ["studio-shell"], runtime: { status: "stopped" } })); - return; - } - if (request.method === "GET" && url.pathname === "/api/themes") { - response.end(JSON.stringify({ themes })); - return; - } - if (request.method === "POST" && url.pathname === "/api/drafts") { - response.writeHead(201); - response.end(JSON.stringify({ draftId: "00000000-0000-4000-8000-000000000001", revision: 0 })); - return; - } - if (request.method === "POST" && url.pathname.endsWith("/assets")) { - response.end(JSON.stringify({ draftId: "00000000-0000-4000-8000-000000000001", revision: 1 })); - return; - } - if (request.method === "POST" && url.pathname.endsWith("/save")) { - response.end(JSON.stringify({ ref: { id: "acceptance-theme", version: "1.0.0" } })); - return; - } - if (request.method === "GET" && url.pathname === "/api/export") { - response.writeHead(200, { "Content-Type": "application/vnd.open-chatgpt-skin+zip" }); - response.end("fixture-archive"); - return; - } - response.writeHead(404); - response.end(JSON.stringify({ error: { code: "NOT_FOUND" } })); -}); -server.listen(0, "127.0.0.1", () => { - const address = server.address(); - process.stdout.write(JSON.stringify({ url: \`http://127.0.0.1:\${address.port}/#bootstrap=\${token}\` }) + "\\n"); -}); -process.once("SIGTERM", () => server.close()); -`, - "utf8", - ); - - const builtins = []; - for (const id of themeIds) { - const directory = join(workspaceRoot, "themes", "builtin", id); - await mkdir(join(directory, "assets"), { recursive: true }); - const files = new Map([ - ["theme.json", Buffer.from(JSON.stringify({ id, version: "1.0.0" }))], - ["assets/background.webp", Buffer.from(`background:${id}`)], - ["preview.webp", Buffer.from(`preview:${id}`)], - ]); - for (const [relativePath, bytes] of files) { - await writeFile(join(directory, ...relativePath.split("/")), bytes); - } - await writeFile(join(directory, "source.png"), `authoring:${id}`, "utf8"); - await writeFile(join(directory, "LICENSE.md"), `License for ${id}\n`, "utf8"); - await writeJson(join(directory, "manifest.json"), { - schemaVersion: 1, - themeId: id, - themeVersion: "1.0.0", - files: Object.fromEntries([...files].map(([relativePath, bytes]) => [ - relativePath, - { bytes: bytes.length, sha256: sha256(bytes) }, - ])), - }); - builtins.push({ - id, - name: id, - version: "1.0.0", - kind: "theme", - path: `builtin/${id}`, - ready: true, - localOnly: false, - licenseId: "MIT", - preview: `builtin/${id}/preview.webp`, - }); - } - await writeJson(join(workspaceRoot, "themes", "catalog.json"), { - schemaVersion: 1, - builtins, - recipes: [], - }); - - return { - workspaceRoot, - nodeExecutablePath: join(workspaceRoot, "node-runtime", "node.exe"), - nodeLicensePath: join(workspaceRoot, "node-runtime", "LICENSE"), - }; -} - -async function createReleaseRoot(): Promise { - const outputRoot = await mkdtemp(join(tmpdir(), "ocs-release-output-")); - roots.push(outputRoot); - return join(outputRoot, "OpenChatGPTSkin"); -} - -function releaseOptions( - fixture: Awaited>, - releaseRoot: string, - overrides: Partial = {}, -): StageReleasePayloadOptions { - return { - workspaceRoot: fixture.workspaceRoot, - releaseRoot, - version: "0.1.0-alpha.1", - platform: "win32", - arch: "x64", - nodeVersion: process.versions.node, - buildCommit, - nodeExecutablePath: fixture.nodeExecutablePath, - nodeLicensePath: fixture.nodeLicensePath, - ...overrides, - }; -} - -describe("Release Acceptance", () => { - it("stages the production payload while excluding authoring-only assets", async () => { - const fixture = await createReleaseFixture(); - const releaseRoot = await createReleaseRoot(); - - await stageReleasePayload(releaseOptions(fixture, releaseRoot)); - - const manifest = await readReleaseManifest(releaseRoot); - const verification = await verifyReleasePayload(releaseRoot); - expect(verification).toMatchObject({ - version: "0.1.0-alpha.1", - target: { platform: "win32", arch: "x64" }, - filesVerified: Object.keys(manifest.files).length, - }); - expect(manifest).toMatchObject({ - schemaVersion: 1, - product: "OpenChatGPTSkin", - version: "0.1.0-alpha.1", - target: { platform: "win32", arch: "x64" }, - runtime: { nodeVersion: process.versions.node }, - build: { commit: buildCommit }, - themeCatalog: { schemaVersion: 1 }, - entry: "OpenChatGPTSkin.cmd", - }); - expect(manifest.themes).toEqual(themeIds); - expect(Object.keys(manifest.files)).toEqual( - expect.arrayContaining([ - "OpenChatGPTSkin.cmd", - "apps/theme-studio/dist/index.html", - "runtime/node.exe", - "runtime/LICENSE", - "themes/catalog.json", - ]), - ); - const releaseFiles = Object.keys(manifest.files); - for (const forbidden of [ - /source\.png$/, - /^docs\/superpowers\//, - /^node_modules\/vite\//, - /\.map$/, - /\.d\.ts$/, - ]) { - expect(releaseFiles.some((file) => forbidden.test(file))).toBe(false); - } - await expect(access(join( - releaseRoot, - "node_modules", - "@open-chatgpt-skin", - "theme-studio-service", - "dist", - "cli.js", - ))).resolves.toBeUndefined(); - await expect(access(join( - releaseRoot, - "themes", - "builtin", - "mountain-mist", - "source.png", - ))).rejects.toMatchObject({ code: "ENOENT" }); - - const launcher = await readFile(join(releaseRoot, "OpenChatGPTSkin.cmd"), "utf8"); - expect(launcher).toContain("OPEN_CHATGPT_SKIN_INSTALL_ROOT"); - expect(launcher).toContain("runtime\\node.exe"); - expect(launcher).toContain("theme-studio-service\\dist\\cli.js"); - - const previousNodePath = process.env.NODE_PATH; - const previousNodeOptions = process.env.NODE_OPTIONS; - process.env.NODE_PATH = join(fixture.workspaceRoot, "global-node-modules"); - process.env.NODE_OPTIONS = "--no-warnings"; - let acceptance: Awaited>; - try { - acceptance = await acceptReleasePayload(releaseRoot); - } finally { - if (previousNodePath === undefined) delete process.env.NODE_PATH; - else process.env.NODE_PATH = previousNodePath; - if (previousNodeOptions === undefined) delete process.env.NODE_OPTIONS; - else process.env.NODE_OPTIONS = previousNodeOptions; - } - expect(acceptance).toMatchObject({ - scenario: "staged-payload", - version: "0.1.0-alpha.1", - target: { platform: "win32", arch: "x64" }, - uiVerified: true, - themesVerified: 5, - imageProcessingVerified: true, - gracefulExitVerified: true, - steps: { - manifest: "passed", - productionUi: "passed", - session: "passed", - runtimeStatus: "passed", - themes: "passed", - imageProcessing: "passed", - themeExport: "passed", - gracefulExit: "passed", - }, - }); - expect(acceptance.durationMs).toBeGreaterThanOrEqual(0); - expect(acceptance.exportBytes).toBeGreaterThan(0); - }); - - it("rejects an unsupported target before creating a payload", async () => { - const fixture = await createReleaseFixture(); - const releaseRoot = await createReleaseRoot(); - - await expect(stageReleasePayload(releaseOptions(fixture, releaseRoot, { - platform: "linux" as StageReleasePayloadOptions["platform"], - }))).rejects.toThrow("Unsupported release platform: linux"); - await expect(access(releaseRoot)).rejects.toMatchObject({ code: "ENOENT" }); - }); - - it("creates a missing output parent without reusing an existing payload", async () => { - const fixture = await createReleaseFixture(); - const outputRoot = await mkdtemp(join(tmpdir(), "ocs-release-parent-")); - roots.push(outputRoot); - const releaseRoot = join(outputRoot, "missing", "OpenChatGPTSkin"); - - await stageReleasePayload(releaseOptions(fixture, releaseRoot)); - await expect(access(join(releaseRoot, "release-manifest.json"))) - .resolves.toBeUndefined(); - await expect(stageReleasePayload(releaseOptions(fixture, releaseRoot))) - .rejects.toMatchObject({ code: "EEXIST" }); - }); - - it("allows only Inno Setup metadata outside the installed payload manifest", async () => { - const fixture = await createReleaseFixture(); - const releaseRoot = await createReleaseRoot(); - await stageReleasePayload(releaseOptions(fixture, releaseRoot)); - await Promise.all([ - writeFile(join(releaseRoot, "unins000.dat"), "inno metadata"), - writeFile(join(releaseRoot, "unins000.exe"), "inno uninstaller"), - ]); - - await expect(verifyReleasePayload(releaseRoot)).rejects.toThrow( - "Release manifest file list does not match the payload", - ); - await expect(verifyReleasePayload(releaseRoot, "installed-payload")) - .resolves.toMatchObject({ filesVerified: expect.any(Number) }); - - await writeFile(join(releaseRoot, "unexpected.txt"), "not installer metadata"); - await expect(verifyReleasePayload(releaseRoot, "installed-payload")).rejects.toThrow( - "Release manifest file list does not match the payload", - ); - }); - - it.skipIf(process.platform !== "win32")( - "rejects a case-variant output path inside the source workspace on Windows", - async () => { - const fixture = await createReleaseFixture(); - const caseVariantWorkspace = fixture.workspaceRoot === fixture.workspaceRoot.toUpperCase() - ? fixture.workspaceRoot.toLowerCase() - : fixture.workspaceRoot.toUpperCase(); - const releaseRoot = join(caseVariantWorkspace, "release", "OpenChatGPTSkin"); - - await expect(stageReleasePayload(releaseOptions(fixture, releaseRoot))) - .rejects.toThrow("Release output must be outside the source workspace"); - }, - ); - - it("rejects a theme asset that no longer matches its manifest", async () => { - const fixture = await createReleaseFixture(); - const releaseRoot = await createReleaseRoot(); - await writeFile(join( - fixture.workspaceRoot, - "themes", - "builtin", - "mountain-mist", - "assets", - "background.webp", - ), "tampered", "utf8"); - - await expect(stageReleasePayload(releaseOptions(fixture, releaseRoot))) - .rejects.toThrow("Theme asset failed manifest verification: mountain-mist"); - }); - - it("rejects a missing production dependency", async () => { - const fixture = await createReleaseFixture(); - const releaseRoot = await createReleaseRoot(); - await writeJson(join( - fixture.workspaceRoot, - "runtime", - "theme-studio-service", - "package.json", - ), { - name: "@open-chatgpt-skin/theme-studio-service", - version: "0.1.0-alpha.1", - type: "module", - dependencies: { sharp: "0.34.5" }, - }); - - await expect(stageReleasePayload(releaseOptions(fixture, releaseRoot))) - .rejects.toThrow("Production dependency is not installed: sharp"); - }); - - it("rejects a package version that differs from the requested release", async () => { - const fixture = await createReleaseFixture(); - const releaseRoot = await createReleaseRoot(); - - await expect(stageReleasePayload(releaseOptions(fixture, releaseRoot, { - version: "0.1.0-alpha.2", - }))).rejects.toThrow("Root package version does not match release version"); - }); - - it("rejects a bundled Node Runtime that differs from the manifest", async () => { - const fixture = await createReleaseFixture(); - const releaseRoot = await createReleaseRoot(); - await stageReleasePayload(releaseOptions(fixture, releaseRoot, { - nodeVersion: "0.0.0", - })); - - await expect(acceptReleasePayload(releaseRoot)) - .rejects.toThrow("Bundled Node Runtime version mismatch"); - }); - - it("rejects production UI responses without the complete security headers", async () => { - const fixture = await createReleaseFixture(); - const releaseRoot = await createReleaseRoot(); - const serviceCli = join( - fixture.workspaceRoot, - "runtime", - "theme-studio-service", - "dist", - "cli.js", - ); - const source = await readFile(serviceCli, "utf8"); - await writeFile(serviceCli, source - .replace(' response.setHeader("Referrer-Policy", "no-referrer");\n', "") - .replace(' response.setHeader("X-Frame-Options", "DENY");\n', ""), "utf8"); - await stageReleasePayload(releaseOptions(fixture, releaseRoot)); - - await expect(acceptReleasePayload(releaseRoot)) - .rejects.toThrow("Production UI security headers are invalid"); - }); - - it("rejects production UI with an inline asset missing the CSP nonce", async () => { - const fixture = await createReleaseFixture(); - const releaseRoot = await createReleaseRoot(); - const serviceCli = join( - fixture.workspaceRoot, - "runtime", - "theme-studio-service", - "dist", - "cli.js", - ); - const source = await readFile(serviceCli, "utf8"); - await writeFile( - serviceCli, - source.replace('', - "", - ].join(""), - "utf8", - ); - - const host = await startThemeStudioProductionHost({ - indexHtmlPath, - runtimeStatus: async () => stopped, - }); - running.push(host); - - const response = await fetch(host.bootstrapUrl.split("#")[0]!); - const html = await response.text(); - const contentSecurityPolicy = response.headers.get("content-security-policy")!; - const nonce = contentSecurityPolicy.match(/'nonce-([^']+)'/)![1]!; - - expect(response.status).toBe(200); - expect(html).toContain(`nonce="${nonce}"`); - expect(html).not.toContain("__OPEN_CHATGPT_SKIN_CSP_NONCE__"); - expect(html).not.toContain("/@vite/client"); - const directives = new Map(contentSecurityPolicy.split(";").map((value) => { - const [name, ...sources] = value.trim().split(/\s+/); - return [name, sources]; - })); - expect(directives.get("script-src")).not.toContain("'unsafe-inline'"); - expect(directives.get("style-src")).not.toContain("'unsafe-inline'"); - expect(response.headers.get("referrer-policy")).toBe("no-referrer"); - expect(response.headers.get("x-frame-options")).toBe("DENY"); - }); - - it("reports a production startup failure and writes a sanitized diagnostic log", async () => { - const root = await mkdtemp(join(tmpdir(), "ocs-production-failure-")); - temporaryRoots.push(root); - const installRoot = join(root, "install"); - const localAppData = join(root, "local-app-data"); - await mkdir(installRoot); - const tsxCli = join(process.cwd(), "node_modules", "tsx", "dist", "cli.mjs"); - let stderr = ""; - - try { - await execFileAsync(process.execPath, [ - tsxCli, - join(process.cwd(), "runtime", "theme-studio-service", "src", "cli.ts"), - "--no-open", - ], { - env: { - ...process.env, - LOCALAPPDATA: localAppData, - OPEN_CHATGPT_SKIN_INSTALL_ROOT: installRoot, - OPEN_CHATGPT_SKIN_VERSION: "0.1.0-alpha.1", - }, - }); - } catch (error) { - stderr = String((error as { stderr?: string }).stderr ?? ""); - } - - const failure = JSON.parse(stderr.trim()) as { - error: { - code: string; - message: string; - nextAction: string; - log: string; - }; - }; - expect(failure.error).toMatchObject({ - code: "INTERNAL", - message: "Theme Studio failed to start.", - log: "%LOCALAPPDATA%\\OpenChatGPTSkin\\runtime\\logs\\theme-studio.jsonl", - }); - expect(failure.error.nextAction).toContain("startup log"); - - const log = await readFile(join( - localAppData, - "OpenChatGPTSkin", - "runtime", - "logs", - "theme-studio.jsonl", - ), "utf8"); - expect(log).toContain('"event":"studio-startup-error"'); - expect(log).toContain('"errorCode":"INTERNAL"'); - expect(log).not.toContain(root); - }); -}); diff --git a/tests/theme-studio-runtime-status.test.ts b/tests/theme-studio-runtime-status.test.ts deleted file mode 100644 index 9b54a96..0000000 --- a/tests/theme-studio-runtime-status.test.ts +++ /dev/null @@ -1,147 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { - applyProductionRuntimeTheme, - mapRuntimeControlResult, - mapRuntimeStatus, - restoreProductionRuntimeTheme, -} from "@open-chatgpt-skin/theme-studio-service"; -import type { RuntimeStatusView } from "@open-chatgpt-skin/windows-runtime"; - -describe("Theme Studio Runtime projection", () => { - it.each([ - "stopped", - "launching", - "active", - "paused", - "paused-incompatible", - "recovery-required", - "restoring", - "restored-awaiting-exit", - "restored-cleanup-required", - ] as const)("maps %s without process details", (status) => { - const source: RuntimeStatusView = { - status, - controllerAvailable: status !== "stopped", - selectedTheme: status === "stopped" - ? null - : { id: "mountain-mist", version: "1.0.0" }, - appliedTheme: status === "active" - ? { id: "mountain-mist", version: "1.0.0" } - : null, - skinApplied: status === "active" - ? true - : status === "recovery-required" - ? null - : false, - packageVersion: null, - operation: null, - nextAction: "Structured next action.", - }; - - expect(mapRuntimeStatus(source)).toEqual(source); - expect(JSON.stringify(mapRuntimeStatus(source))).not.toMatch(/pid|port|path|websocket/i); - }); - - it("unwraps a successful Runtime controller response", () => { - expect(mapRuntimeControlResult({ - protocolVersion: 1, - requestId: "00000000-0000-4000-8000-000000000000", - ok: true, - result: { - status: "stopped", - controllerAvailable: false, - selectedTheme: null, - appliedTheme: null, - skinApplied: false, - packageVersion: null, - operation: null, - nextAction: "Launch one of the built-in themes.", - }, - })).toMatchObject({ status: "stopped" }); - }); - - it("converts Runtime controller failures to a stable Studio error", () => { - expect(() => mapRuntimeControlResult({ - protocolVersion: 1, - requestId: "00000000-0000-4000-8000-000000000000", - ok: false, - error: { - code: "RUNTIME_CONTROL_UNAVAILABLE", - message: "Internal Runtime detail", - nextAction: "Start the managed Runtime.", - }, - })).toThrow(expect.objectContaining({ - code: "RUNTIME_STATUS_UNAVAILABLE", - nextAction: "Start the managed Runtime.", - })); - }); - - it("resumes a paused Runtime and confirms the exact applied version", async () => { - const ref = { id: "my-theme", version: "1.2.3" }; - const paused = mapRuntimeStatus({ - status: "paused", - controllerAvailable: true, - selectedTheme: { id: "mountain-mist", version: "1.0.0" }, - appliedTheme: null, - skinApplied: false, - packageVersion: "26.707.12708.0", - operation: null, - nextAction: "Resume the managed Runtime.", - }); - const execute = vi.fn() - .mockResolvedValueOnce({ - ...paused, - selectedTheme: ref, - }) - .mockResolvedValueOnce({ - ...paused, - status: "active", - selectedTheme: ref, - appliedTheme: ref, - skinApplied: true, - }); - - await expect(applyProductionRuntimeTheme(ref, { - readStatus: async () => paused, - execute, - })).resolves.toMatchObject({ - status: "active", - appliedTheme: ref, - skinApplied: true, - }); - expect(execute).toHaveBeenNthCalledWith(1, { - kind: "switch", - themeId: ref.id, - themeVersion: ref.version, - }); - expect(execute).toHaveBeenNthCalledWith(2, { kind: "resume" }); - }); - - it("restores the official Codex appearance through the Runtime controller", async () => { - const active = mapRuntimeStatus({ - status: "active", - controllerAvailable: true, - selectedTheme: { id: "mountain-mist", version: "1.0.0" }, - appliedTheme: { id: "mountain-mist", version: "1.0.0" }, - skinApplied: true, - packageVersion: "26.715.2305.0", - operation: null, - nextAction: "Theme is active.", - }); - const restored = { - ...active, - status: "restored-awaiting-exit" as const, - appliedTheme: null, - skinApplied: false, - nextAction: "Quit Codex normally to finish restoring.", - }; - const execute = vi.fn(async () => restored); - - await expect(restoreProductionRuntimeTheme({ - readStatus: async () => active, - execute, - })).resolves.toEqual(restored); - expect(execute).toHaveBeenCalledOnce(); - expect(execute).toHaveBeenCalledWith({ kind: "restore" }); - }); -}); diff --git a/tests/theme-studio-service.test.ts b/tests/theme-studio-service.test.ts deleted file mode 100644 index 5af760f..0000000 --- a/tests/theme-studio-service.test.ts +++ /dev/null @@ -1,224 +0,0 @@ -import { afterEach, describe, expect, it } from "vitest"; -import { mkdtemp, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join, resolve } from "node:path"; -import { StudioEventSchema } from "@open-chatgpt-skin/theme-studio-core"; -import { - startThemeStudioServer, - ThemeStudioWorkspace, - type RunningThemeStudioServer, -} from "@open-chatgpt-skin/theme-studio-service"; -import { createRuntimePaths } from "@open-chatgpt-skin/windows-runtime"; - -const running: RunningThemeStudioServer[] = []; -const temporaryRoots: string[] = []; - -afterEach(async () => { - await Promise.all(running.splice(0).map((server) => server.close())); - await Promise.all(temporaryRoots.splice(0).map((root) => - rm(root, { recursive: true, force: true }) - )); -}); - -const stopped = { - status: "stopped" as const, - controllerAvailable: false, - selectedTheme: null, - appliedTheme: null, - skinApplied: false, - packageVersion: null, - operation: null, - nextAction: "No managed session.", -}; - -function tokenFrom(url: string): string { - return new URLSearchParams(new URL(url).hash.slice(1)).get("bootstrap")!; -} - -async function createServer(): Promise { - const server = await startThemeStudioServer({ - studioVersion: "0.1.0", - runtimeStatus: async () => stopped, - indexHtml: "Theme Studio", - }); - running.push(server); - return server; -} - -describe("Theme Studio loopback service", () => { - it("exchanges once and returns authenticated bootstrap", async () => { - const server = await createServer(); - const token = tokenFrom(server.bootstrapUrl); - const exchange = await fetch(`${server.origin}/api/session`, { - method: "POST", - headers: { "Content-Type": "application/json", Origin: server.origin }, - body: JSON.stringify({ token }), - }); - - expect(exchange.status).toBe(204); - const setCookie = exchange.headers.get("set-cookie")!; - expect(setCookie).toContain("HttpOnly"); - expect(setCookie).toContain("SameSite=Strict"); - const cookie = setCookie.split(";")[0]!; - - const bootstrap = await fetch(`${server.origin}/api/bootstrap`, { - headers: { Cookie: cookie }, - }); - await expect(bootstrap.json()).resolves.toMatchObject({ - protocolVersion: 2, - capabilities: ["studio-shell"], - runtime: { status: "stopped" }, - }); - expect((await fetch(`${server.origin}/api/bootstrap`)).status).toBe(401); - expect((await fetch(`${server.origin}/api/session`, { - method: "POST", - headers: { "Content-Type": "application/json", Origin: server.origin }, - body: JSON.stringify({ token }), - })).status).toBe(401); - }); - - it("rejects cross-origin, malformed, and oversized exchanges", async () => { - const server = await createServer(); - const endpoint = `${server.origin}/api/session`; - - expect((await fetch(endpoint, { - method: "POST", - headers: { "Content-Type": "application/json", Origin: "http://evil.invalid" }, - body: JSON.stringify({ token: tokenFrom(server.bootstrapUrl) }), - })).status).toBe(403); - expect((await fetch(endpoint, { - method: "POST", - headers: { "Content-Type": "application/json", Origin: server.origin }, - body: "{", - })).status).toBe(400); - expect((await fetch(endpoint, { - method: "POST", - headers: { "Content-Type": "application/json", Origin: server.origin }, - body: JSON.stringify({ token: "a".repeat(17 * 1024) }), - })).status).toBe(413); - }); - - it("streams authenticated Runtime status events", async () => { - const server = await createServer(); - const exchange = await fetch(`${server.origin}/api/session`, { - method: "POST", - headers: { "Content-Type": "application/json", Origin: server.origin }, - body: JSON.stringify({ token: tokenFrom(server.bootstrapUrl) }), - }); - const cookie = exchange.headers.get("set-cookie")!.split(";")[0]!; - const controller = new AbortController(); - const response = await fetch(`${server.origin}/api/events`, { - headers: { Cookie: cookie }, - signal: controller.signal, - }); - const reader = response.body!.getReader(); - const decoder = new TextDecoder(); - let text = ""; - - while (!text.includes("\n\n")) { - const chunk = await reader.read(); - if (chunk.done) throw new Error("SSE ended before its first frame"); - text += decoder.decode(chunk.value, { stream: true }); - } - - const data = text - .split("\n") - .find((line) => line.startsWith("data: "))! - .slice(6); - expect(StudioEventSchema.parse(JSON.parse(data))).toMatchObject({ - kind: "runtime-status", - runtime: { status: "stopped" }, - }); - - await reader.cancel(); - controller.abort(); - }); - - it("serves the authenticated theme draft, version save, and export closure", async () => { - const root = await mkdtemp(join(tmpdir(), "ocs-studio-http-")); - temporaryRoots.push(root); - const workspace = new ThemeStudioWorkspace({ - paths: createRuntimePaths(root, resolve(".")), - runtimeStatus: async () => stopped, - applyRuntimeTheme: async (ref) => ({ - ...stopped, - status: "active", - controllerAvailable: true, - selectedTheme: ref, - appliedTheme: ref, - skinApplied: true, - }), - restoreRuntimeTheme: async () => stopped, - }); - await workspace.initialize(); - const server = await startThemeStudioServer({ - studioVersion: "0.1.0", - runtimeStatus: async () => stopped, - workspace, - indexHtml: "Theme Studio", - }); - running.push(server); - - const exchange = await fetch(`${server.origin}/api/session`, { - method: "POST", - headers: { "Content-Type": "application/json", Origin: server.origin }, - body: JSON.stringify({ token: tokenFrom(server.bootstrapUrl) }), - }); - const cookie = exchange.headers.get("set-cookie")!.split(";")[0]!; - const headers = { Cookie: cookie, Origin: server.origin, "Content-Type": "application/json" }; - - const themesResponse = await fetch(`${server.origin}/api/themes`, { - headers: { Cookie: cookie }, - }); - const themes = await themesResponse.json() as { - themes: { ref: { id: string; version: string }; source: string }[]; - }; - const source = themes.themes.find((theme) => theme.ref.id === "mountain-mist")!; - expect(themes.themes).toHaveLength(5); - - const directlyApplied = await fetch(`${server.origin}/api/themes/apply`, { - method: "POST", - headers, - body: JSON.stringify(source.ref), - }); - expect(directlyApplied.status).toBe(200); - await expect(directlyApplied.json()).resolves.toMatchObject({ - status: "active", - appliedTheme: source.ref, - }); - - const create = await fetch(`${server.origin}/api/drafts`, { - method: "POST", - headers, - body: JSON.stringify({ source: { source: source.source, ref: source.ref } }), - }); - expect(create.status).toBe(201); - const draft = await create.json() as { draftId: string; revision: number }; - const save = await fetch(`${server.origin}/api/drafts/${draft.draftId}/save`, { - method: "POST", - headers, - body: JSON.stringify({ expectedRevision: draft.revision }), - }); - expect(save.status).toBe(200); - const saved = await save.json() as { - ref: { id: string; version: string }; - }; - - const exported = await fetch( - `${server.origin}/api/export?id=${saved.ref.id}&version=${saved.ref.version}`, - { headers: { Cookie: cookie } }, - ); - expect(exported.status).toBe(200); - expect(exported.headers.get("content-type")).toBe( - "application/vnd.open-chatgpt-skin+zip", - ); - expect((await exported.arrayBuffer()).byteLength).toBeGreaterThan(100); - - const restored = await fetch(`${server.origin}/api/runtime/restore`, { - method: "POST", - headers, - }); - expect(restored.status).toBe(200); - await expect(restored.json()).resolves.toMatchObject({ status: "stopped" }); - }); -}); diff --git a/tests/theme-studio-session.test.ts b/tests/theme-studio-session.test.ts deleted file mode 100644 index 9c63662..0000000 --- a/tests/theme-studio-session.test.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { createStudioSession } from "@open-chatgpt-skin/theme-studio-service"; - -describe("Theme Studio session authentication", () => { - it("exchanges the bootstrap token exactly once", () => { - const values = ["a".repeat(64), "b".repeat(64)]; - const session = createStudioSession(() => values.shift()!); - - expect(session.exchange(session.bootstrapToken)).toBe("b".repeat(64)); - - let failure: unknown; - try { - session.exchange(session.bootstrapToken); - } catch (error) { - failure = error; - } - expect(failure).toMatchObject({ code: "STUDIO_SESSION_INVALID" }); - }); - - it("verifies only the exact session cookie", () => { - const values = ["a".repeat(64), "b".repeat(64)]; - const session = createStudioSession(() => values.shift()!); - const cookie = session.exchange(session.bootstrapToken); - - expect(session.verifyCookie(`other=x; ocs_studio_session=${cookie}`)).toBe(true); - expect(session.verifyCookie(`ocs_studio_session=${"c".repeat(64)}`)).toBe(false); - expect(session.verifyCookie(undefined)).toBe(false); - }); -}); diff --git a/tests/theme-studio-workspace.test.ts b/tests/theme-studio-workspace.test.ts deleted file mode 100644 index 46a4290..0000000 --- a/tests/theme-studio-workspace.test.ts +++ /dev/null @@ -1,661 +0,0 @@ -import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join, resolve } from "node:path"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import sharp from "sharp"; -import { - createRuntimePaths, - type RuntimePaths, -} from "@open-chatgpt-skin/windows-runtime"; -import { - ThemeStudioWorkspace, -} from "@open-chatgpt-skin/theme-studio-service"; -import type { - StudioRuntimeStatus, - StudioThemeRef, -} from "@open-chatgpt-skin/theme-studio-core"; - -const temporaryRoots: string[] = []; - -afterEach(async () => { - await Promise.all(temporaryRoots.splice(0).map((root) => - rm(root, { recursive: true, force: true }) - )); -}); - -const stopped: StudioRuntimeStatus = { - status: "stopped", - controllerAvailable: false, - selectedTheme: null, - appliedTheme: null, - skinApplied: false, - packageVersion: null, - operation: null, - nextAction: "Launch a managed Codex session.", -}; - -function woff2(): Uint8Array { - return Uint8Array.from([0x77, 0x4f, 0x46, 0x32]); -} - -async function createWorkspace(): Promise<{ - readonly workspace: ThemeStudioWorkspace; - readonly paths: RuntimePaths; - readonly applyRuntimeTheme: ReturnType; -}> { - const root = await mkdtemp(join(tmpdir(), "ocs-studio-")); - temporaryRoots.push(root); - const paths = createRuntimePaths(root, resolve(".")); - const applyRuntimeTheme = vi.fn(async (ref: StudioThemeRef): Promise => ({ - status: "active", - controllerAvailable: true, - selectedTheme: ref, - appliedTheme: ref, - skinApplied: true, - packageVersion: "26.707.12708.0", - operation: null, - nextAction: "Theme is active.", - })); - const result = new ThemeStudioWorkspace({ - paths, - runtimeStatus: async () => stopped, - applyRuntimeTheme, - restoreRuntimeTheme: async () => stopped, - now: () => "2026-07-18T02:00:00.000Z", - }); - await result.initialize(); - return { workspace: result, paths, applyRuntimeTheme }; -} - -describe("ThemeStudioWorkspace", () => { - it("creates and saves only schema v4 drafts", async () => { - const { workspace } = await createWorkspace(); - const source = (await workspace.listThemes()).themes - .find((theme) => theme.ref.id === "mountain-mist")!; - const draft = await workspace.createDraft({ - source: { source: source.source, ref: source.ref }, - themeId: "schema-v4-draft", - name: "Schema v4 Draft", - }); - - expect(draft.theme.schemaVersion).toBe(4); - const saved = await workspace.saveVersion({ - draftId: draft.draftId, - expectedRevision: draft.revision, - }); - expect(saved.draft.theme.schemaVersion).toBe(4); - }); - - it("uploads and references a display font", async () => { - const { workspace } = await createWorkspace(); - const source = (await workspace.listThemes()).themes - .find((theme) => theme.ref.id === "mountain-mist")!; - const draft = await workspace.createDraft({ - source: { source: source.source, ref: source.ref }, - themeId: "display-font-draft", - name: "Display Font Draft", - }); - - const uploaded = await workspace.uploadAsset({ - draftId: draft.draftId, - expectedRevision: draft.revision, - slot: "display-font", - fileName: "display.woff2", - mimeType: "font/woff2", - bytes: woff2(), - }); - - expect(uploaded.theme.assets.fonts?.["display-font"]) - .toMatch(/^fonts\/display-font-[0-9a-f]{12}\.woff2$/); - expect(uploaded.theme.typography).toMatchObject({ - displayFontAssetKey: "display-font", - displayFamily: "ocs-display-font", - }); - }); - - it("uploads a composition layer and replaces the same layer ID", async () => { - const { workspace } = await createWorkspace(); - const source = (await workspace.listThemes()).themes - .find((theme) => theme.ref.id === "mountain-mist")!; - const draft = await workspace.createDraft({ - source: { source: source.source, ref: source.ref }, - themeId: "composition-layer-draft", - name: "Composition Layer Draft", - }); - const sourceImage = await sharp({ - create: { - width: 320, - height: 180, - channels: 4, - background: { r: 220, g: 80, b: 140, alpha: 1 }, - }, - }).png().toBuffer(); - - const uploaded = await workspace.uploadAsset({ - draftId: draft.draftId, - expectedRevision: draft.revision, - slot: "composition-layer", - assetKey: "hero-signature", - fileName: "hero-signature.png", - mimeType: "image/png", - bytes: sourceImage, - }); - - expect(uploaded.theme.assets.decorations?.["hero-signature"]) - .toMatch(/^assets\/decoration-hero-signature-[0-9a-f]{12}\.webp$/); - expect(uploaded.theme.composition.layers).toEqual([{ - id: "hero-signature", - asset: { kind: "decoration", assetKey: "hero-signature" }, - surface: "home-hero", - anchor: "top-left", - positionX: 0.1, - positionY: 0.1, - width: 0.2, - opacity: 1, - rotation: 0, - required: false, - }]); - - const replaced = await workspace.uploadAsset({ - draftId: uploaded.draftId, - expectedRevision: uploaded.revision, - slot: "composition-layer", - assetKey: "hero-signature", - fileName: "hero-signature.png", - mimeType: "image/png", - bytes: sourceImage, - }); - expect(replaced.theme.composition.layers).toHaveLength(1); - expect(replaced.theme.composition.layers[0]?.id).toBe("hero-signature"); - }); - - it("rejects a 25th distinct composition layer without changing the draft", async () => { - const { workspace } = await createWorkspace(); - const source = (await workspace.listThemes()).themes - .find((theme) => theme.ref.id === "mountain-mist")!; - const draft = await workspace.createDraft({ - source: { source: source.source, ref: source.ref }, - themeId: "full-composition-draft", - name: "Full Composition Draft", - }); - const fullTheme = structuredClone(draft.theme); - const sharedPath = fullTheme.assets.background!; - fullTheme.assets.decorations = Object.fromEntries( - Array.from({ length: 24 }, (_value, index) => [ - `layer-${index}`, - sharedPath, - ]), - ); - fullTheme.composition.layers = Array.from({ length: 24 }, (_value, index) => ({ - id: `layer-${index}`, - asset: { kind: "decoration" as const, assetKey: `layer-${index}` }, - surface: "home-hero" as const, - anchor: "top-left" as const, - positionX: 0.1, - positionY: 0.1, - width: 0.2, - opacity: 1, - rotation: 0, - required: false, - })); - const full = await workspace.updateDraft({ - draftId: draft.draftId, - expectedRevision: draft.revision, - theme: fullTheme, - }); - const sourceImage = await sharp({ - create: { - width: 64, - height: 64, - channels: 4, - background: { r: 220, g: 80, b: 140, alpha: 1 }, - }, - }).png().toBuffer(); - - await expect(workspace.uploadAsset({ - draftId: full.draftId, - expectedRevision: full.revision, - slot: "composition-layer", - assetKey: "layer-24", - fileName: "layer-24.png", - mimeType: "image/png", - bytes: sourceImage, - })).rejects.toMatchObject({ code: "STUDIO_ASSET_INVALID" }); - const unchanged = await workspace.openDraft(full.draftId); - expect(unchanged).toMatchObject({ - revision: full.revision, - theme: { composition: { layers: expect.arrayContaining([ - expect.objectContaining({ id: "layer-0" }), - ]) } }, - }); - expect(unchanged.theme.composition.layers).toHaveLength(24); - }); - - it("creates, updates, validates, undoes, and redoes a typed draft", async () => { - const { workspace, applyRuntimeTheme } = await createWorkspace(); - const library = await workspace.listThemes(); - expect(library.themes.filter((theme) => theme.source === "builtin")).toHaveLength(5); - expect(library.themes.filter((theme) => theme.source === "recipe")).toHaveLength(0); - - const source = library.themes.find((theme) => theme.ref.id === "mountain-mist")!; - expect(source).toMatchObject({ - author: "OpenChatGPTSkin", - homepage: "https://github.com/u2bo/OpenChatGPTSkin.git", - localized: { en: { name: "Mountain Mist" } }, - }); - await expect(workspace.applySavedTheme(source.ref)).resolves.toMatchObject({ - status: "active", - appliedTheme: source.ref, - }); - expect(applyRuntimeTheme).toHaveBeenCalledWith(source.ref); - await expect(workspace.exportTheme(source.ref)).rejects.toMatchObject({ - code: "STUDIO_EXPORT_INVALID", - message: "只有个人主题可以导出", - }); - const draft = await workspace.createDraft({ - source: { source: source.source, ref: source.ref }, - themeId: "my-mountain", - name: "我的山岚", - }); - expect(draft).toMatchObject({ revision: 0, dirty: true }); - expect(draft.assetUrls[draft.theme.assets.background!]).toMatch(/^\/api\/draft-asset/); - await expect(workspace.openLatestDraft()).resolves.toMatchObject({ - draftId: draft.draftId, - revision: draft.revision, - }); - - const reservedTheme = structuredClone(draft.theme); - reservedTheme.id = "mountain-mist"; - await expect(workspace.updateDraft({ - draftId: draft.draftId, - expectedRevision: draft.revision, - theme: reservedTheme, - })).rejects.toMatchObject({ - code: "STUDIO_DRAFT_INVALID", - message: "Theme ID is reserved by the built-in catalog", - }); - - const invalidTheme = structuredClone(draft.theme); - invalidTheme.colors.text = "#ffffff"; - const invalid = await workspace.updateDraft({ - draftId: draft.draftId, - expectedRevision: draft.revision, - theme: invalidTheme, - }); - expect(invalid.issues).toEqual(expect.arrayContaining([ - expect.objectContaining({ code: "TEXT_CONTRAST_TOO_LOW", path: "colors.text" }), - ])); - await expect(workspace.saveVersion({ - draftId: invalid.draftId, - expectedRevision: invalid.revision, - })).rejects.toMatchObject({ code: "STUDIO_DRAFT_INVALID" }); - - const undone = await workspace.undo({ - draftId: invalid.draftId, - expectedRevision: invalid.revision, - }); - expect(undone.theme.colors.text).toBe(draft.theme.colors.text); - const redone = await workspace.redo({ - draftId: undone.draftId, - expectedRevision: undone.revision, - }); - expect(redone.theme.colors.text).toBe("#ffffff"); - }); - - it("keeps one draft per theme and requires an explicit load or overwrite choice", async () => { - const { workspace } = await createWorkspace(); - const source = (await workspace.listThemes()).themes - .find((theme) => theme.ref.id === "mountain-mist")!; - - const first = await workspace.createDraft({ - source: { source: source.source, ref: source.ref }, - }); - expect(first.theme.id).toBe("mountain-mist-custom"); - await expect(workspace.createDraft({ - source: { source: source.source, ref: source.ref }, - })).rejects.toMatchObject({ code: "STUDIO_DRAFT_CONFLICT" }); - - const loaded = await workspace.createDraft({ - source: { source: source.source, ref: source.ref }, - conflictResolution: "load-existing", - }); - expect(loaded.draftId).toBe(first.draftId); - - const overwritten = await workspace.createDraft({ - source: { source: source.source, ref: source.ref }, - conflictResolution: "overwrite-existing", - }); - expect(overwritten.draftId).toBe(first.draftId); - expect(overwritten.theme.id).toBe(first.theme.id); - }); - - it("uploads authorized artwork, saves, exports, imports, and applies an exact version", async () => { - const { workspace, applyRuntimeTheme } = await createWorkspace(); - const source = (await workspace.listThemes()).themes - .find((theme) => theme.ref.id === "mountain-mist")!; - const draft = await workspace.createDraft({ - source: { source: source.source, ref: source.ref }, - themeId: "my-local-vocalist", - name: "我的本地歌姬", - }); - expect(draft.issues).toHaveLength(0); - - const publicWithoutAttribution = structuredClone(draft.theme); - publicWithoutAttribution.rights.localOnly = false; - delete publicWithoutAttribution.rights.attribution; - const rightsInvalid = await workspace.updateDraft({ - draftId: draft.draftId, - expectedRevision: draft.revision, - theme: publicWithoutAttribution, - }); - expect(rightsInvalid.issues).toEqual(expect.arrayContaining([ - expect.objectContaining({ - code: "RIGHTS_ATTRIBUTION_REQUIRED", - path: "rights.attribution", - }), - ])); - const rightsUndone = await workspace.undo({ - draftId: rightsInvalid.draftId, - expectedRevision: rightsInvalid.revision, - }); - - const sourceImage = await sharp({ - create: { - width: 1280, - height: 720, - channels: 4, - background: { r: 32, g: 124, b: 134, alpha: 1 }, - }, - }).png().toBuffer(); - const uploaded = await workspace.uploadAsset({ - draftId: rightsUndone.draftId, - expectedRevision: rightsUndone.revision, - slot: "background", - fileName: "authorized-art.png", - mimeType: "image/png", - bytes: sourceImage, - }); - expect(uploaded.theme.assets.background).toMatch(/^assets\/background-[0-9a-f]{12}\.webp$/); - expect(uploaded.issues).toHaveLength(0); - - const uploadUndone = await workspace.undo({ - draftId: uploaded.draftId, - expectedRevision: uploaded.revision, - }); - expect(uploadUndone.theme.assets.background).toBe(draft.theme.assets.background); - const uploadRedone = await workspace.redo({ - draftId: uploadUndone.draftId, - expectedRevision: uploadUndone.revision, - }); - expect(uploadRedone.theme.assets.background).toBe(uploaded.theme.assets.background); - await expect(workspace.readDraftAsset( - uploadRedone.draftId, - uploadRedone.theme.assets.background!, - )).resolves.toMatchObject({ mimeType: "image/webp" }); - - const saved = await workspace.saveVersion({ - draftId: uploadRedone.draftId, - expectedRevision: uploadRedone.revision, - }); - expect(saved.ref).toEqual({ id: "my-local-vocalist", version: "1.0.0" }); - expect(saved.draft.dirty).toBe(false); - - const sameContentDraft = await workspace.createDraft({ - source: { source: "personal", ref: saved.ref }, - themeId: saved.ref.id, - name: saved.draft.theme.name, - conflictResolution: "load-existing", - }); - expect(sameContentDraft.draftId).toBe(saved.draft.draftId); - const idempotentSave = await workspace.saveVersion({ - draftId: sameContentDraft.draftId, - expectedRevision: sameContentDraft.revision, - }); - expect(idempotentSave.ref).toEqual(saved.ref); - - const changedTheme = structuredClone(idempotentSave.draft.theme); - changedTheme.colors.accent = "#167f8a"; - const changedDraft = await workspace.updateDraft({ - draftId: idempotentSave.draft.draftId, - expectedRevision: idempotentSave.draft.revision, - theme: changedTheme, - }); - await expect(workspace.saveVersion({ - draftId: changedDraft.draftId, - expectedRevision: changedDraft.revision, - })).resolves.toMatchObject({ - ref: { id: saved.ref.id, version: "1.0.1" }, - }); - - await expect(workspace.createDraft({ - source: { source: "personal", ref: saved.ref }, - themeId: saved.ref.id, - name: saved.draft.theme.name, - })).rejects.toMatchObject({ code: "STUDIO_DRAFT_CONFLICT" }); - - const exported = await workspace.exportTheme(saved.ref); - expect(exported.fileName).toBe("my-local-vocalist-1.0.0.ocskin"); - expect(exported.bytes.length).toBeGreaterThan(100); - - const imported = await workspace.importTheme({ - fileName: exported.fileName, - bytes: exported.bytes, - }); - expect(imported.savedRef).toEqual(saved.ref); - expect(imported.dirty).toBe(false); - - const applied = await workspace.applyTheme({ - draftId: imported.draftId, - expectedRevision: imported.revision, - }); - expect(applied.runtime).toMatchObject({ status: "active", appliedTheme: saved.ref }); - expect(applyRuntimeTheme).toHaveBeenCalledWith(saved.ref); - }); - - it("normalizes interface imagery, shares the background, and keeps failed uploads atomic", async () => { - const { workspace, paths } = await createWorkspace(); - const source = (await workspace.listThemes()).themes - .find((theme) => theme.ref.id === "mountain-mist")!; - const draft = await workspace.createDraft({ - source: { source: source.source, ref: source.ref }, - themeId: "interface-imagery-demo", - name: "界面素材测试", - }); - const sourceImage = await sharp({ - create: { - width: 900, - height: 500, - channels: 4, - background: { r: 42, g: 119, b: 151, alpha: 1 }, - }, - }).png().toBuffer(); - - const avatar = await workspace.uploadAsset({ - draftId: draft.draftId, - expectedRevision: draft.revision, - slot: "profile-avatar", - fileName: "avatar.png", - mimeType: "image/png", - bytes: sourceImage, - }); - const avatarPath = avatar.theme.assets.profileAvatar!; - expect(avatarPath).toMatch(/^assets\/profile-avatar-[0-9a-f]{12}\.webp$/); - expect(await sharp((await workspace.readDraftAsset( - avatar.draftId, - avatarPath, - )).bytes).metadata()).toMatchObject({ width: 256, height: 256 }); - - const suggestion = await workspace.uploadAsset({ - draftId: avatar.draftId, - expectedRevision: avatar.revision, - slot: "suggestion-card1", - fileName: "card.png", - mimeType: "image/png", - bytes: sourceImage, - }); - const suggestionPath = suggestion.theme.assets.suggestionIcons?.card1!; - expect(suggestionPath).toMatch(/^assets\/suggestion-card1-[0-9a-f]{12}\.webp$/); - expect(await sharp((await workspace.readDraftAsset( - suggestion.draftId, - suggestionPath, - )).bytes).metadata()).toMatchObject({ width: 192, height: 192 }); - - const project = await workspace.uploadAsset({ - draftId: suggestion.draftId, - expectedRevision: suggestion.revision, - slot: "project-icon1", - fileName: "project.png", - mimeType: "image/png", - bytes: sourceImage, - }); - const projectPath = project.theme.assets.projectIcons?.[0]!; - expect(projectPath).toMatch(/^assets\/project-icon1-[0-9a-f]{12}\.webp$/); - expect(await sharp((await workspace.readDraftAsset( - project.draftId, - projectPath, - )).bytes).metadata()).toMatchObject({ width: 192, height: 192 }); - - const sharedTheme = structuredClone(project.theme); - const backgroundPath = sharedTheme.assets.background!; - sharedTheme.assets.profileAvatar = backgroundPath; - sharedTheme.assets.suggestionIcons = { - card1: backgroundPath, - card2: backgroundPath, - card3: backgroundPath, - card4: backgroundPath, - }; - const shared = await workspace.updateDraft({ - draftId: project.draftId, - expectedRevision: project.revision, - theme: sharedTheme, - }); - expect(shared.assetUrls[backgroundPath]).toMatch(/^\/api\/draft-asset/); - expect(Object.keys(shared.assetUrls).filter((path) => path === backgroundPath)).toHaveLength(1); - - await expect(workspace.uploadAsset({ - draftId: shared.draftId, - expectedRevision: shared.revision, - slot: "suggestion-card2", - fileName: "broken.txt", - mimeType: "text/plain", - bytes: new TextEncoder().encode("not an image"), - })).rejects.toMatchObject({ code: "STUDIO_ASSET_INVALID" }); - await expect(workspace.openDraft(shared.draftId)).resolves.toMatchObject({ - revision: shared.revision, - theme: { assets: { suggestionIcons: sharedTheme.assets.suggestionIcons } }, - }); - - const orphanPath = join( - paths.themeStudioDraftDirectory, - shared.draftId, - "assets", - "orphan.webp", - ); - await writeFile(orphanPath, sourceImage); - const renamedTheme = structuredClone(shared.theme); - renamedTheme.name = "界面素材测试(已更新)"; - await workspace.updateDraft({ - draftId: shared.draftId, - expectedRevision: shared.revision, - theme: renamedTheme, - }); - await expect(readFile(orphanPath)).rejects.toMatchObject({ code: "ENOENT" }); - }); - - it("migrates persisted v2 draft history to v4 when it is reopened", async () => { - const { workspace, paths } = await createWorkspace(); - const source = (await workspace.listThemes()).themes - .find((theme) => theme.ref.id === "mountain-mist")!; - const draft = await workspace.createDraft({ - source: { source: source.source, ref: source.ref }, - themeId: "legacy-draft", - name: "旧草稿", - }); - const recordPath = join(paths.themeStudioDraftDirectory, draft.draftId, "draft.json"); - const record = JSON.parse(await readFile(recordPath, "utf8")); - record.theme.schemaVersion = 2; - delete record.theme.assets.profileAvatar; - delete record.theme.assets.suggestionIcons; - delete record.theme.assets.projectIcons; - delete record.theme.interfaceImages; - delete record.theme.typography.displayFamily; - delete record.theme.typography.displayFontAssetKey; - delete record.theme.typography.displaySize; - delete record.theme.typography.displayWeight; - delete record.theme.typography.displayLineHeight; - delete record.theme.typography.displayLetterSpacing; - delete record.theme.composition; - record.past = [{ ...record.theme }]; - await writeFile(recordPath, `${JSON.stringify(record, null, 2)}\n`, "utf8"); - - const migrated = await workspace.openDraft(draft.draftId); - - expect(migrated.theme.schemaVersion).toBe(4); - expect(migrated.undoAvailable).toBe(true); - await expect(workspace.undo({ - draftId: migrated.draftId, - expectedRevision: migrated.revision, - })).resolves.toMatchObject({ theme: { schemaVersion: 4 } }); - }); - - it("never creates a personal version from the apply command", async () => { - const { workspace, applyRuntimeTheme } = await createWorkspace(); - const source = (await workspace.listThemes()).themes - .find((theme) => theme.ref.id === "mountain-mist")!; - const draft = await workspace.createDraft({ - source: { source: source.source, ref: source.ref }, - themeId: "unsaved-mountain", - name: "未保存山岚", - }); - - await expect(workspace.applyTheme({ - draftId: draft.draftId, - expectedRevision: draft.revision, - })).rejects.toMatchObject({ code: "STUDIO_APPLY_FAILED" }); - expect((await workspace.listThemes()).themes.some((theme) => - theme.source === "personal" && theme.ref.id === draft.theme.id - )).toBe(false); - expect(applyRuntimeTheme).not.toHaveBeenCalled(); - }); - - it("deletes one personal version or every version and invalidates saved draft refs", async () => { - const { workspace } = await createWorkspace(); - const source = (await workspace.listThemes()).themes - .find((theme) => theme.ref.id === "mountain-mist")!; - const draft = await workspace.createDraft({ - source: { source: source.source, ref: source.ref }, - themeId: "deletable-mountain", - name: "可删除山岚", - }); - const first = await workspace.saveVersion({ - draftId: draft.draftId, - expectedRevision: draft.revision, - }); - const changedTheme = structuredClone(first.draft.theme); - changedTheme.colors.accent = "#315f4a"; - const changed = await workspace.updateDraft({ - draftId: draft.draftId, - expectedRevision: first.draft.revision, - theme: changedTheme, - }); - const second = await workspace.saveVersion({ - draftId: draft.draftId, - expectedRevision: changed.revision, - }); - - await workspace.deletePersonalTheme({ - id: first.ref.id, - version: first.ref.version, - }); - expect((await workspace.listThemes()).themes.filter((theme) => - theme.source === "personal" && theme.ref.id === first.ref.id - ).map((theme) => theme.ref.version)).toEqual([second.ref.version]); - - await workspace.deletePersonalTheme({ id: second.ref.id }); - expect((await workspace.listThemes()).themes.some((theme) => - theme.source === "personal" && theme.ref.id === second.ref.id - )).toBe(false); - await expect(workspace.openDraft(draft.draftId)) - .rejects.toMatchObject({ code: "STUDIO_DRAFT_NOT_FOUND" }); - }); -}); diff --git a/tests/windows-local-release.test.ts b/tests/windows-local-release.test.ts deleted file mode 100644 index 929ce41..0000000 --- a/tests/windows-local-release.test.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { readFile } from "node:fs/promises"; -import { describe, expect, it } from "vitest"; - -describe("local Windows release command", () => { - it("builds validated ZIP and Setup artifacts without touching personal data", async () => { - const [packageJson, script, gitignore] = await Promise.all([ - readFile("package.json", "utf8").then(JSON.parse) as Promise<{ - readonly scripts: Readonly>; - }>, - readFile("scripts/release/build-windows-local.ps1", "utf8"), - readFile(".gitignore", "utf8"), - ]); - - expect(packageJson.scripts["release:windows"]).toBe( - "powershell -NoProfile -ExecutionPolicy Bypass -File scripts/release/build-windows-local.ps1", - ); - for (const expected of [ - "'install', '--no-audit', '--no-fund'", - "'ls' '--depth=0' '--silent'", - "'release:version'", - "'verify'", - "'studio:build'", - "'release:node'", - "'release:stage'", - "'release:acceptance'", - "'release:package'", - "'release:acceptance:archive'", - "build-windows-installer.ps1", - "'release:checksums'", - "artifacts\\windows-x64", - "https://github.com/jrsoftware/issrc/releases/download/is-6_7_1/innosetup-", - "4D11E8050B6185E0D49BD9E8CC661A7A59F44959A621D31D11033124C4E8A7B0", - "'/PORTABLE=1'", - "$env:INNO_SETUP_COMPILER = Resolve-InnoSetupCompiler", - ]) { - expect(script).toContain(expected); - } - expect(script).not.toContain("accept-windows-installer.ps1"); - expect(script).toContain( - "Existing dependencies are complete; skipping dependency installation.", - ); - expect(script).toContain("Local build files were retained for diagnosis"); - expect(gitignore).toContain("artifacts/"); - }); -}); diff --git a/tests/workspace.test.ts b/tests/workspace.test.ts index 1215126..26e85d8 100644 --- a/tests/workspace.test.ts +++ b/tests/workspace.test.ts @@ -6,43 +6,36 @@ import { THEME_SCHEMA_VERSION } from "@open-chatgpt-skin/theme-schema"; describe("workspace packages", () => { it("exports stable foundation versions", () => { expect(THEME_SCHEMA_VERSION).toBe(4); - expect(THEME_CORE_VERSION).toBe("0.2.0"); + expect(THEME_CORE_VERSION).toBe("0.3.0-alpha.1"); }); - it("declares the Runtime CLI and its built executable", async () => { - const [rootPackage, runtimePackage, studioPackage, studioServicePackage] = await Promise.all([ + it("keeps Go as the only business host and TypeScript as authoring packages", async () => { + const [rootPackage, runtimeContractPackage, studioPackage, goModule] = await Promise.all([ readFile("package.json", "utf8").then(JSON.parse), - readFile("runtime/windows/package.json", "utf8").then(JSON.parse), + readFile("packages/runtime-contract/package.json", "utf8").then(JSON.parse), readFile("apps/theme-studio/package.json", "utf8").then(JSON.parse), - readFile("runtime/theme-studio-service/package.json", "utf8").then(JSON.parse), + readFile("host/go/go.mod", "utf8"), ]); - expect(rootPackage.workspaces).toEqual(["apps/*", "packages/*", "runtime/*"]); + expect(rootPackage.workspaces).toEqual(["apps/*", "packages/*"]); expect(rootPackage.name).toBe("open-chatgpt-skin"); - expect(runtimePackage.name).toBe("@open-chatgpt-skin/windows-runtime"); + expect(runtimeContractPackage.name).toBe("@open-chatgpt-skin/runtime-contract"); expect(studioPackage.name).toBe("@open-chatgpt-skin/theme-studio"); - expect(studioServicePackage.name).toBe("@open-chatgpt-skin/theme-studio-service"); + expect(goModule).toContain("module github.com/u2bo/OpenChatGPTSkin/host/go"); expect(rootPackage.scripts).toMatchObject({ - runtime: "npm run build && node runtime/windows/dist/cli.js", - "studio:dev": "npm run build && npm run dev -w @open-chatgpt-skin/theme-studio-service", + runtime: "go -C host/go run -buildvcs=false -tags nodynamic ./cmd/openchatgptskin runtime", + "studio:dev": "tsx scripts/dev/start-go-studio.ts", "studio:build": "npm run build -w @open-chatgpt-skin/theme-studio", - verify: "npm run contracts:verify && npm run build && npm run test && npm run typecheck", - "verify:runtime": "npm run build && npm run test && npm run typecheck", + verify: "npm run contracts:verify && npm run build && npm run test && npm run typecheck && npm run go:verify", "verify:foundation": "npm run themes:build && npm run build && npm run test && npm run typecheck && node packages/theme-core/dist/cli.js catalog --root themes", }); - expect(runtimePackage.bin).toMatchObject({ - "open-chatgpt-skin-runtime": "./dist/cli.js", - }); - expect(runtimePackage.dependencies).toHaveProperty( + expect(runtimeContractPackage.dependencies).toHaveProperty( "@open-chatgpt-skin/theme-schema", - "0.2.0", + "0.3.0-alpha.1", ); expect(studioPackage.dependencies).toHaveProperty( "@open-chatgpt-skin/theme-schema", - "0.2.0", + "0.3.0-alpha.1", ); - expect(studioServicePackage.bin).toMatchObject({ - "open-chatgpt-skin-studio": "./dist/cli.js", - }); }); }); diff --git a/tsconfig.json b/tsconfig.json index 12e6195..c19acef 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,7 +6,6 @@ { "path": "./packages/theme-core" }, { "path": "./packages/cdp-adapter" }, { "path": "./packages/theme-studio-core" }, - { "path": "./runtime/windows" }, - { "path": "./runtime/theme-studio-service" } + { "path": "./packages/runtime-contract" } ] } diff --git a/tsconfig.scripts.json b/tsconfig.scripts.json index 08ed034..9262c15 100644 --- a/tsconfig.scripts.json +++ b/tsconfig.scripts.json @@ -20,8 +20,8 @@ "@open-chatgpt-skin/cdp-adapter": [ "./packages/cdp-adapter/src/index.ts" ], - "@open-chatgpt-skin/windows-runtime": [ - "./runtime/windows/src/index.ts" + "@open-chatgpt-skin/runtime-contract": [ + "./packages/runtime-contract/src/index.ts" ] } }, diff --git a/vitest.config.ts b/vitest.config.ts index 12276fd..1a36efe 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -15,19 +15,14 @@ export default defineConfig({ "@open-chatgpt-skin/theme-studio-core": fileURLToPath( new URL("./packages/theme-studio-core/src/index.ts", import.meta.url), ), - "@open-chatgpt-skin/theme-studio-service": fileURLToPath( - new URL("./runtime/theme-studio-service/src/index.ts", import.meta.url), - ), - "@open-chatgpt-skin/windows-runtime": fileURLToPath( - new URL("./runtime/windows/src/index.ts", import.meta.url), + "@open-chatgpt-skin/runtime-contract": fileURLToPath( + new URL("./packages/runtime-contract/src/index.ts", import.meta.url), ), }, }, test: { environment: "node", include: ["tests/**/*.test.ts", "tests/**/*.test.tsx"], - // The secured Windows Pipe has one intentional name per user SID. - fileParallelism: process.platform !== "win32", coverage: { reporter: ["text", "json-summary"] }, }, }); From d291a6150562bcb9470e07a48b05a0f7e43eef43 Mon Sep 17 00:00:00 2001 From: u2bo <285077078@qq.com> Date: Sat, 25 Jul 2026 15:30:00 +0800 Subject: [PATCH 22/23] fix: bound macOS control socket paths --- host/go/internal/app/platform_darwin.go | 2 +- .../platform/macos/transport_darwin.go | 33 +++++++++++ .../platform/macos/transport_darwin_test.go | 57 ++++++++++++++++++- 3 files changed, 88 insertions(+), 4 deletions(-) diff --git a/host/go/internal/app/platform_darwin.go b/host/go/internal/app/platform_darwin.go index 2096317..806fa26 100644 --- a/host/go/internal/app/platform_darwin.go +++ b/host/go/internal/app/platform_darwin.go @@ -14,7 +14,7 @@ import ( "golang.org/x/sys/unix" ) -func controlEndpoint(dataRoot string) string { return filepath.Join(dataRoot, "runtime.sock") } +func controlEndpoint(dataRoot string) string { return platform.Endpoint(dataRoot) } func listenControl(endpoint string) (net.Listener, error) { return platform.Listen(endpoint) } func dialControl(endpoint string) (net.Conn, error) { return platform.Dial(endpoint) } func configureDetached(command *exec.Cmd) { command.SysProcAttr = &syscall.SysProcAttr{Setsid: true} } diff --git a/host/go/internal/platform/macos/transport_darwin.go b/host/go/internal/platform/macos/transport_darwin.go index 4f17eba..16a9a1e 100644 --- a/host/go/internal/platform/macos/transport_darwin.go +++ b/host/go/internal/platform/macos/transport_darwin.go @@ -3,12 +3,18 @@ package macos import ( + "crypto/sha256" + "encoding/hex" "errors" + "fmt" "net" "os" + "path/filepath" "syscall" ) +const socketDirectoryPrefix = "ocskin-" + type ownedListener struct { net.Listener path string @@ -16,7 +22,19 @@ type ownedListener struct { ino uint64 } +// Endpoint maps a data root to a short, user-private socket path. macOS Unix +// sockets have a small fixed path limit, while Application Support and test +// directories can be substantially longer than that limit. +func Endpoint(dataRoot string) string { + digest := sha256.Sum256([]byte(filepath.Clean(dataRoot))) + directory := filepath.Join("/tmp", fmt.Sprintf("%s%d", socketDirectoryPrefix, os.Getuid())) + return filepath.Join(directory, hex.EncodeToString(digest[:8])+".sock") +} + func Listen(endpoint string) (net.Listener, error) { + if err := ensurePrivateDirectory(filepath.Dir(endpoint)); err != nil { + return nil, err + } if err := removeStaleSocket(endpoint); err != nil { return nil, err } @@ -44,6 +62,21 @@ func Listen(endpoint string) (net.Listener, error) { return &ownedListener{Listener: listener, path: endpoint, dev: uint64(stat.Dev), ino: stat.Ino}, nil } +func ensurePrivateDirectory(directory string) error { + if err := os.MkdirAll(directory, 0o700); err != nil { + return err + } + info, err := os.Lstat(directory) + if err != nil { + return err + } + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 || int(stat.Uid) != os.Getuid() || info.Mode().Perm()&0o077 != 0 { + return errors.New("unix socket parent directory is not private") + } + return nil +} + func removeStaleSocket(endpoint string) error { info, err := os.Lstat(endpoint) if errors.Is(err, os.ErrNotExist) { diff --git a/host/go/internal/platform/macos/transport_darwin_test.go b/host/go/internal/platform/macos/transport_darwin_test.go index bbdf7c1..919dc88 100644 --- a/host/go/internal/platform/macos/transport_darwin_test.go +++ b/host/go/internal/platform/macos/transport_darwin_test.go @@ -4,15 +4,27 @@ package macos import ( "context" + "fmt" "os" "path/filepath" + "strings" "testing" "github.com/u2bo/OpenChatGPTSkin/host/go/internal/control" ) +func shortSocketEndpoint(t *testing.T) string { + t.Helper() + directory, err := os.MkdirTemp("/tmp", "ocskin-test-") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.RemoveAll(directory) }) + return filepath.Join(directory, "runtime.sock") +} + func TestUnixSocketRoundTripAndPermissions(t *testing.T) { - endpoint := filepath.Join(t.TempDir(), "runtime.sock") + endpoint := shortSocketEndpoint(t) listener, err := Listen(endpoint) if err != nil { t.Fatal(err) @@ -42,7 +54,7 @@ func TestUnixSocketRoundTripAndPermissions(t *testing.T) { } func TestUnixSocketRefusesUnsafeStartupPath(t *testing.T) { - endpoint := filepath.Join(t.TempDir(), "runtime.sock") + endpoint := shortSocketEndpoint(t) if err := os.WriteFile(endpoint, []byte("keep"), 0o600); err != nil { t.Fatal(err) } @@ -57,7 +69,7 @@ func TestUnixSocketRefusesUnsafeStartupPath(t *testing.T) { } func TestUnixSocketClosePreservesReplacementInode(t *testing.T) { - endpoint := filepath.Join(t.TempDir(), "runtime.sock") + endpoint := shortSocketEndpoint(t) listener, err := Listen(endpoint) if err != nil { t.Fatal(err) @@ -76,3 +88,42 @@ func TestUnixSocketClosePreservesReplacementInode(t *testing.T) { t.Fatalf("replacement inode changed: contents=%q error=%v", contents, err) } } + +func TestEndpointStaysShortForLongDataRoot(t *testing.T) { + dataRoot := filepath.Join(t.TempDir(), strings.Repeat("long-data-root-", 20)) + endpoint := Endpoint(dataRoot) + if len([]byte(endpoint)) > 80 { + t.Fatalf("endpoint is too long for a Unix socket: %q", endpoint) + } + if endpoint != Endpoint(dataRoot) { + t.Fatalf("endpoint is not deterministic: %q", endpoint) + } + if endpoint == Endpoint(dataRoot+"-other") { + t.Fatalf("endpoint does not isolate data roots: %q", endpoint) + } + wantDirectory := filepath.Join("/tmp", fmt.Sprintf("ocskin-%d", os.Getuid())) + if filepath.Dir(endpoint) != wantDirectory { + t.Fatalf("endpoint directory = %q, want %q", filepath.Dir(endpoint), wantDirectory) + } + listener, err := Listen(endpoint) + if err != nil { + t.Fatal(err) + } + if err := listener.Close(); err != nil { + t.Fatal(err) + } +} + +func TestUnixSocketRefusesNonPrivateParentDirectory(t *testing.T) { + directory := filepath.Join(t.TempDir(), "not-private") + if err := os.Mkdir(directory, 0o755); err != nil { + t.Fatal(err) + } + if err := os.Chmod(directory, 0o755); err != nil { + t.Fatal(err) + } + if listener, err := Listen(filepath.Join(directory, "runtime.sock")); err == nil { + listener.Close() + t.Fatal("listener started in a non-private directory") + } +} From 115a86fc3f583decc059883b2c9e0eb3eb474836 Mon Sep 17 00:00:00 2001 From: u2bo <285077078@qq.com> Date: Sat, 25 Jul 2026 15:38:01 +0800 Subject: [PATCH 23/23] fix: preserve replacement Unix socket paths --- host/go/internal/platform/macos/transport_darwin.go | 1 + 1 file changed, 1 insertion(+) diff --git a/host/go/internal/platform/macos/transport_darwin.go b/host/go/internal/platform/macos/transport_darwin.go index 16a9a1e..43f8286 100644 --- a/host/go/internal/platform/macos/transport_darwin.go +++ b/host/go/internal/platform/macos/transport_darwin.go @@ -42,6 +42,7 @@ func Listen(endpoint string) (net.Listener, error) { if err != nil { return nil, err } + listener.(*net.UnixListener).SetUnlinkOnClose(false) if err := os.Chmod(endpoint, 0o600); err != nil { listener.Close() os.Remove(endpoint)