Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ jobs:
- name: Run portable Node.js regressions
shell: bash
run: |
node tests/theme-contract.test.mjs
node --test macos/tests/*.test.mjs
node --test windows/tests/*.test.mjs
node macos/scripts/injector.mjs --check-payload >/dev/null
Expand Down
18 changes: 16 additions & 2 deletions docs/platforms.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,10 +51,14 @@ Windows 启动、失败回滚与恢复重开均从已注册的 `OpenAI.Codex`

## 自适应图像主题契约

同一套主题配置在两个渲染器中使用相同的外观约定。只需要提供一张背景图,注入层就会在本机分析图像并生成协调的颜色、焦点和页面布局不会上传图片,也不依赖外部 AI/API。
同一套主题配置在两个渲染器中使用相同的外观约定。可移植核心由机器可读的 [Theme v1 JSON Schema](../schemas/theme-v1.schema.json) 定义;只需要提供一张背景图,注入层就会在本机分析图像并生成协调的颜色、焦点和页面布局不会上传图片,也不依赖外部 AI/API。

```json
{
"schemaVersion": 1,
"id": "portable-rose",
"name": "跨平台玫瑰主题",
"image": "background.webp",
"appearance": "auto",
"art": {
"focusX": 0.72,
Expand All @@ -65,13 +69,23 @@ Windows 启动、失败回滚与恢复重开均从已注册的 `OpenAI.Codex`
}
```

- `schemaVersion`:新建或重新保存的主题必须写数值 `1`。加载器仅把“完全没有该属性”的旧主题作为 v1 兼容读取;显式的 `null`、字符串 `"1"` 和未来版本都会被拒绝。
- `image`:与 `theme.json` 位于同一目录的单个 PNG、JPEG 或 WebP 文件名,不接受子目录、绝对路径、路径逃逸或 Windows 保留设备名(如 `CON.png`)。
- `id` / `name`:可选的单行文本,省略时分别使用 `custom` 与 `Codex Dream Skin`。
- `appearance`:`auto | light | dark`。`auto` 跟随 Codex/ChatGPT 与系统外观;`light` / `dark` 为显式覆盖。图像亮度只参与配色和构图,不会反向覆盖用户选择的外观。
- `art.focusX` / `art.focusY`:`0..1` 的归一化焦点坐标(左/上为 `0`,右/下为 `1`)。用于控制背景定位,超出范围的值会被拒绝或限制
- `art.focusX` / `art.focusY`:`0..1` 的归一化焦点坐标(左/上为 `0`,右/下为 `1`)。省略或写 `null` 表示自动分析;数字字符串与越界值会被拒绝
- `art.safeArea`:`auto | left | right | center | none`。`auto` 根据左右信息量推断适合放置原生首页内容的一侧;其余值显式指定安全区,`none` 表示不保留安全区。
- `art.taskMode`:`auto | ambient | banner | off`。`auto` 对超宽图使用横幅/纵向渐隐,对普通比例图使用低噪环境背景;`off` 在任务页关闭背景图。

显式的 `appearance` 优先于 Codex/ChatGPT 外观;焦点、安全区和任务模式的显式值优先于图像分析。首页保留更完整的主视觉和原生控件,任务页默认降低背景干扰以保证代码、消息和输入框可读。

### 扩展与版本策略

- v1 会严格校验已知核心字段,同时接受并忽略未知字段。这样旧加载器可以安全读取带有平台扩展或社区元数据的主题。
- macOS 当前识别 `colors`、品牌/界面文案和 promo 字段;Windows 当前识别 `palette.accent`。另一平台会忽略这些扩展,不承诺得到相同的视觉结果。
- 只有在旧实现忽略后仍然安全的可选字段,才可以继续加入 v1。新增必填字段、改变已有字段含义或移除字段时,应发布新的核心版本;不支持的未来版本会直接拒绝,而不会按 v1 猜测。
- 两端测试入口共同读取 `tests/fixtures/theme-contract/cases.json`,并比较规范化后的可移植核心。新增核心字段时,应在同一次修改中同步 Schema、共享夹具、两个加载器和本文档。

### 平台差异

- macOS 的选图脚本会把这些字段写入主题库,可通过 `--appearance`、`--focus-x`、`--focus-y`、`--safe-area`、`--task-mode` 设置。
Expand Down
1 change: 1 addition & 0 deletions macos/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

### 新增

- 新增机器可读的 Theme v1 跨平台核心规范与共享兼容性夹具;macOS、Windows 现在用同一组有效/无效主题验证版本、图片文件名、外观和构图字段,并检查所有内置主题与预设。
- 自适应图像主题引擎:通过本地 Canvas 分析亮度、主色、视觉焦点、左右安全区和图像比例,为任意背景图生成协调的浅色/深色外观;图片不会上传到外部服务。
- 主题新增 `appearance: auto | light | dark` 与 `art.focusX/focusY`(`0..1`)、`art.safeArea: auto | left | right | center | none`、`art.taskMode: auto | ambient | banner | off` 配置;显式值优先于自动分析。
- 首页与任务页按图像比例采用不同呈现:超宽图在任务页使用横幅和纵向渐隐,普通比例图使用低噪环境背景,也可用 `taskMode=off` 关闭任务页图像。
Expand Down
20 changes: 19 additions & 1 deletion macos/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,10 +130,16 @@ The pixels stay in the Codex renderer; there is no upload or external API call.
If Canvas analysis is unavailable, the theme falls back to a safe default and
the detected Codex shell/OS appearance.

Theme metadata is optional. The defaults are deliberately adaptive:
New themes follow the machine-readable
[`theme-v1.schema.json`](https://github.com/Fei-Away/Codex-Dream-Skin/blob/main/schemas/theme-v1.schema.json)
portable contract.
Only `schemaVersion` and `image` are required; the remaining metadata uses
adaptive defaults:

```json
{
"schemaVersion": 1,
"image": "background.webp",
"appearance": "auto",
"art": {
"focusX": 0.72,
Expand All @@ -144,6 +150,12 @@ Theme metadata is optional. The defaults are deliberately adaptive:
}
```

- `schemaVersion`: new or rewritten themes use the JSON number `1`. A
descriptor with no version property is read as legacy v1 for migration;
explicit `null`, `"1"`, and unsupported future versions are rejected.
- `image`: a PNG, JPEG, or WebP filename beside `theme.json`. Absolute paths,
subdirectories, traversal, and Windows reserved device names such as
`CON.png` are rejected so the pack remains portable.
- `appearance`: `auto`, `light`, or `dark`. `auto` follows the native
Codex/ChatGPT or OS appearance; an explicit value wins. Image luminance
still informs palette and composition, but never overrides the user's UI mode.
Expand All @@ -163,6 +175,12 @@ the same priority over automatic inference. The home route remains expressive;
task routes keep native content, cards, composer, and code readable above the
image layer.

The v1 portable core accepts unknown fields so platform extensions and
community metadata remain forward-compatible. macOS currently owns `colors`,
brand/interface copy, and promo fields; Windows owns `palette`. An extension
unknown to this platform is ignored, while every recognized core field remains
strictly validated.

CLI example:

```bash
Expand Down
74 changes: 58 additions & 16 deletions macos/scripts/injector.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,13 @@ const SKIN_VERSION = "1.2.0";
const LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "[::1]"]);
const CDP_ID_PATTERN = /^[A-Za-z0-9._-]{1,200}$/;
const MAX_ART_BYTES = 16 * 1024 * 1024;
const PORTABLE_IMAGE_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".webp"]);
const PORTABLE_RESERVED_IMAGE_NAME = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])\./iu;
const PORTABLE_THEME_CHOICES = {
appearance: new Set(["auto", "light", "dark"]),
safeArea: new Set(["auto", "left", "right", "center", "none"]),
taskMode: new Set(["auto", "ambient", "banner", "off"]),
};
let staticPayloadAssets = null;

function parseArgs(argv) {
Expand Down Expand Up @@ -300,13 +307,35 @@ async function loadTheme(themeDir) {
throw error;
}
const raw = JSON.parse(config);
if (raw.schemaVersion !== 1 || typeof raw.image !== "string" || !raw.image) {
throw new Error(`${configPath} has an unsupported schema or image field`);
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
throw new Error(`${configPath} root must be an object`);
}
if (/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/u.test(raw.image)) {
const schemaVersion = Object.hasOwn(raw, "schemaVersion") ? raw.schemaVersion : 1;
if (schemaVersion !== 1) {
throw new Error(`${configPath} has an unsupported schemaVersion field`);
}
if (
typeof raw.image !== "string"
|| !raw.image
|| Array.from(raw.image).length > 240
|| /[<>:"/\\|?*\u0000-\u001f\u007f-\u009f\u2028\u2029]/u.test(raw.image)
|| PORTABLE_RESERVED_IMAGE_NAME.test(raw.image)
|| !PORTABLE_IMAGE_EXTENSIONS.has(path.extname(raw.image).toLowerCase())
) {
throw new Error(`${configPath} has an invalid image field`);
}
if (path.basename(raw.image) !== raw.image) throw new Error("Theme image must stay inside its theme directory");
const portableText = (value, fallback, max, name) => {
if (value === undefined) return fallback;
if (
typeof value !== "string"
|| Array.from(value).length > max
|| /[\u0000-\u001f\u007f-\u009f\u2028\u2029]/u.test(value)
|| !value.trim()
) {
throw new Error(`${configPath} has an invalid ${name} field`);
}
return value.trim();
};
const text = (value, fallback, max, name) => {
if (value === undefined) return fallback;
if (typeof value !== "string" || /[\u0000-\u001f\u007f-\u009f\u2028\u2029]/u.test(value)) {
Expand All @@ -323,13 +352,14 @@ async function loadTheme(themeDir) {
};
const choice = (value, name, choices) => {
if (value === undefined) return undefined;
if (typeof value !== "string" || !choices.includes(value)) {
if (typeof value !== "string" || !choices.has(value)) {
throw new Error(`${configPath} has an invalid ${name} field`);
}
return value;
};
const unit = (value, name) => {
if (value === undefined) return undefined;
if (value === null) return null;
if (typeof value !== "number" || !Number.isFinite(value) || value < 0 || value > 1) {
throw new Error(`${configPath} has an invalid ${name} field`);
}
Expand All @@ -341,21 +371,21 @@ async function loadTheme(themeDir) {
"background", "panel", "panelAlt", "accent", "accentAlt", "secondary",
"highlight", "text", "muted", "line",
];
const appearance = choice(raw.appearance, "appearance", ["auto", "light", "dark"]);
const appearance = choice(raw.appearance, "appearance", PORTABLE_THEME_CHOICES.appearance);
if (raw.art !== undefined && (!raw.art || typeof raw.art !== "object" || Array.isArray(raw.art))) {
throw new Error(`${configPath} has an invalid art field`);
}
const rawArt = raw.art || {};
const art = {
focusX: unit(rawArt.focusX, "art.focusX"),
focusY: unit(rawArt.focusY, "art.focusY"),
safeArea: choice(rawArt.safeArea, "art.safeArea", ["auto", "left", "right", "center", "none"]),
taskMode: choice(rawArt.taskMode, "art.taskMode", ["auto", "ambient", "banner", "off"]),
safeArea: choice(rawArt.safeArea, "art.safeArea", PORTABLE_THEME_CHOICES.safeArea),
taskMode: choice(rawArt.taskMode, "art.taskMode", PORTABLE_THEME_CHOICES.taskMode),
};
const theme = {
schemaVersion: 1,
id: text(raw.id, "custom", 80, "id"),
name: text(raw.name, "Codex Dream Skin", 80, "name"),
schemaVersion,
id: portableText(raw.id, "custom", 80, "id"),
name: portableText(raw.name, "Codex Dream Skin", 80, "name"),
brandSubtitle: text(raw.brandSubtitle, "CODEX DREAM SKIN", 80, "brandSubtitle"),
tagline: text(raw.tagline, "Make something wonderful.", 160, "tagline"),
projectPrefix: text(raw.projectPrefix, "选择项目 · ", 80, "projectPrefix"),
Expand All @@ -382,6 +412,19 @@ async function loadTheme(themeDir) {
if (Object.values(art).some((value) => value !== undefined)) {
theme.art = Object.fromEntries(Object.entries(art).filter(([, value]) => value !== undefined));
}
const portableTheme = {
schemaVersion,
id: theme.id,
name: theme.name,
image: theme.image,
appearance: appearance ?? "auto",
art: {
focusX: art.focusX ?? null,
focusY: art.focusY ?? null,
safeArea: art.safeArea ?? "auto",
taskMode: art.taskMode ?? "auto",
},
};
const requestedImagePath = path.join(assetsRoot, theme.image);
let imagePath;
try {
Expand All @@ -393,9 +436,6 @@ async function loadTheme(themeDir) {
assertContainedPath(assetsRoot, imagePath, "Theme image");
const imageStat = await fs.stat(imagePath);
const extension = path.extname(theme.image).toLowerCase();
if (![".png", ".jpg", ".jpeg", ".webp"].includes(extension)) {
throw new Error(`Unsupported theme image format: ${extension || "missing"}`);
}
let imageHandle;
try {
imageHandle = await fs.open(imagePath, fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0));
Expand All @@ -419,7 +459,7 @@ async function loadTheme(themeDir) {
if (art.length < 1 || art.length > MAX_ART_BYTES) {
throw new Error(`Theme image must be a non-empty file no larger than ${MAX_ART_BYTES} bytes`);
}
return { art, assetsRoot, extension, imagePath, theme };
return { art, assetsRoot, extension, imagePath, portableTheme, theme };
} finally {
await imageHandle.close();
}
Expand Down Expand Up @@ -451,7 +491,7 @@ async function loadPayload(themeDir) {
loadTheme(themeDir),
]);
const { css, template } = staticAssets;
const { art, extension, theme } = loaded;
const { art, extension, portableTheme, theme } = loaded;
const styleRevision = createHash("sha256").update(css).digest("hex").slice(0, 20);
const artMetadata = readImageMetadata(art, extension);
if (!artMetadata) {
Expand Down Expand Up @@ -479,6 +519,7 @@ async function loadPayload(themeDir) {
return {
imageBytes: art.length,
payload,
portableTheme,
revision,
theme,
timings: {
Expand Down Expand Up @@ -880,6 +921,7 @@ if (path.resolve(process.argv[1] || "") === path.resolve(scriptPath)) {
imageBytes: loaded.imageBytes,
payloadBytes: Buffer.byteLength(loaded.payload),
artMetadata: loaded.theme.artMetadata ?? null,
portableTheme: loaded.portableTheme,
timings: loaded.timings,
}, null, 2));
} else if (options.mode === "watch") await runWatch(options);
Expand Down
22 changes: 16 additions & 6 deletions macos/scripts/stage-theme.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ if (!sourceDirArg || !stageDirArg) {
const MAX_CONFIG_BYTES = 1024 * 1024;
const MAX_IMAGE_BYTES = 16 * 1024 * 1024;
const OPEN_FLAGS = fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0);
const PORTABLE_IMAGE_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".webp"]);
const PORTABLE_RESERVED_IMAGE_NAME = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])\./iu;

function assertContained(rootPath, candidatePath, label) {
const relative = path.relative(rootPath, candidatePath);
Expand Down Expand Up @@ -81,18 +83,26 @@ async function main() {
const configPath = path.join(sourceRoot, "theme.json");
const config = await readStableFile(configPath, "Theme config", MAX_CONFIG_BYTES);
const theme = decodeJson(config.bytes, "Theme config");
if (theme?.schemaVersion !== 1 || typeof theme.image !== "string" || !theme.image) {
throw new Error("Theme config has an unsupported schema or image field");
if (!theme || typeof theme !== "object" || Array.isArray(theme)) {
throw new Error("Theme config root must be an object");
}
if (path.basename(theme.image) !== theme.image) {
const schemaVersion = Object.hasOwn(theme, "schemaVersion") ? theme.schemaVersion : 1;
if (schemaVersion !== 1) {
throw new Error("Theme config has an unsupported schemaVersion field");
}
if (
typeof theme.image !== "string"
|| !theme.image
|| Array.from(theme.image).length > 240
|| /[<>:"/\\|?*\u0000-\u001f\u007f-\u009f\u2028\u2029]/u.test(theme.image)
|| PORTABLE_RESERVED_IMAGE_NAME.test(theme.image)
|| !PORTABLE_IMAGE_EXTENSIONS.has(path.extname(theme.image).toLowerCase())
) {
throw new Error("Theme image must stay inside its theme directory");
}
if (theme.image === "theme.json") {
throw new Error("Theme image must not replace theme.json");
}
if (/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/u.test(theme.image)) {
throw new Error("Theme image contains control characters");
}

const imagePath = path.resolve(sourceRoot, theme.image);
assertContained(sourceRoot, imagePath, "Theme image");
Expand Down
4 changes: 4 additions & 0 deletions macos/tests/run-tests.sh
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@ if ! /usr/bin/grep -F -q 'flag: "wx"' "$ROOT/scripts/write-theme.mjs"; then
fi

"$NODE" "$ROOT/scripts/injector.mjs" --check-payload >/dev/null
PORTABLE_CONTRACT_TEST="$ROOT/../tests/theme-contract.test.mjs"
if [ -f "$PORTABLE_CONTRACT_TEST" ]; then
"$NODE" "$PORTABLE_CONTRACT_TEST"
fi
"$NODE" "$ROOT/tests/image-metadata.test.mjs"
"$NODE" "$ROOT/tests/injector-bootstrap.test.mjs"
"$NODE" "$ROOT/tests/renderer-inject.test.mjs"
Expand Down
22 changes: 22 additions & 0 deletions macos/tests/theme-stage.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,28 @@ try {
await fs.mkdir(traversalStage);
await assert.rejects(runStage(traversal, traversalStage), /inside its theme directory/);

const legacy = path.join(tempRoot, "legacy");
const legacyStage = path.join(tempRoot, "legacy-stage");
await fs.mkdir(legacy);
await fs.mkdir(legacyStage);
await fs.copyFile(fixtureAsset, path.join(legacy, "background.png"));
await fs.writeFile(
path.join(legacy, "theme.json"),
`${JSON.stringify({ id: "legacy", name: "Legacy", image: "background.png" })}\n`,
);
assert.equal(await runStage(legacy, legacyStage), "background.png");

const future = path.join(tempRoot, "future");
const futureStage = path.join(tempRoot, "future-stage");
await fs.mkdir(future);
await fs.mkdir(futureStage);
await fs.copyFile(fixtureAsset, path.join(future, "background.png"));
await fs.writeFile(
path.join(future, "theme.json"),
`${JSON.stringify({ schemaVersion: 2, id: "future", image: "background.png" })}\n`,
);
await assert.rejects(runStage(future, futureStage), /unsupported schemaVersion/);

const symlink = path.join(tempRoot, "symlink");
await fs.mkdir(symlink);
await fs.symlink(outside, path.join(symlink, "background.png"));
Expand Down
Loading
Loading