mirror of
https://github.com/yynxxxxx/Codex-X.git
synced 2026-10-02 03:24:52 +08:00
feat: ship cross-platform skin center
This commit is contained in:
@@ -216,6 +216,31 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Stage pinned Windows skin runtime
|
||||
if: matrix.platform == 'windows-latest'
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$version = '22.23.1'
|
||||
$expectedSha256 = '7df0bc9375723f4a86b3aa1b7cc73342423d9677a8df4538aca31a049e309c29'
|
||||
$archive = Join-Path $env:RUNNER_TEMP "node-v$version-win-x64.zip"
|
||||
$extractRoot = Join-Path $env:RUNNER_TEMP "codex-x-node-$version"
|
||||
$targetRoot = 'apps/desktop/src-tauri/runtime/windows/node'
|
||||
Invoke-WebRequest -Uri "https://nodejs.org/dist/v$version/node-v$version-win-x64.zip" -OutFile $archive
|
||||
$actualSha256 = (Get-FileHash -LiteralPath $archive -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
if ($actualSha256 -cne $expectedSha256) {
|
||||
throw "Node.js archive checksum mismatch: $actualSha256"
|
||||
}
|
||||
Remove-Item -LiteralPath $extractRoot -Recurse -Force -ErrorAction SilentlyContinue
|
||||
Expand-Archive -LiteralPath $archive -DestinationPath $extractRoot
|
||||
$sourceRoot = Join-Path $extractRoot "node-v$version-win-x64"
|
||||
New-Item -ItemType Directory -Path $targetRoot -Force | Out-Null
|
||||
Copy-Item -LiteralPath (Join-Path $sourceRoot 'node.exe') -Destination $targetRoot -Force
|
||||
Copy-Item -LiteralPath (Join-Path $sourceRoot 'LICENSE') -Destination $targetRoot -Force
|
||||
if (-not (Test-Path -LiteralPath (Join-Path $targetRoot 'node.exe') -PathType Leaf)) {
|
||||
throw 'Pinned Windows Node.js runtime was not staged.'
|
||||
}
|
||||
|
||||
- name: Test Windows filesystem compatibility
|
||||
if: matrix.platform == 'windows-latest'
|
||||
run: cargo test --manifest-path apps/desktop/src-tauri/Cargo.toml --lib --locked
|
||||
@@ -350,6 +375,9 @@ jobs:
|
||||
rm -rf "$portable_root"
|
||||
mkdir -p "$portable_dir"
|
||||
cp apps/desktop/src-tauri/target/release/codex-x.exe "$portable_dir/Codex-X.exe"
|
||||
mkdir -p "$portable_dir/skin-runtime/node"
|
||||
cp apps/desktop/src-tauri/runtime/windows/node/node.exe "$portable_dir/skin-runtime/node/node.exe"
|
||||
cp apps/desktop/src-tauri/runtime/windows/node/LICENSE "$portable_dir/skin-runtime/node/LICENSE"
|
||||
touch "$portable_dir/Codex-X.portable"
|
||||
cat > "$portable_dir/README-portable.txt" <<'EOF'
|
||||
Codex-X Windows Portable
|
||||
@@ -357,6 +385,7 @@ jobs:
|
||||
Double click Codex-X.exe to run. No MSI installation is required.
|
||||
The portable edition uses manual downloads instead of in-app installation.
|
||||
Keep Codex-X.portable next to Codex-X.exe so the app can identify this edition.
|
||||
Keep the skin-runtime directory beside Codex-X.exe; it contains the verified Windows skin runtime.
|
||||
Microsoft Edge WebView2 Runtime is required; Windows 10/11 usually already includes it.
|
||||
EOF
|
||||
powershell -NoProfile -Command "Compress-Archive -Path '${portable_dir}/*' -DestinationPath '${archive}' -Force"
|
||||
@@ -382,6 +411,28 @@ jobs:
|
||||
with:
|
||||
ref: ${{ needs.prepare.outputs.tag }}
|
||||
|
||||
- name: Match release asset labels to filenames
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
RELEASE_ID: ${{ needs.prepare.outputs.release_id }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
assets_endpoint="/repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}/assets?per_page=100"
|
||||
gh api "$assets_endpoint" \
|
||||
--jq '.[] | select(.label != .name) | [.id, .name] | @tsv' \
|
||||
| while IFS=$'\t' read -r asset_id asset_name; do
|
||||
gh api --method PATCH \
|
||||
"/repos/${GITHUB_REPOSITORY}/releases/assets/${asset_id}" \
|
||||
-f "label=${asset_name}" >/dev/null
|
||||
done
|
||||
|
||||
mismatched_labels=$(gh api "$assets_endpoint" \
|
||||
--jq '[.[] | select(.label != .name)] | length')
|
||||
if [ "$mismatched_labels" -ne 0 ]; then
|
||||
echo "Release asset labels do not match their filenames" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Download and validate updater manifest
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
|
||||
@@ -2,6 +2,36 @@
|
||||
|
||||
All notable changes to Codex-X will be documented here.
|
||||
|
||||
## [v0.3.1] - 2026-07-24
|
||||
|
||||
### 更新
|
||||
|
||||
- 皮肤中心重新设计主题画廊:直接展示真实壁纸和侧边栏、会话区、输入框、按钮等整套界面效果,单个主题不再拉伸占满整行,并移除端点、目录、`theme.json` 路径和主题包结构等开发者信息。
|
||||
- 皮肤中心新增可管理的分类筛选并进一步压缩主题卡片密度;导出主题时改用系统“另存为”窗口选择目录和文件名。
|
||||
- 支持直接从图片创建自适应主题,并可编辑主题名称、简介和界面透明度;内置主题与图片主题共用相同的编辑与预览规格。
|
||||
- 主题画廊根据当前分类数量自适应 1 至 4 列,少量主题保持紧凑,多个主题充分利用可用宽度。
|
||||
- 实机换肤扩展到 Windows:动态校验官方 `OpenAI.Codex` Store 包,安全启动仅绑定回环地址的 CDP 会话,并在 Windows 发布包中附带固定校验的 Node.js 22 运行时;不修改 `WindowsApps`、`app.asar` 或权限。
|
||||
- 皮肤中心只保留“关闭皮肤”入口,移除与其重复的“恢复官方外观”按钮;底层完整恢复能力继续保留用于异常恢复。
|
||||
|
||||
### 修复 Bug
|
||||
|
||||
- 修复 macOS 皮肤已经注入后 Codex-X 仍长期显示“应用中”、重开后无法识别当前主题的问题;单次注入现在会明确退出,外部命令带有硬超时,并可从经过验证的 Codex 主进程恢复遗失的本机调试端口。
|
||||
- 修复皮肤尚未运行时错误显示“重新应用”、刷新缺少明确反馈,以及提示词或皮肤卡片过多时页面无法继续向下滚动的问题。
|
||||
- 优化普通横图在新任务首页超宽横幅中的自动焦点,避免人物头部被居中 `cover` 裁掉;主题包仍可通过 `art.focusY` 显式覆盖自动位置。
|
||||
- 修复切换到中转供应商时覆盖官方 `auth.json` 的问题,官方登录凭据与供应商凭据现在分别保存和恢复。
|
||||
- 改进 Codex Desktop 检测,兼容更多安装路径、包标识和版本输出格式,减少部分用户错误显示“未检测到 Codex”的情况。
|
||||
- 修复启用皮肤后新会话输入框消失、输入框出现会话内容穿透,以及图片主题预览错误使用默认背景的问题。
|
||||
- 修复浅色主题的固定白色遮罩洗淡壁纸颜色的问题;壁纸保持原图色彩,卡片和项目选择区域跟随透明度,同时为输入框保留可读性下限。
|
||||
|
||||
### 安全
|
||||
|
||||
- 换肤运行时只连接经过签名、进程归属和页面标记验证的官方 Codex,并将 CDP 限制在 `127.0.0.1`;停止注入器前会核对 PID、启动时间、Node 路径、脚本路径和端口。
|
||||
- 主题导入增加 ZIP 越界、符号链接、重复文件、文件数量、图片大小和 Manifest 字段校验;运行时使用稳定快照,避免主题切换时配置与图片不一致。
|
||||
|
||||
### 开发
|
||||
|
||||
- 固定复用 Codex Dream Skin Studio `5fd8af532efbaa87d2d0092297fd2d45cd56574e` 的 MIT 注入实现,并加入 Rust 与 Node 专项回归测试;未包含上游声明为非 MIT 授权的真人主题素材。
|
||||
|
||||
## [v0.3.0] - 2026-07-16
|
||||
|
||||
### 更新
|
||||
|
||||
+15
-2
@@ -10,7 +10,7 @@
|
||||
|
||||
**Codex Prompts · API / Providers · Sessions · Skills / MCP in One Place**
|
||||
|
||||
A cross-platform desktop tool for **OpenAI Codex Desktop / Codex CLI**. Manage prompt templates, switch third-party APIs, organize / repair / permanently delete local sessions, manage Skills / MCP, and inspect TOML and login credentials without repeatedly editing configuration files by hand.
|
||||
A cross-platform desktop tool for **OpenAI Codex Desktop / Codex CLI**. Manage prompt templates, switch third-party APIs, organize / repair / permanently delete local sessions, manage Skills / MCP, apply macOS / Windows skins, and inspect TOML and login credentials without repeatedly editing configuration files by hand.
|
||||
|
||||
<p>
|
||||
<img src="https://img.shields.io/github/v/release/yynxxxxx/Codex-X?label=version&color=blue" alt="version" />
|
||||
@@ -131,6 +131,10 @@ You can use it to:
|
||||
<td align="center">📝 <b>Manage config and login</b></td>
|
||||
<td align="left">Inspect the active Codex <code>config.toml</code> and <code>auth.json</code> in one place, with a clear distinction between official login credentials and third-party API Keys. Important writes are backed up automatically and can be restored when needed.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">🎨 <b>Apply macOS / Windows skins</b></td>
|
||||
<td align="left">Use the bundled Shiina Mashiro art-studio theme with real-wallpaper, full-interface previews. Import or export packs, hot-switch themes, and turn the skin off directly. The runtime is built into Codex-X; the separate Codex Dream Skin engine is not required.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">📦 <b>Use it across platforms</b></td>
|
||||
<td align="left">Available for macOS Apple Silicon / Intel, Windows MSI / portable, and Linux packages. Installed editions can download, verify, and install updates in the app; the portable edition keeps manual downloads.</td>
|
||||
@@ -369,7 +373,16 @@ Build desktop bundles:
|
||||
pnpm --dir apps/desktop tauri build
|
||||
```
|
||||
|
||||
## macOS Installation Note
|
||||
## Desktop Installation Notes
|
||||
|
||||
### Skin Center security boundary
|
||||
|
||||
- Live skin application supports macOS and Windows. Linux can still manage, import, and export packs but is never reported as applied.
|
||||
- macOS uses only the signed Node.js bundled with the official Codex app and validates its bundle and process identity. Windows dynamically validates the current user's registered `OpenAI.Codex` Store package and uses the pinned Node.js 22 runtime shipped with Codex-X releases.
|
||||
- CDP binds only to `127.0.0.1`; Codex-X does not modify the official `.app`, `WindowsApps`, `app.asar`, code signature, directory permissions, or `config.toml`.
|
||||
- First application usually asks to restart Codex. Turn off skin removes the live interface skin immediately, and any theme can be applied again later.
|
||||
- Themes adapt cropping from image dimensions and content. Subject-focused artwork can use `art.focusX`, `art.focusY` (`0` to `1`), and `art.safeArea` in `theme.json` for precise framing without generating duplicate images for the same theme.
|
||||
- Injection is based on the MIT-licensed [Fei-Away/Codex-Dream-Skin](https://github.com/Fei-Away/Codex-Dream-Skin) source. The pinned source and license notice are in `apps/desktop/src-tauri/resources/skin-runtime/NOTICE.md`.
|
||||
|
||||
If you see “app is damaged” when opening an unsigned / unnotarized DMG, this is normal macOS Gatekeeper behavior.
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
**Codex 可视化提示词注入 · Provider · 会话 · Skills / MCP 管理工具**
|
||||
|
||||
一款面向 **OpenAI Codex 桌面端 / Codex CLI** 的跨平台桌面工具。把提示词模板、自定义 Prompt、第三方 API 供应商、会话同步、Skills / MCP 和 TOML 配置都放进可视化界面里,不用反复手改文件。
|
||||
一款面向 **OpenAI Codex 桌面端 / Codex CLI** 的跨平台桌面工具。把提示词模板、自定义 Prompt、第三方 API 供应商、会话同步、Skills / MCP、macOS / Windows 皮肤和 TOML 配置都放进可视化界面里,不用反复手改文件。
|
||||
|
||||
<p>
|
||||
<img src="https://img.shields.io/github/v/release/yynxxxxx/Codex-X?label=version&color=blue" alt="version" />
|
||||
@@ -111,6 +111,10 @@
|
||||
<td align="center"><b>配置与登录</b></td>
|
||||
<td align="left">集中查看 Codex 当前使用的 <code>config.toml</code> 与 <code>auth.json</code>,区分官方登录态和第三方 API Key;重要写入前自动备份。</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><b>macOS / Windows 皮肤中心</b></td>
|
||||
<td align="left">内置“椎名真白·樱花画室”主题,以真实壁纸和完整界面缩略图展示主题效果,支持导入 / 导出、热切换和关闭皮肤;换肤运行时已经内置在 Codex-X 中,无需另装 Codex Dream Skin 引擎。</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><b>跨平台使用</b></td>
|
||||
<td align="left">提供 macOS Apple Silicon / Intel、Windows MSI / 便携版和 Linux 安装包;安装版可在应用内直接下载、校验并安装更新,便携版继续使用手动下载。</td>
|
||||
@@ -358,7 +362,16 @@ pnpm dev
|
||||
pnpm --dir apps/desktop tauri build
|
||||
```
|
||||
|
||||
## macOS 安装说明
|
||||
## 桌面端安装说明
|
||||
|
||||
### 皮肤中心安全边界
|
||||
|
||||
- 实机换肤支持 macOS 和 Windows;Linux 仍可管理、导入和导出主题包,但不会显示为已应用。
|
||||
- macOS 只使用官方 Codex 内置且签名一致的 Node.js,并校验 Bundle ID、代码签名和进程归属;Windows 动态校验当前用户注册的 `OpenAI.Codex` Store 包,并使用发布包内固定校验的 Node.js 22 运行时。
|
||||
- CDP 仅绑定 `127.0.0.1`,不修改官方 `.app`、`WindowsApps`、`app.asar`、代码签名、目录权限或 `config.toml`。
|
||||
- 首次应用通常需要确认重启 Codex;“关闭皮肤”会立即移除当前界面皮肤,之后可随时重新应用主题。
|
||||
- 主题默认会根据图片尺寸和内容自适应裁切;人物或主体构图可在 `theme.json` 使用 `art.focusX`、`art.focusY`(`0` 到 `1`)和 `art.safeArea` 精确控制,无需为同一主题重复生成图片。
|
||||
- 注入实现基于 [Fei-Away/Codex-Dream-Skin](https://github.com/Fei-Away/Codex-Dream-Skin) 的 MIT 源码,固定来源和许可见 `apps/desktop/src-tauri/resources/skin-runtime/NOTICE.md`。
|
||||
|
||||
如果你在未签名 / 未公证的 DMG 中看到“软件已损坏”提示,这是 macOS Gatekeeper 的正常行为。
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "codex-x",
|
||||
"version": "0.3.0",
|
||||
"version": "0.3.1",
|
||||
"private": true,
|
||||
"description": "Codex Switch & Instruct desktop manager",
|
||||
"type": "module",
|
||||
@@ -10,10 +10,12 @@
|
||||
"tauri": "tauri",
|
||||
"dev:renderer": "vite --host 127.0.0.1 --port 1420",
|
||||
"build:renderer": "vite build",
|
||||
"typecheck": "tsc --noEmit"
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test:skin-runtime": "node src-tauri/resources/skin-runtime/tests/run-tests.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.8.0",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.2",
|
||||
"@tauri-apps/plugin-process": "^2.3.1",
|
||||
"@tauri-apps/plugin-updater": "^2.10.1",
|
||||
"lucide-react": "^0.542.0",
|
||||
|
||||
Generated
+75
-1
@@ -346,7 +346,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "codex-x"
|
||||
version = "0.3.0"
|
||||
version = "0.3.1"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dirs 5.0.1",
|
||||
@@ -360,6 +360,7 @@ dependencies = [
|
||||
"sha2",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-dialog",
|
||||
"tauri-plugin-process",
|
||||
"tauri-plugin-updater",
|
||||
"thiserror 2.0.18",
|
||||
@@ -1382,6 +1383,12 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "http-range"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "21dec9db110f5f872ed9699c3ecf50cf16f423502706ba5c72462e28d3157573"
|
||||
|
||||
[[package]]
|
||||
name = "httparse"
|
||||
version = "1.10.1"
|
||||
@@ -2655,6 +2662,30 @@ dependencies = [
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rfd"
|
||||
version = "0.16.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672"
|
||||
dependencies = [
|
||||
"block2",
|
||||
"dispatch2",
|
||||
"glib-sys",
|
||||
"gobject-sys",
|
||||
"gtk-sys",
|
||||
"js-sys",
|
||||
"log",
|
||||
"objc2",
|
||||
"objc2-app-kit",
|
||||
"objc2-core-foundation",
|
||||
"objc2-foundation",
|
||||
"raw-window-handle",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ring"
|
||||
version = "0.17.14"
|
||||
@@ -3404,6 +3435,7 @@ dependencies = [
|
||||
"gtk",
|
||||
"heck 0.5.0",
|
||||
"http",
|
||||
"http-range",
|
||||
"jni 0.21.1",
|
||||
"libc",
|
||||
"log",
|
||||
@@ -3516,6 +3548,48 @@ dependencies = [
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-dialog"
|
||||
version = "2.7.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b2d3c1dbe38037e7f590cdf2492594d5ceebe031e7bc7e827509b22a999d2940"
|
||||
dependencies = [
|
||||
"log",
|
||||
"raw-window-handle",
|
||||
"rfd",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"tauri-plugin-fs",
|
||||
"thiserror 2.0.18",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-fs"
|
||||
version = "2.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7ecc274121aca0c036a2b42d1cbe83d368d348f54e0bb8a735c2b1548e8f371"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"dunce",
|
||||
"glob",
|
||||
"log",
|
||||
"objc2-foundation",
|
||||
"percent-encoding",
|
||||
"schemars 0.8.22",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_repr",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"tauri-utils",
|
||||
"thiserror 2.0.18",
|
||||
"toml 1.1.2+spec-1.1.0",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-process"
|
||||
version = "2.3.1"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "codex-x"
|
||||
version = "0.3.0"
|
||||
version = "0.3.1"
|
||||
description = "Codex Switch & Instruct desktop manager"
|
||||
authors = ["yynxxxxx"]
|
||||
license = "MIT"
|
||||
@@ -11,11 +11,14 @@ rust-version = "1.89.0"
|
||||
name = "codexx_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[features]
|
||||
windows-runtime-check = []
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2.4.0", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2.8.2", features = [] }
|
||||
tauri = { version = "2.8.2", features = ["protocol-asset"] }
|
||||
tauri-plugin-process = "2.3.1"
|
||||
tauri-plugin-updater = "2.10.1"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
@@ -37,3 +40,4 @@ rustls = { version = "0.23", default-features = false, features = ["ring"] }
|
||||
sha2 = "0.10"
|
||||
percent-encoding = "2.3"
|
||||
zip = { version = "2.2", default-features = false, features = ["deflate"] }
|
||||
tauri-plugin-dialog = "2"
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"core:window:allow-start-dragging",
|
||||
"dialog:allow-save",
|
||||
"updater:allow-check",
|
||||
"updater:allow-download",
|
||||
"updater:allow-install",
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.7 MiB |
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "shiina-mashiro-blossom",
|
||||
"name": "椎名真白·樱花画室",
|
||||
"brandSubtitle": "椎名真白主题",
|
||||
"tagline": "椎名真白在白樱画室里安静作画。",
|
||||
"projectPrefix": "选择项目 · ",
|
||||
"projectLabel": "◉ 选择项目",
|
||||
"statusText": "椎名真白主题运行中",
|
||||
"quote": "安静创作,也会发光",
|
||||
"image": "background.png",
|
||||
"appearance": "light",
|
||||
"surfaceOpacity": 0.62,
|
||||
"art": {
|
||||
"focusX": 0.28,
|
||||
"focusY": 0.26,
|
||||
"safeArea": "right",
|
||||
"taskMode": "ambient"
|
||||
},
|
||||
"colors": {
|
||||
"background": "#f9f4f7",
|
||||
"panel": "#fffafd",
|
||||
"panelAlt": "#fff1f7",
|
||||
"accent": "#ef7da4",
|
||||
"accentAlt": "#ffadc9",
|
||||
"secondary": "#f5c2d6",
|
||||
"highlight": "#c95f8a",
|
||||
"text": "#2a2026",
|
||||
"muted": "#8d7180",
|
||||
"line": "rgba(239, 125, 164, .26)"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Codex Dream Skin Studio contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,22 @@
|
||||
# Codex Dream Skin Runtime Notice
|
||||
|
||||
The files in this directory are adapted from Codex Dream Skin Studio:
|
||||
|
||||
- Source: https://github.com/Fei-Away/Codex-Dream-Skin
|
||||
- Pinned source commit: `5fd8af532efbaa87d2d0092297fd2d45cd56574e`
|
||||
- Upstream runtime version: `1.2.0`
|
||||
- License: MIT, reproduced in `LICENSE`
|
||||
|
||||
Codex-X embeds these runtime sources and manages them itself. It does not require
|
||||
the separate Codex Dream Skin Studio installer or its `~/.codex` engine directory.
|
||||
The Windows release also bundles Node.js 22.23.1 from nodejs.org, verified against
|
||||
SHA-256 `7df0bc9375723f4a86b3aa1b7cc73342423d9677a8df4538aca31a049e309c29`;
|
||||
its upstream license is distributed beside `node.exe`.
|
||||
|
||||
Only the upstream software source and procedurally generated abstract presets are
|
||||
included. The Arina Hashimoto preset and other assets excluded by the upstream
|
||||
notice are intentionally not distributed with Codex-X.
|
||||
|
||||
Codex Dream Skin Studio and Codex-X are unofficial projects and are not affiliated
|
||||
with, endorsed by, or sponsored by OpenAI. Codex and related trademarks belong to
|
||||
their respective owners.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,796 @@
|
||||
((cssText, artDataUrl, themeConfig) => {
|
||||
const STATE_KEY = "__CODEX_DREAM_SKIN_STATE__";
|
||||
const DISABLED_KEY = "__CODEX_DREAM_SKIN_DISABLED__";
|
||||
const STYLE_ID = "codex-dream-skin-style";
|
||||
const CHROME_ID = "codex-dream-skin-chrome";
|
||||
const SHELL_ATTR = "data-dream-shell";
|
||||
const ART_ATTRS = [
|
||||
"data-dream-art-wide", "data-dream-art-safe", "data-dream-task-mode",
|
||||
"data-dream-art-safe-area", "data-dream-art-task-mode", "data-dream-art-aspect",
|
||||
"data-dream-art-ready",
|
||||
];
|
||||
const VERSION = __DREAM_SKIN_VERSION_JSON__;
|
||||
const STYLE_REVISION = __DREAM_SKIN_STYLE_REVISION_JSON__;
|
||||
const THEME = themeConfig && typeof themeConfig === "object" ? themeConfig : {};
|
||||
const ART = THEME.art && typeof THEME.art === "object" ? THEME.art : {};
|
||||
const ART_METADATA = THEME.artMetadata && typeof THEME.artMetadata === "object"
|
||||
? THEME.artMetadata : null;
|
||||
const ANALYSIS_CACHE_KEY = "__CODEX_DREAM_SKIN_ANALYSIS_CACHE__";
|
||||
const THEME_VARIABLES = [
|
||||
"--ds-bg", "--ds-panel", "--ds-panel-2", "--ds-green", "--ds-lime",
|
||||
"--ds-cyan", "--ds-purple", "--ds-text", "--ds-muted", "--ds-line",
|
||||
"--ds-bg-rgb", "--ds-panel-rgb", "--ds-panel-2-rgb", "--ds-accent-rgb",
|
||||
"--ds-accent-alt-rgb", "--ds-secondary-rgb", "--ds-highlight-rgb",
|
||||
"--ds-text-rgb", "--ds-muted-rgb", "--ds-line-rgb", "--ds-surface-opacity",
|
||||
"--dream-art-focus-x", "--dream-art-focus-y", "--dream-art-position",
|
||||
"--dream-skin-focus-x", "--dream-skin-focus-y", "--dream-skin-art-position",
|
||||
"--dream-skin-home-art-position",
|
||||
"--dream-skin-name", "--dream-skin-tagline", "--dream-skin-project-prefix",
|
||||
"--dream-skin-project-label",
|
||||
];
|
||||
const installToken = {};
|
||||
const existingAnalysisCache = window[ANALYSIS_CACHE_KEY];
|
||||
const analysisCache = existingAnalysisCache && typeof existingAnalysisCache.get === "function" &&
|
||||
typeof existingAnalysisCache.set === "function" ? existingAnalysisCache : new Map();
|
||||
window[ANALYSIS_CACHE_KEY] = analysisCache;
|
||||
let artAnalysis = typeof THEME.artKey === "string" ? analysisCache.get(THEME.artKey) ?? null : null;
|
||||
let analysisTimer = null;
|
||||
let samplingNativeShell = false;
|
||||
let rootObserver = null;
|
||||
const now = () => typeof performance === "object" && typeof performance.now === "function"
|
||||
? performance.now() : Date.now();
|
||||
const metrics = {
|
||||
ensureCalls: 0,
|
||||
rootPasses: 0,
|
||||
routePasses: 0,
|
||||
layoutReads: 0,
|
||||
attributeWrites: 0,
|
||||
styleWrites: 0,
|
||||
textWrites: 0,
|
||||
analysisRuns: 0,
|
||||
analysisCacheHits: artAnalysis ? 1 : 0,
|
||||
firstEnsureMs: null,
|
||||
analysisMs: null,
|
||||
};
|
||||
window[DISABLED_KEY] = false;
|
||||
|
||||
const previous = window[STATE_KEY];
|
||||
const artUrl = (() => {
|
||||
const comma = artDataUrl.indexOf(",");
|
||||
const mime = /^data:([^;,]+)/.exec(artDataUrl)?.[1] || "image/png";
|
||||
const binary = atob(artDataUrl.slice(comma + 1));
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index);
|
||||
return URL.createObjectURL(new Blob([bytes], { type: mime }));
|
||||
})();
|
||||
|
||||
if (previous?.observer) previous.observer.disconnect();
|
||||
if (previous?.rootObserver) previous.rootObserver.disconnect();
|
||||
if (previous?.resizeObserver) previous.resizeObserver.disconnect();
|
||||
if (previous?.timer) clearInterval(previous.timer);
|
||||
if (previous?.scheduler?.timeout) clearTimeout(previous.scheduler.timeout);
|
||||
if (previous?.scheduler?.frame != null && typeof cancelAnimationFrame === "function") {
|
||||
cancelAnimationFrame(previous.scheduler.frame);
|
||||
}
|
||||
if (previous?.analysisTimer) clearTimeout(previous.analysisTimer);
|
||||
if (previous?.resizeHandler) window.removeEventListener("resize", previous.resizeHandler);
|
||||
if (previous?.mediaHandler && previous?.mediaQuery) {
|
||||
try { previous.mediaQuery.removeEventListener("change", previous.mediaHandler); } catch {}
|
||||
}
|
||||
|
||||
const cssString = (value) => JSON.stringify(String(value ?? ""));
|
||||
|
||||
const setStyleProperty = (root, name, value) => {
|
||||
if (root.style.getPropertyValue(name) !== value) {
|
||||
root.style.setProperty(name, value);
|
||||
metrics.styleWrites += 1;
|
||||
}
|
||||
};
|
||||
|
||||
const setAttribute = (root, name, value) => {
|
||||
const normalized = String(value);
|
||||
if (root.getAttribute(name) !== normalized) {
|
||||
root.setAttribute(name, normalized);
|
||||
metrics.attributeWrites += 1;
|
||||
}
|
||||
};
|
||||
|
||||
const setTextContent = (node, value) => {
|
||||
if (node && node.textContent !== value) {
|
||||
node.textContent = value;
|
||||
metrics.textWrites += 1;
|
||||
}
|
||||
};
|
||||
|
||||
const parseRgb = (value) => {
|
||||
if (!value || value === "transparent") return null;
|
||||
const hex = String(value).trim().match(/^#([0-9a-f]{6})$/i);
|
||||
if (hex) {
|
||||
const number = Number.parseInt(hex[1], 16);
|
||||
return { r: number >> 16, g: (number >> 8) & 255, b: number & 255 };
|
||||
}
|
||||
const m = String(value).match(/rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)/i);
|
||||
if (!m) return null;
|
||||
return { r: Number(m[1]), g: Number(m[2]), b: Number(m[3]) };
|
||||
};
|
||||
|
||||
const clamp = (value, min, max) => Math.min(max, Math.max(min, value));
|
||||
|
||||
const rgbString = (value) => {
|
||||
const rgb = parseRgb(value);
|
||||
return rgb ? `${Math.round(rgb.r)} ${Math.round(rgb.g)} ${Math.round(rgb.b)}` : null;
|
||||
};
|
||||
|
||||
const rgbToHex = ({ r, g, b }) => `#${[r, g, b]
|
||||
.map((value) => clamp(Math.round(value), 0, 255).toString(16).padStart(2, "0"))
|
||||
.join("")}`;
|
||||
|
||||
const rgbToHsl = ({ r, g, b }) => {
|
||||
const values = [r, g, b].map((value) => value / 255);
|
||||
const max = Math.max(...values);
|
||||
const min = Math.min(...values);
|
||||
const lightness = (max + min) / 2;
|
||||
if (max === min) return { h: 0, s: 0, l: lightness };
|
||||
const delta = max - min;
|
||||
const saturation = lightness > 0.5 ? delta / (2 - max - min) : delta / (max + min);
|
||||
let hue;
|
||||
if (max === values[0]) hue = (values[1] - values[2]) / delta + (values[1] < values[2] ? 6 : 0);
|
||||
else if (max === values[1]) hue = (values[2] - values[0]) / delta + 2;
|
||||
else hue = (values[0] - values[1]) / delta + 4;
|
||||
return { h: hue * 60, s: saturation, l: lightness };
|
||||
};
|
||||
|
||||
const hslToRgb = ({ h, s, l }) => {
|
||||
const hue = ((h % 360) + 360) % 360 / 360;
|
||||
if (s === 0) {
|
||||
const neutral = Math.round(l * 255);
|
||||
return { r: neutral, g: neutral, b: neutral };
|
||||
}
|
||||
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
|
||||
const p = 2 * l - q;
|
||||
const channel = (offset) => {
|
||||
let t = hue + offset;
|
||||
if (t < 0) t += 1;
|
||||
if (t > 1) t -= 1;
|
||||
if (t < 1 / 6) return p + (q - p) * 6 * t;
|
||||
if (t < 1 / 2) return q;
|
||||
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
|
||||
return p;
|
||||
};
|
||||
return { r: channel(1 / 3) * 255, g: channel(0) * 255, b: channel(-1 / 3) * 255 };
|
||||
};
|
||||
|
||||
const luminance = ({ r, g, b }) => {
|
||||
const lin = [r, g, b].map((c) => {
|
||||
const x = c / 255;
|
||||
return x <= 0.03928 ? x / 12.92 : ((x + 0.055) / 1.055) ** 2.4;
|
||||
});
|
||||
return 0.2126 * lin[0] + 0.7152 * lin[1] + 0.0722 * lin[2];
|
||||
};
|
||||
|
||||
/** Detect Codex app light/dark shell for CSS branching. */
|
||||
const detectShellMode = () => {
|
||||
const root = document.documentElement;
|
||||
const body = document.body;
|
||||
const cls = `${root.className || ""} ${body?.className || ""}`.toLowerCase();
|
||||
|
||||
if (/\b(dark|theme-dark|appearance-dark)\b/.test(cls)) return "dark";
|
||||
if (/\b(light|theme-light|appearance-light)\b/.test(cls)) return "light";
|
||||
|
||||
const dataTheme = (
|
||||
root.getAttribute("data-theme") ||
|
||||
root.getAttribute("data-appearance") ||
|
||||
root.getAttribute("data-color-mode") ||
|
||||
body?.getAttribute("data-theme") ||
|
||||
body?.getAttribute("data-appearance") ||
|
||||
""
|
||||
).toLowerCase();
|
||||
if (dataTheme.includes("dark")) return "dark";
|
||||
if (dataTheme.includes("light")) return "light";
|
||||
|
||||
// Radios in profile menu (if present in DOM)
|
||||
const checked = document.querySelector('input[name="appearance-theme"]:checked');
|
||||
if (checked) {
|
||||
const label = (checked.getAttribute("aria-label") || checked.value || "").toLowerCase();
|
||||
if (label.includes("暗") || label.includes("dark")) return "dark";
|
||||
if (label.includes("浅") || label.includes("light")) return "light";
|
||||
if (label.includes("系统") || label.includes("system")) {
|
||||
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
||||
}
|
||||
}
|
||||
|
||||
// The skin itself declares color-scheme on :root. Once installed,
|
||||
// reading getComputedStyle(root) directly would therefore keep `auto`
|
||||
// themes locked to the previous shell mode. Temporarily remove only our
|
||||
// own root class/attribute, sample the native computed scheme, then restore
|
||||
// synchronously. Mutation records created by this probe are drained below
|
||||
// so the root observer does not schedule a redundant ensure pass.
|
||||
try {
|
||||
const hadSkin = root.classList.contains("codex-dream-skin");
|
||||
const savedShell = root.getAttribute(SHELL_ATTR);
|
||||
samplingNativeShell = true;
|
||||
if (hadSkin) root.classList.remove("codex-dream-skin");
|
||||
if (savedShell !== null) root.removeAttribute(SHELL_ATTR);
|
||||
let colorScheme = "";
|
||||
try {
|
||||
colorScheme = getComputedStyle(root).colorScheme || "";
|
||||
} finally {
|
||||
if (hadSkin) root.classList.add("codex-dream-skin");
|
||||
if (savedShell !== null) root.setAttribute(SHELL_ATTR, savedShell);
|
||||
rootObserver?.takeRecords?.();
|
||||
samplingNativeShell = false;
|
||||
}
|
||||
if (colorScheme.includes("dark") && !colorScheme.includes("light")) return "dark";
|
||||
if (colorScheme.includes("light") && !colorScheme.includes("dark")) return "light";
|
||||
} catch {
|
||||
samplingNativeShell = false;
|
||||
}
|
||||
|
||||
try {
|
||||
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
||||
} catch {}
|
||||
|
||||
// Only use surface luminance before the skin owns those surfaces. Sampling
|
||||
// our own translucent layers would create route-dependent light/dark flips.
|
||||
if (!root.classList.contains("codex-dream-skin")) {
|
||||
const samples = [
|
||||
body,
|
||||
document.querySelector("main.main-surface"),
|
||||
document.querySelector("aside.app-shell-left-panel"),
|
||||
].filter(Boolean);
|
||||
let votesLight = 0;
|
||||
let votesDark = 0;
|
||||
for (const el of samples) {
|
||||
try {
|
||||
const rgb = parseRgb(getComputedStyle(el).backgroundColor);
|
||||
if (!rgb) continue;
|
||||
const L = luminance(rgb);
|
||||
if (L >= 0.55) votesLight += 1;
|
||||
else if (L <= 0.25) votesDark += 1;
|
||||
} catch {}
|
||||
}
|
||||
if (votesLight > votesDark) return "light";
|
||||
if (votesDark > votesLight) return "dark";
|
||||
}
|
||||
return "light";
|
||||
};
|
||||
|
||||
const makeAdaptivePalette = (sample, shell) => {
|
||||
const source = sample || { r: 108, g: 126, b: 136 };
|
||||
const hsl = rgbToHsl(source);
|
||||
const hue = hsl.s < 0.12 ? 214 : hsl.h;
|
||||
const saturation = clamp(hsl.s, 0.38, 0.72);
|
||||
const accent = hslToRgb({ h: hue, s: saturation, l: shell === "light" ? 0.42 : 0.66 });
|
||||
const accentAlt = hslToRgb({ h: hue + 12, s: saturation * 0.82, l: shell === "light" ? 0.52 : 0.73 });
|
||||
const secondary = hslToRgb({ h: hue - 24, s: saturation * 0.64, l: shell === "light" ? 0.56 : 0.62 });
|
||||
const highlight = hslToRgb({ h: hue + 24, s: saturation * 0.76, l: shell === "light" ? 0.36 : 0.58 });
|
||||
const neutral = (lightness, chroma = 0.08) => rgbToHex(hslToRgb({ h: hue, s: chroma, l: lightness }));
|
||||
return shell === "light" ? {
|
||||
background: neutral(0.965, 0.07),
|
||||
panel: neutral(0.987, 0.035),
|
||||
panelAlt: neutral(0.945, 0.09),
|
||||
accent: rgbToHex(accent),
|
||||
accentAlt: rgbToHex(accentAlt),
|
||||
secondary: rgbToHex(secondary),
|
||||
highlight: rgbToHex(highlight),
|
||||
text: neutral(0.13, 0.10),
|
||||
muted: neutral(0.42, 0.08),
|
||||
line: `rgba(${Math.round(accent.r)}, ${Math.round(accent.g)}, ${Math.round(accent.b)}, .24)`,
|
||||
} : {
|
||||
background: neutral(0.055, 0.045),
|
||||
panel: neutral(0.085, 0.04),
|
||||
panelAlt: neutral(0.125, 0.05),
|
||||
accent: rgbToHex(accent),
|
||||
accentAlt: rgbToHex(accentAlt),
|
||||
secondary: rgbToHex(secondary),
|
||||
highlight: rgbToHex(highlight),
|
||||
text: neutral(0.93, 0.025),
|
||||
muted: neutral(0.69, 0.03),
|
||||
line: `rgba(${Math.round(accent.r)}, ${Math.round(accent.g)}, ${Math.round(accent.b)}, .28)`,
|
||||
};
|
||||
};
|
||||
|
||||
const resolvedShell = () => {
|
||||
if (THEME.appearance === "light" || THEME.appearance === "dark") return THEME.appearance;
|
||||
// Image luminance may tune accents and scrims, but auto appearance follows
|
||||
// Codex/ChatGPT (or the OS fallback) so a bright wallpaper cannot flip a
|
||||
// native dark session back to a light shell after analysis.
|
||||
return detectShellMode();
|
||||
};
|
||||
|
||||
const applyTheme = (root, shell) => {
|
||||
const colors = THEME.colors || {};
|
||||
const explicit = new Set(Array.isArray(THEME.explicitColorKeys) ? THEME.explicitColorKeys : []);
|
||||
const adaptive = makeAdaptivePalette(artAnalysis?.accentRgb, shell);
|
||||
const legacyLight = !THEME.appearance && shell === "light";
|
||||
const structural = new Set(["background", "panel", "panelAlt", "text", "muted"]);
|
||||
const pick = (name) => {
|
||||
const allowExplicit = explicit.has(name) && !(legacyLight && structural.has(name));
|
||||
return allowExplicit && typeof colors[name] === "string" ? colors[name] : adaptive[name];
|
||||
};
|
||||
const accent = pick("accent");
|
||||
const accentAlt = explicit.has("accentAlt") ? pick("accentAlt") : (explicit.has("accent") ? accent : adaptive.accentAlt);
|
||||
const variables = {
|
||||
"--ds-bg": pick("background"),
|
||||
"--ds-panel": pick("panel"),
|
||||
"--ds-panel-2": pick("panelAlt"),
|
||||
"--ds-green": accent,
|
||||
"--ds-lime": accentAlt,
|
||||
"--ds-cyan": pick("secondary"),
|
||||
"--ds-purple": pick("highlight"),
|
||||
"--ds-text": pick("text"),
|
||||
"--ds-muted": pick("muted"),
|
||||
"--ds-line": explicit.has("line") && typeof colors.line === "string" ? colors.line : adaptive.line,
|
||||
};
|
||||
const surfaceOpacity = Number.isFinite(Number(THEME.surfaceOpacity))
|
||||
? clamp(Number(THEME.surfaceOpacity), 0.35, 1)
|
||||
: 1;
|
||||
setStyleProperty(root, "--ds-surface-opacity", surfaceOpacity.toFixed(2));
|
||||
|
||||
for (const [name, value] of Object.entries(variables)) {
|
||||
if (typeof value === "string" && value) setStyleProperty(root, name, value);
|
||||
}
|
||||
const rgbVariables = {
|
||||
"--ds-bg-rgb": variables["--ds-bg"],
|
||||
"--ds-panel-rgb": variables["--ds-panel"],
|
||||
"--ds-panel-2-rgb": variables["--ds-panel-2"],
|
||||
"--ds-accent-rgb": variables["--ds-green"],
|
||||
"--ds-accent-alt-rgb": variables["--ds-lime"],
|
||||
"--ds-secondary-rgb": variables["--ds-cyan"],
|
||||
"--ds-highlight-rgb": variables["--ds-purple"],
|
||||
"--ds-text-rgb": variables["--ds-text"],
|
||||
"--ds-muted-rgb": variables["--ds-muted"],
|
||||
"--ds-line-rgb": variables["--ds-line"],
|
||||
};
|
||||
for (const [name, value] of Object.entries(rgbVariables)) {
|
||||
const rgb = rgbString(value);
|
||||
if (rgb) setStyleProperty(root, name, rgb);
|
||||
}
|
||||
setStyleProperty(root, "--dream-skin-name", cssString(THEME.name || "Codex Dream Skin"));
|
||||
setStyleProperty(root, "--dream-skin-tagline", cssString(THEME.tagline || "Make something wonderful."));
|
||||
setStyleProperty(root, "--dream-skin-project-prefix", cssString(THEME.projectPrefix || "选择项目 · "));
|
||||
setStyleProperty(root, "--dream-skin-project-label", cssString(THEME.projectLabel || "◉ 选择项目"));
|
||||
};
|
||||
|
||||
const applyArtMetadata = (root) => {
|
||||
const profile = artAnalysis || ART_METADATA;
|
||||
const inferredSafe = profile?.safeArea || "center";
|
||||
const safeArea = ART.safeArea && ART.safeArea !== "auto" ? ART.safeArea : inferredSafe;
|
||||
const canonicalSafe = ["left", "right", "center", "none"].includes(safeArea)
|
||||
? safeArea : "center";
|
||||
const focusX = typeof ART.focusX === "number" ? ART.focusX
|
||||
: profile?.focusX ?? (safeArea === "left" ? 0.72 : safeArea === "right" ? 0.28 : 0.5);
|
||||
const focusY = typeof ART.focusY === "number" ? ART.focusY : profile?.focusY ?? 0.5;
|
||||
const explicitFocusY = typeof ART.focusY === "number";
|
||||
const taskMode = ART.taskMode && ART.taskMode !== "auto"
|
||||
? ART.taskMode : profile?.taskMode || "ambient";
|
||||
const wide = profile?.wide || false;
|
||||
const aspect = profile?.aspect || "unknown";
|
||||
const homeFocusY = !explicitFocusY && !wide && ["wide", "landscape"].includes(aspect)
|
||||
? Math.min(focusY, 0.28) : focusY;
|
||||
const focusXValue = `${(clamp(focusX, 0, 1) * 100).toFixed(2)}%`;
|
||||
const focusYValue = `${(clamp(focusY, 0, 1) * 100).toFixed(2)}%`;
|
||||
const homeFocusYValue = `${(clamp(homeFocusY, 0, 1) * 100).toFixed(2)}%`;
|
||||
|
||||
setAttribute(root, "data-dream-art-wide", wide ? "true" : "false");
|
||||
setAttribute(root, "data-dream-art-safe", canonicalSafe);
|
||||
setAttribute(root, "data-dream-task-mode", taskMode);
|
||||
setAttribute(root, "data-dream-art-safe-area", safeArea);
|
||||
setAttribute(root, "data-dream-art-task-mode", taskMode);
|
||||
setAttribute(root, "data-dream-art-aspect", aspect);
|
||||
setAttribute(root, "data-dream-art-ready", artAnalysis ? "true" : "false");
|
||||
setStyleProperty(root, "--dream-art-focus-x", focusXValue);
|
||||
setStyleProperty(root, "--dream-art-focus-y", focusYValue);
|
||||
setStyleProperty(root, "--dream-art-position", `${focusXValue} ${focusYValue}`);
|
||||
setStyleProperty(root, "--dream-skin-focus-x", focusXValue);
|
||||
setStyleProperty(root, "--dream-skin-focus-y", focusYValue);
|
||||
setStyleProperty(root, "--dream-skin-art-position", `${focusXValue} ${focusYValue}`);
|
||||
setStyleProperty(root, "--dream-skin-home-art-position", `${focusXValue} ${homeFocusYValue}`);
|
||||
};
|
||||
|
||||
const analyzeArt = () => new Promise((resolve) => {
|
||||
const startedAt = now();
|
||||
metrics.analysisRuns += 1;
|
||||
if (typeof window.Image !== "function" || !document?.createElement) {
|
||||
metrics.analysisMs = Number((now() - startedAt).toFixed(3));
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
const image = new window.Image();
|
||||
let settled = false;
|
||||
const finish = (value) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
if (analysisTimer) clearTimeout(analysisTimer);
|
||||
analysisTimer = null;
|
||||
metrics.analysisMs = Number((now() - startedAt).toFixed(3));
|
||||
resolve(value);
|
||||
};
|
||||
analysisTimer = setTimeout(() => finish(null), 6000);
|
||||
image.onerror = () => finish(null);
|
||||
image.onload = () => {
|
||||
try {
|
||||
const ratio = image.naturalWidth / image.naturalHeight;
|
||||
if (!Number.isFinite(ratio) || ratio <= 0) throw new Error("Invalid image dimensions");
|
||||
const maxDimension = 96;
|
||||
const width = Math.max(16, Math.round(ratio >= 1 ? maxDimension : maxDimension * ratio));
|
||||
const height = Math.max(16, Math.round(ratio >= 1 ? maxDimension / ratio : maxDimension));
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
const context = canvas.getContext?.("2d", { willReadFrequently: true });
|
||||
if (!context) throw new Error("Canvas is unavailable");
|
||||
context.drawImage(image, 0, 0, width, height);
|
||||
const data = context.getImageData(0, 0, width, height).data;
|
||||
const samples = new Array(width * height);
|
||||
const bins = Array.from({ length: 24 }, () => ({ weight: 0, r: 0, g: 0, b: 0 }));
|
||||
let lightTotal = 0;
|
||||
let count = 0;
|
||||
|
||||
for (let y = 0; y < height; y += 1) {
|
||||
for (let x = 0; x < width; x += 1) {
|
||||
const offset = (y * width + x) * 4;
|
||||
if (data[offset + 3] < 32) continue;
|
||||
const rgb = { r: data[offset], g: data[offset + 1], b: data[offset + 2] };
|
||||
const light = (0.2126 * rgb.r + 0.7152 * rgb.g + 0.0722 * rgb.b) / 255;
|
||||
const hsl = rgbToHsl(rgb);
|
||||
samples[y * width + x] = { light, saturation: hsl.s };
|
||||
lightTotal += light;
|
||||
count += 1;
|
||||
if (hsl.s >= 0.16 && hsl.l >= 0.16 && hsl.l <= 0.86) {
|
||||
const bin = bins[Math.min(23, Math.floor(hsl.h / 15))];
|
||||
const weight = hsl.s * (1 - Math.abs(hsl.l - 0.52) * 0.85);
|
||||
bin.weight += weight;
|
||||
bin.r += rgb.r * weight;
|
||||
bin.g += rgb.g * weight;
|
||||
bin.b += rgb.b * weight;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!count) throw new Error("Image has no visible pixels");
|
||||
const brightness = lightTotal / count;
|
||||
const information = (start, end) => {
|
||||
let total = 0;
|
||||
let totalSquared = 0;
|
||||
let edges = 0;
|
||||
let edgeCount = 0;
|
||||
let pixels = 0;
|
||||
for (let y = 0; y < height; y += 1) {
|
||||
for (let x = start; x < end; x += 1) {
|
||||
const sample = samples[y * width + x];
|
||||
if (!sample) continue;
|
||||
total += sample.light;
|
||||
totalSquared += sample.light * sample.light;
|
||||
pixels += 1;
|
||||
const previous = x > start ? samples[y * width + x - 1] : null;
|
||||
const above = y > 0 ? samples[(y - 1) * width + x] : null;
|
||||
if (previous) { edges += Math.abs(sample.light - previous.light); edgeCount += 1; }
|
||||
if (above) { edges += Math.abs(sample.light - above.light); edgeCount += 1; }
|
||||
}
|
||||
}
|
||||
const mean = pixels ? total / pixels : 0;
|
||||
const variance = pixels ? Math.max(0, totalSquared / pixels - mean * mean) : 1;
|
||||
return Math.sqrt(variance) * 0.58 + (edgeCount ? edges / edgeCount : 1) * 0.42;
|
||||
};
|
||||
const zoneWidth = Math.max(1, Math.floor(width * 0.38));
|
||||
const leftInformation = information(0, zoneWidth);
|
||||
const rightInformation = information(width - zoneWidth, width);
|
||||
let safeArea = "center";
|
||||
if (leftInformation < rightInformation * 0.86) safeArea = "left";
|
||||
else if (rightInformation < leftInformation * 0.86) safeArea = "right";
|
||||
|
||||
let saliencyTotal = 0;
|
||||
let saliencyX = 0;
|
||||
let saliencyY = 0;
|
||||
for (let y = 0; y < height; y += 1) {
|
||||
for (let x = 0; x < width; x += 1) {
|
||||
const sample = samples[y * width + x];
|
||||
if (!sample) continue;
|
||||
const previous = x > 0 ? samples[y * width + x - 1] : null;
|
||||
const above = y > 0 ? samples[(y - 1) * width + x] : null;
|
||||
const edge = (previous ? Math.abs(sample.light - previous.light) : 0) +
|
||||
(above ? Math.abs(sample.light - above.light) : 0);
|
||||
const weight = 0.01 + Math.abs(sample.light - brightness) * 0.48 +
|
||||
sample.saturation * 0.34 + edge * 0.28;
|
||||
saliencyTotal += weight;
|
||||
saliencyX += (x + 0.5) / width * weight;
|
||||
saliencyY += (y + 0.5) / height * weight;
|
||||
}
|
||||
}
|
||||
let focusX = saliencyTotal ? saliencyX / saliencyTotal : 0.5;
|
||||
let focusY = saliencyTotal ? saliencyY / saliencyTotal : 0.5;
|
||||
if (safeArea === "left") focusX = Math.max(0.64, focusX);
|
||||
if (safeArea === "right") focusX = Math.min(0.36, focusX);
|
||||
focusX = clamp(focusX, 0.12, 0.88);
|
||||
focusY = clamp(focusY, 0.18, 0.82);
|
||||
|
||||
const accentBin = bins.reduce((best, candidate) => candidate.weight > best.weight ? candidate : best, bins[0]);
|
||||
const accentRgb = accentBin.weight > 0 ? {
|
||||
r: accentBin.r / accentBin.weight,
|
||||
g: accentBin.g / accentBin.weight,
|
||||
b: accentBin.b / accentBin.weight,
|
||||
} : null;
|
||||
const aspect = ratio >= 2.25 ? "ultrawide" : ratio >= 1.45 ? "wide"
|
||||
: ratio >= 1.08 ? "landscape" : ratio >= 0.9 ? "square" : "portrait";
|
||||
finish({
|
||||
width: image.naturalWidth,
|
||||
height: image.naturalHeight,
|
||||
ratio,
|
||||
wide: ratio >= 1.75,
|
||||
aspect,
|
||||
brightness,
|
||||
shell: brightness >= 0.58 ? "light" : "dark",
|
||||
safeArea,
|
||||
focusX,
|
||||
focusY,
|
||||
taskMode: ratio >= 2.25 ? "banner" : "ambient",
|
||||
accentRgb,
|
||||
});
|
||||
} catch {
|
||||
finish(null);
|
||||
}
|
||||
};
|
||||
image.src = artUrl;
|
||||
});
|
||||
|
||||
let chromeParts = null;
|
||||
let observedShellMain = null;
|
||||
let resizeObserver = null;
|
||||
|
||||
const ensureStyle = (root) => {
|
||||
let style = document.getElementById(STYLE_ID);
|
||||
if (!style) {
|
||||
style = document.createElement("style");
|
||||
style.id = STYLE_ID;
|
||||
style.textContent = cssText;
|
||||
style.dataset.dreamSkinVersion = VERSION;
|
||||
(document.head || root).appendChild(style);
|
||||
} else if (style.dataset.dreamSkinStyleRevision !== STYLE_REVISION) {
|
||||
style.textContent = cssText;
|
||||
}
|
||||
style.dataset.dreamSkinVersion = VERSION;
|
||||
style.dataset.dreamSkinStyleRevision = STYLE_REVISION;
|
||||
return style;
|
||||
};
|
||||
|
||||
const applyRootState = (root) => {
|
||||
metrics.rootPasses += 1;
|
||||
ensureStyle(root);
|
||||
const shell = resolvedShell();
|
||||
setAttribute(root, SHELL_ATTR, shell);
|
||||
setStyleProperty(root, "--dream-skin-art", `url("${artUrl}")`);
|
||||
applyTheme(root, shell);
|
||||
applyArtMetadata(root);
|
||||
root.classList.add("codex-dream-skin");
|
||||
return shell;
|
||||
};
|
||||
|
||||
const syncRouteState = (shell, { layout = false } = {}) => {
|
||||
metrics.routePasses += 1;
|
||||
const root = document.documentElement;
|
||||
if (!root) return;
|
||||
shell ||= root.getAttribute(SHELL_ATTR) || resolvedShell();
|
||||
const shellMain = document.querySelector("main.main-surface") || document.querySelector("main");
|
||||
const homeIndicator = document.querySelector('[data-testid="home-icon"]');
|
||||
const home = homeIndicator?.closest('[role="main"]') ||
|
||||
[...document.querySelectorAll('[role="main"]')].find((candidate) =>
|
||||
candidate.querySelector('[data-feature="game-source"]') &&
|
||||
candidate.querySelector('[data-codex-composer-root]')) || null;
|
||||
for (const candidate of document.querySelectorAll('[role="main"].dream-skin-home')) {
|
||||
if (candidate !== home) candidate.classList.remove("dream-skin-home");
|
||||
}
|
||||
if (home) home.classList.add("dream-skin-home");
|
||||
const homeUtilityBars = new Set(home
|
||||
? home.querySelectorAll('[class*="_homeUtilityBar_"]')
|
||||
: []);
|
||||
for (const candidate of document.querySelectorAll(".dream-skin-home-utility")) {
|
||||
if (!homeUtilityBars.has(candidate)) candidate.classList.remove("dream-skin-home-utility");
|
||||
}
|
||||
for (const candidate of homeUtilityBars) candidate.classList.add("dream-skin-home-utility");
|
||||
|
||||
if (!shellMain || !document.body) return;
|
||||
if (observedShellMain !== shellMain) {
|
||||
resizeObserver?.disconnect();
|
||||
resizeObserver?.observe(shellMain);
|
||||
observedShellMain = shellMain;
|
||||
layout = true;
|
||||
}
|
||||
shellMain.classList.toggle("dream-skin-home-shell", Boolean(home));
|
||||
let chrome = document.getElementById(CHROME_ID);
|
||||
let created = false;
|
||||
if (!chrome || chrome.parentElement !== document.body) {
|
||||
chrome?.remove();
|
||||
chrome = document.createElement("div");
|
||||
chrome.id = CHROME_ID;
|
||||
chrome.setAttribute("aria-hidden", "true");
|
||||
chrome.innerHTML = `
|
||||
<div class="dream-skin-brand">
|
||||
<span class="dream-skin-portal-mark">◉</span>
|
||||
<span><b></b><small></small></span>
|
||||
</div>
|
||||
<div class="dream-skin-status"><i></i><span></span></div>
|
||||
<div class="dream-skin-quote"></div>
|
||||
<div class="dream-skin-particles"><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i></div>
|
||||
<div class="dream-skin-orbit"></div>`;
|
||||
document.body.appendChild(chrome);
|
||||
created = true;
|
||||
chromeParts = null;
|
||||
}
|
||||
if (!chromeParts || chromeParts.chrome !== chrome) {
|
||||
chromeParts = {
|
||||
chrome,
|
||||
name: chrome.querySelector(".dream-skin-brand b"),
|
||||
subtitle: chrome.querySelector(".dream-skin-brand small"),
|
||||
status: chrome.querySelector(".dream-skin-status span"),
|
||||
quote: chrome.querySelector(".dream-skin-quote"),
|
||||
};
|
||||
}
|
||||
setTextContent(chromeParts.name, THEME.name || "Codex Dream Skin");
|
||||
setTextContent(chromeParts.subtitle, THEME.brandSubtitle || "CODEX DREAM SKIN");
|
||||
setTextContent(chromeParts.status, THEME.statusText || "DREAM SKIN ONLINE");
|
||||
setTextContent(chromeParts.quote, THEME.quote || "MAKE SOMETHING WONDERFUL");
|
||||
if (layout || created) {
|
||||
metrics.layoutReads += 1;
|
||||
const shellBox = shellMain.getBoundingClientRect();
|
||||
setStyleProperty(chrome, "left", `${Math.round(shellBox.left)}px`);
|
||||
setStyleProperty(chrome, "top", `${Math.round(shellBox.top)}px`);
|
||||
setStyleProperty(chrome, "width", `${Math.round(shellBox.width)}px`);
|
||||
setStyleProperty(chrome, "height", `${Math.round(shellBox.height)}px`);
|
||||
}
|
||||
chrome.classList.toggle("dream-skin-home-shell", Boolean(home));
|
||||
if (chrome.dataset.dreamShell !== shell) {
|
||||
chrome.dataset.dreamShell = shell;
|
||||
metrics.attributeWrites += 1;
|
||||
}
|
||||
};
|
||||
|
||||
const ensure = ({ root: rootPass = true, route = true, layout = true } = {}) => {
|
||||
if (window[DISABLED_KEY]) return;
|
||||
const root = document.documentElement;
|
||||
if (!root) return;
|
||||
metrics.ensureCalls += 1;
|
||||
const shell = rootPass ? applyRootState(root) : null;
|
||||
if (route) syncRouteState(shell, { layout });
|
||||
};
|
||||
|
||||
const cleanup = () => {
|
||||
const state = window[STATE_KEY];
|
||||
if (state?.installToken !== installToken) return false;
|
||||
window[DISABLED_KEY] = true;
|
||||
document.documentElement?.classList.remove("codex-dream-skin");
|
||||
document.documentElement?.removeAttribute(SHELL_ATTR);
|
||||
for (const name of ART_ATTRS) document.documentElement?.removeAttribute(name);
|
||||
document.documentElement?.style.removeProperty("--dream-skin-art");
|
||||
for (const name of THEME_VARIABLES) document.documentElement?.style.removeProperty(name);
|
||||
document.querySelectorAll(".dream-skin-home").forEach((node) => node.classList.remove("dream-skin-home"));
|
||||
document.querySelectorAll(".dream-skin-home-shell").forEach((node) => node.classList.remove("dream-skin-home-shell"));
|
||||
document.querySelectorAll(".dream-skin-home-utility").forEach((node) => node.classList.remove("dream-skin-home-utility"));
|
||||
document.getElementById(STYLE_ID)?.remove();
|
||||
document.getElementById(CHROME_ID)?.remove();
|
||||
state?.observer?.disconnect();
|
||||
state?.rootObserver?.disconnect();
|
||||
state?.resizeObserver?.disconnect();
|
||||
if (state?.timer) clearInterval(state.timer);
|
||||
if (state?.scheduler?.timeout) clearTimeout(state.scheduler.timeout);
|
||||
if (state?.scheduler?.frame != null && typeof cancelAnimationFrame === "function") {
|
||||
cancelAnimationFrame(state.scheduler.frame);
|
||||
}
|
||||
if (analysisTimer) clearTimeout(analysisTimer);
|
||||
if (state?.resizeHandler) window.removeEventListener("resize", state.resizeHandler);
|
||||
if (state?.mediaHandler && state?.mediaQuery) {
|
||||
try { state.mediaQuery.removeEventListener("change", state.mediaHandler); } catch {}
|
||||
}
|
||||
if (state?.artUrl) URL.revokeObjectURL(state.artUrl);
|
||||
delete window[STATE_KEY];
|
||||
return true;
|
||||
};
|
||||
|
||||
const scheduler = { timeout: null, frame: null, root: false, route: false, layout: false };
|
||||
const flushScheduledEnsure = () => {
|
||||
if (scheduler.frame !== null && typeof cancelAnimationFrame === "function") {
|
||||
cancelAnimationFrame(scheduler.frame);
|
||||
}
|
||||
if (scheduler.timeout) clearTimeout(scheduler.timeout);
|
||||
scheduler.frame = null;
|
||||
scheduler.timeout = null;
|
||||
const pending = { root: scheduler.root, route: scheduler.route, layout: scheduler.layout };
|
||||
scheduler.root = false;
|
||||
scheduler.route = false;
|
||||
scheduler.layout = false;
|
||||
ensure(pending);
|
||||
};
|
||||
const scheduleEnsure = ({ root = false, route = true, layout = false } = {}) => {
|
||||
scheduler.root ||= root;
|
||||
scheduler.route ||= route;
|
||||
scheduler.layout ||= layout;
|
||||
if (scheduler.timeout || scheduler.frame !== null) return;
|
||||
if (typeof requestAnimationFrame === "function") {
|
||||
scheduler.frame = requestAnimationFrame(flushScheduledEnsure);
|
||||
scheduler.timeout = setTimeout(flushScheduledEnsure, 96);
|
||||
} else {
|
||||
scheduler.timeout = setTimeout(flushScheduledEnsure, 64);
|
||||
}
|
||||
};
|
||||
const observer = new MutationObserver(() => scheduleEnsure({ route: true }));
|
||||
rootObserver = new MutationObserver(() => {
|
||||
if (samplingNativeShell) return;
|
||||
scheduleEnsure({ root: true, route: true });
|
||||
});
|
||||
const resizeHandler = () => scheduleEnsure({ route: true, layout: true });
|
||||
if (typeof ResizeObserver === "function") {
|
||||
resizeObserver = new ResizeObserver(() => scheduleEnsure({ route: true, layout: true }));
|
||||
}
|
||||
|
||||
let mediaQuery = null;
|
||||
let mediaHandler = null;
|
||||
try {
|
||||
mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
mediaHandler = () => scheduleEnsure({ root: true, route: true });
|
||||
} catch {}
|
||||
|
||||
window[STATE_KEY] = {
|
||||
ensure,
|
||||
cleanup,
|
||||
observer,
|
||||
rootObserver,
|
||||
resizeObserver,
|
||||
timer: null,
|
||||
scheduler,
|
||||
resizeHandler,
|
||||
mediaQuery,
|
||||
mediaHandler,
|
||||
artUrl,
|
||||
installToken,
|
||||
analysis: artAnalysis,
|
||||
artMetadata: ART_METADATA,
|
||||
metrics,
|
||||
version: VERSION,
|
||||
themeId: THEME.id || "custom",
|
||||
detectShellMode,
|
||||
};
|
||||
const firstEnsureStartedAt = now();
|
||||
ensure({ layout: !previous || !document.getElementById(CHROME_ID) });
|
||||
metrics.firstEnsureMs = Number((now() - firstEnsureStartedAt).toFixed(3));
|
||||
if (previous?.artUrl && previous.artUrl !== artUrl) URL.revokeObjectURL(previous.artUrl);
|
||||
|
||||
observer.observe(document.documentElement, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
rootObserver.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ["class", "data-theme", "data-appearance", "data-color-mode", "style"],
|
||||
});
|
||||
if (document.body) {
|
||||
rootObserver.observe(document.body, {
|
||||
attributes: true,
|
||||
attributeFilter: ["class", "data-theme", "data-appearance", "data-color-mode", "style"],
|
||||
});
|
||||
}
|
||||
const timer = setInterval(() => ensure(), 4000);
|
||||
window[STATE_KEY].timer = timer;
|
||||
window.addEventListener("resize", resizeHandler, { passive: true });
|
||||
if (mediaHandler && mediaQuery) {
|
||||
mediaQuery.addEventListener("change", mediaHandler);
|
||||
}
|
||||
const analysisPromise = artAnalysis ? Promise.resolve(null) : analyzeArt();
|
||||
window[STATE_KEY].analysisTimer = analysisTimer;
|
||||
analysisPromise.then((analysis) => {
|
||||
const state = window[STATE_KEY];
|
||||
if (!analysis || state?.installToken !== installToken || window[DISABLED_KEY]) return;
|
||||
artAnalysis = analysis;
|
||||
state.analysis = analysis;
|
||||
if (typeof THEME.artKey === "string") {
|
||||
analysisCache.set(THEME.artKey, analysis);
|
||||
while (analysisCache.size > 8) analysisCache.delete(analysisCache.keys().next().value);
|
||||
}
|
||||
ensure({ root: true, route: false, layout: false });
|
||||
}).catch(() => {});
|
||||
return {
|
||||
installed: true,
|
||||
version: VERSION,
|
||||
themeId: THEME.id || "custom",
|
||||
shell: resolvedShell(),
|
||||
analysis: artAnalysis,
|
||||
};
|
||||
})(__DREAM_SKIN_CSS_JSON__, __DREAM_SKIN_ART_JSON__, __DREAM_SKIN_THEME_JSON__)
|
||||
@@ -0,0 +1,131 @@
|
||||
const SOF_MARKERS = new Set([
|
||||
0xc0, 0xc1, 0xc2, 0xc3, 0xc5, 0xc6, 0xc7,
|
||||
0xc9, 0xca, 0xcb, 0xcd, 0xce, 0xcf,
|
||||
]);
|
||||
export const MAX_IMAGE_DIMENSION = 16384;
|
||||
export const MAX_IMAGE_PIXELS = 50_000_000;
|
||||
|
||||
function uint16be(bytes, offset) {
|
||||
return bytes[offset] * 256 + bytes[offset + 1];
|
||||
}
|
||||
|
||||
function uint16le(bytes, offset) {
|
||||
return bytes[offset] + bytes[offset + 1] * 256;
|
||||
}
|
||||
|
||||
function uint24le(bytes, offset) {
|
||||
return bytes[offset] + bytes[offset + 1] * 256 + bytes[offset + 2] * 65536;
|
||||
}
|
||||
|
||||
function uint32be(bytes, offset) {
|
||||
return bytes[offset] * 0x1000000 + bytes[offset + 1] * 0x10000 +
|
||||
bytes[offset + 2] * 0x100 + bytes[offset + 3];
|
||||
}
|
||||
|
||||
function uint32le(bytes, offset) {
|
||||
return bytes[offset] + bytes[offset + 1] * 0x100 + bytes[offset + 2] * 0x10000 +
|
||||
bytes[offset + 3] * 0x1000000;
|
||||
}
|
||||
|
||||
function ascii(bytes, offset, length) {
|
||||
return String.fromCharCode(...bytes.subarray(offset, offset + length));
|
||||
}
|
||||
|
||||
function pngDimensions(bytes) {
|
||||
const signature = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
|
||||
if (bytes.length < 24 || signature.some((value, index) => bytes[index] !== value) ||
|
||||
uint32be(bytes, 8) !== 13 || ascii(bytes, 12, 4) !== "IHDR") return null;
|
||||
const width = uint32be(bytes, 16);
|
||||
const height = uint32be(bytes, 20);
|
||||
return width > 0 && height > 0 ? { width, height } : null;
|
||||
}
|
||||
|
||||
function jpegDimensions(bytes) {
|
||||
if (bytes.length < 12 || bytes[0] !== 0xff || bytes[1] !== 0xd8) return null;
|
||||
let offset = 2;
|
||||
while (offset + 9 < bytes.length) {
|
||||
if (bytes[offset] !== 0xff) {
|
||||
offset += 1;
|
||||
continue;
|
||||
}
|
||||
while (offset < bytes.length && bytes[offset] === 0xff) offset += 1;
|
||||
const marker = bytes[offset++];
|
||||
if (marker === 0xd9 || marker === 0xda) break;
|
||||
if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd8)) continue;
|
||||
if (offset + 2 > bytes.length) break;
|
||||
const length = uint16be(bytes, offset);
|
||||
if (length < 2 || offset + length > bytes.length) break;
|
||||
if (SOF_MARKERS.has(marker) && length >= 7) {
|
||||
const height = uint16be(bytes, offset + 3);
|
||||
const width = uint16be(bytes, offset + 5);
|
||||
return width > 0 && height > 0 ? { width, height } : null;
|
||||
}
|
||||
offset += length;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function webpDimensions(bytes) {
|
||||
if (bytes.length < 20 || ascii(bytes, 0, 4) !== "RIFF" || ascii(bytes, 8, 4) !== "WEBP") {
|
||||
return null;
|
||||
}
|
||||
const riffEnd = Math.min(bytes.length, uint32le(bytes, 4) + 8);
|
||||
let offset = 12;
|
||||
while (offset + 8 <= riffEnd) {
|
||||
const type = ascii(bytes, offset, 4);
|
||||
const size = bytes[offset + 4] + bytes[offset + 5] * 256 +
|
||||
bytes[offset + 6] * 65536 + bytes[offset + 7] * 0x1000000;
|
||||
const data = offset + 8;
|
||||
if (data + size > riffEnd) break;
|
||||
if (type === "VP8X" && size >= 10) {
|
||||
return { width: uint24le(bytes, data + 4) + 1, height: uint24le(bytes, data + 7) + 1 };
|
||||
}
|
||||
if (type === "VP8L" && size >= 5 && bytes[data] === 0x2f) {
|
||||
const width = 1 + bytes[data + 1] + ((bytes[data + 2] & 0x3f) << 8);
|
||||
const height = 1 + (bytes[data + 2] >> 6) + (bytes[data + 3] << 2) +
|
||||
((bytes[data + 4] & 0x0f) << 10);
|
||||
return { width, height };
|
||||
}
|
||||
if (type === "VP8 " && size >= 10 && bytes[data + 3] === 0x9d &&
|
||||
bytes[data + 4] === 0x01 && bytes[data + 5] === 0x2a) {
|
||||
return {
|
||||
width: uint16le(bytes, data + 6) & 0x3fff,
|
||||
height: uint16le(bytes, data + 8) & 0x3fff,
|
||||
};
|
||||
}
|
||||
offset = data + size + (size % 2);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function classifyImageDimensions({ width, height }) {
|
||||
const ratio = width / height;
|
||||
if (
|
||||
!Number.isSafeInteger(width) || !Number.isSafeInteger(height)
|
||||
|| width < 1 || height < 1
|
||||
|| width > MAX_IMAGE_DIMENSION || height > MAX_IMAGE_DIMENSION
|
||||
|| width * height > MAX_IMAGE_PIXELS
|
||||
|| !Number.isFinite(ratio)
|
||||
) return null;
|
||||
const aspect = ratio >= 2.25 ? "ultrawide" : ratio >= 1.45 ? "wide"
|
||||
: ratio >= 1.08 ? "landscape" : ratio >= 0.9 ? "square" : "portrait";
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
ratio,
|
||||
wide: ratio >= 1.75,
|
||||
aspect,
|
||||
taskMode: ratio >= 2.25 ? "banner" : "ambient",
|
||||
};
|
||||
}
|
||||
|
||||
export function readImageMetadata(value, extension = "") {
|
||||
const bytes = value instanceof Uint8Array ? value : new Uint8Array(value);
|
||||
const normalized = extension.toLowerCase();
|
||||
let dimensions = null;
|
||||
if (normalized === ".png" || bytes[0] === 0x89) dimensions = pngDimensions(bytes);
|
||||
else if (normalized === ".jpg" || normalized === ".jpeg" ||
|
||||
(bytes[0] === 0xff && bytes[1] === 0xd8)) dimensions = jpegDimensions(bytes);
|
||||
else if (normalized === ".webp" || ascii(bytes, 8, 4) === "WEBP") dimensions = webpDimensions(bytes);
|
||||
return dimensions ? classifyImageDimensions(dimensions) : null;
|
||||
}
|
||||
@@ -0,0 +1,913 @@
|
||||
import fs from "node:fs/promises";
|
||||
import { constants as fsConstants, watch as watchFs } from "node:fs";
|
||||
import { createHash } from "node:crypto";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { readImageMetadata } from "./image-metadata.mjs";
|
||||
|
||||
const scriptPath = fileURLToPath(import.meta.url);
|
||||
const here = path.dirname(scriptPath);
|
||||
const root = path.resolve(here, "..");
|
||||
const SKIN_VERSION = "1.2.3";
|
||||
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;
|
||||
let staticPayloadAssets = null;
|
||||
|
||||
function parseArgs(argv) {
|
||||
const options = {
|
||||
port: 9341,
|
||||
mode: "watch",
|
||||
timeoutMs: 30000,
|
||||
screenshot: null,
|
||||
reload: false,
|
||||
themeDir: null,
|
||||
};
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const arg = argv[i];
|
||||
if (arg === "--port") options.port = Number(argv[++i]);
|
||||
else if (arg === "--once") options.mode = "once";
|
||||
else if (arg === "--watch") options.mode = "watch";
|
||||
else if (arg === "--verify") options.mode = "verify";
|
||||
else if (arg === "--remove") options.mode = "remove";
|
||||
else if (arg === "--check-payload") options.mode = "check";
|
||||
else if (arg === "--timeout-ms") options.timeoutMs = Number(argv[++i]);
|
||||
else if (arg === "--screenshot") options.screenshot = path.resolve(argv[++i]);
|
||||
else if (arg === "--theme-dir") options.themeDir = path.resolve(argv[++i]);
|
||||
else if (arg === "--reload") options.reload = true;
|
||||
else throw new Error(`Unknown argument: ${arg}`);
|
||||
}
|
||||
if (!Number.isInteger(options.port) || options.port < 1024 || options.port > 65535) {
|
||||
throw new Error(`Invalid port: ${options.port}`);
|
||||
}
|
||||
if (!Number.isFinite(options.timeoutMs) || options.timeoutMs < 250 || options.timeoutMs > 120000) {
|
||||
throw new Error(`Invalid timeout: ${options.timeoutMs}`);
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
function validatedDebuggerUrl(target, port) {
|
||||
const url = new URL(target.webSocketDebuggerUrl);
|
||||
const pathIsValid = /^\/devtools\/page\/[A-Za-z0-9._-]{1,200}$/.test(url.pathname);
|
||||
if (
|
||||
url.protocol !== "ws:" || !LOOPBACK_HOSTS.has(url.hostname) || Number(url.port) !== port
|
||||
|| url.username || url.password || url.search || url.hash || !pathIsValid
|
||||
) {
|
||||
throw new Error("Rejected a CDP WebSocket URL outside the allowed loopback page endpoint shape");
|
||||
}
|
||||
return url.href;
|
||||
}
|
||||
|
||||
function isValidCdpPageTarget(item, port) {
|
||||
if (
|
||||
item?.type !== "page" || !item.url?.startsWith("app://")
|
||||
|| typeof item.id !== "string" || !CDP_ID_PATTERN.test(item.id)
|
||||
|| !item.webSocketDebuggerUrl
|
||||
) return false;
|
||||
try {
|
||||
const debuggerUrl = new URL(validatedDebuggerUrl(item, port));
|
||||
return debuggerUrl.pathname === `/devtools/page/${item.id}`;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
class CdpSession {
|
||||
constructor(target, port) {
|
||||
this.target = target;
|
||||
this.ws = new WebSocket(validatedDebuggerUrl(target, port));
|
||||
this.nextId = 1;
|
||||
this.pending = new Map();
|
||||
this.listeners = new Map();
|
||||
this.closed = false;
|
||||
}
|
||||
|
||||
async open() {
|
||||
await new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
try { this.ws.close(); } catch {}
|
||||
reject(new Error("CDP WebSocket open timed out"));
|
||||
}, 5000);
|
||||
this.ws.addEventListener("open", () => { clearTimeout(timeout); resolve(); }, { once: true });
|
||||
this.ws.addEventListener("error", () => { clearTimeout(timeout); reject(new Error("CDP WebSocket open failed")); }, { once: true });
|
||||
});
|
||||
this.ws.addEventListener("message", (event) => this.onMessage(event));
|
||||
this.ws.addEventListener("error", () => this.close());
|
||||
this.ws.addEventListener("close", () => {
|
||||
this.closed = true;
|
||||
for (const waiter of this.pending.values()) {
|
||||
clearTimeout(waiter.timeout);
|
||||
waiter.reject(new Error("CDP socket closed"));
|
||||
}
|
||||
this.pending.clear();
|
||||
});
|
||||
await this.send("Runtime.enable");
|
||||
await this.send("Page.enable");
|
||||
return this;
|
||||
}
|
||||
|
||||
onMessage(event) {
|
||||
let message;
|
||||
try {
|
||||
message = JSON.parse(String(event.data));
|
||||
} catch {
|
||||
this.close();
|
||||
return;
|
||||
}
|
||||
if (!message || typeof message !== "object") {
|
||||
this.close();
|
||||
return;
|
||||
}
|
||||
if (message.id) {
|
||||
const waiter = this.pending.get(message.id);
|
||||
if (!waiter) return;
|
||||
clearTimeout(waiter.timeout);
|
||||
this.pending.delete(message.id);
|
||||
if (message.error) waiter.reject(new Error(`${message.error.message} (${message.error.code})`));
|
||||
else waiter.resolve(message.result);
|
||||
return;
|
||||
}
|
||||
for (const listener of this.listeners.get(message.method) ?? []) {
|
||||
try { listener(message.params ?? {}); } catch (error) {
|
||||
console.error(`[dream-skin] CDP listener failed: ${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
on(method, listener) {
|
||||
const listeners = this.listeners.get(method) ?? [];
|
||||
listeners.push(listener);
|
||||
this.listeners.set(method, listeners);
|
||||
}
|
||||
|
||||
send(method, params = {}, timeoutMs = 10000) {
|
||||
if (this.closed) return Promise.reject(new Error("CDP session is closed"));
|
||||
return new Promise((resolve, reject) => {
|
||||
const id = this.nextId++;
|
||||
const timeout = setTimeout(() => {
|
||||
this.pending.delete(id);
|
||||
reject(new Error(`CDP command timed out: ${method}`));
|
||||
}, timeoutMs);
|
||||
this.pending.set(id, { resolve, reject, timeout });
|
||||
try {
|
||||
this.ws.send(JSON.stringify({ id, method, params }));
|
||||
} catch (error) {
|
||||
clearTimeout(timeout);
|
||||
this.pending.delete(id);
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async evaluate(expression) {
|
||||
const result = await this.send("Runtime.evaluate", {
|
||||
expression,
|
||||
awaitPromise: true,
|
||||
returnByValue: true,
|
||||
userGesture: false,
|
||||
});
|
||||
if (result.exceptionDetails) {
|
||||
const detail = result.exceptionDetails.exception?.description ?? result.exceptionDetails.text;
|
||||
throw new Error(`Renderer evaluation failed: ${detail}`);
|
||||
}
|
||||
return result.result?.value;
|
||||
}
|
||||
|
||||
close() {
|
||||
for (const waiter of this.pending.values()) {
|
||||
clearTimeout(waiter.timeout);
|
||||
waiter.reject(new Error("CDP session closed"));
|
||||
}
|
||||
this.pending.clear();
|
||||
if (!this.closed) {
|
||||
try { this.ws.close(); } catch {}
|
||||
}
|
||||
this.closed = true;
|
||||
}
|
||||
}
|
||||
|
||||
async function listAppTargets(port) {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 2000);
|
||||
try {
|
||||
const response = await fetch(`http://127.0.0.1:${port}/json/list`, {
|
||||
redirect: "error",
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
const targets = await response.json();
|
||||
if (!Array.isArray(targets)) throw new Error("CDP target list was not an array");
|
||||
return targets.filter((item) => isValidCdpPageTarget(item, port));
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
async function probeSession(session) {
|
||||
return session.evaluate(`(() => {
|
||||
const markers = {
|
||||
shell: Boolean(document.querySelector('main.main-surface')),
|
||||
sidebar: Boolean(document.querySelector('aside.app-shell-left-panel')),
|
||||
composer: Boolean(document.querySelector('.composer-surface-chrome')),
|
||||
main: Boolean(document.querySelector('[role="main"]')),
|
||||
};
|
||||
return {
|
||||
title: document.title,
|
||||
href: location.href,
|
||||
markers,
|
||||
codex: markers.shell && markers.sidebar,
|
||||
};
|
||||
})()`);
|
||||
}
|
||||
|
||||
async function waitForCodexProbe(session, timeoutMs = 1800) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let probe = null;
|
||||
while (Date.now() < deadline) {
|
||||
probe = await probeSession(session);
|
||||
if (probe?.codex) return probe;
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
return probe;
|
||||
}
|
||||
|
||||
async function connectTarget(target, port) {
|
||||
return new CdpSession(target, port).open();
|
||||
}
|
||||
|
||||
async function connectCodexTargets(port, timeoutMs) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let lastError;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const targets = await listAppTargets(port);
|
||||
const connected = [];
|
||||
for (const target of targets) {
|
||||
let session;
|
||||
try {
|
||||
session = await connectTarget(target, port);
|
||||
const probe = await probeSession(session);
|
||||
if (probe?.codex) connected.push({ target, session, probe });
|
||||
else session.close();
|
||||
} catch (error) {
|
||||
session?.close();
|
||||
lastError = error;
|
||||
}
|
||||
}
|
||||
if (connected.length) return connected;
|
||||
lastError = new Error("No page matched the expected Codex shell markers");
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 350));
|
||||
}
|
||||
throw new Error(`No verified Codex renderer on 127.0.0.1:${port}: ${lastError?.message ?? "timed out"}`);
|
||||
}
|
||||
|
||||
function assertContainedPath(rootPath, candidatePath, label) {
|
||||
const relative = path.relative(rootPath, candidatePath);
|
||||
if (
|
||||
relative === ""
|
||||
|| (!path.isAbsolute(relative) && relative !== ".." && !relative.startsWith(`..${path.sep}`))
|
||||
) return;
|
||||
throw new Error(`${label} must stay inside its theme directory`);
|
||||
}
|
||||
|
||||
async function loadTheme(themeDir) {
|
||||
const requestedRoot = themeDir ?? path.join(root, "assets");
|
||||
const configPath = path.join(requestedRoot, "theme.json");
|
||||
let assetsRoot;
|
||||
let canonicalConfigPath;
|
||||
try {
|
||||
[assetsRoot, canonicalConfigPath] = await Promise.all([
|
||||
fs.realpath(requestedRoot),
|
||||
fs.realpath(configPath),
|
||||
]);
|
||||
} catch (error) {
|
||||
if (themeDir && error.code === "ENOENT") {
|
||||
throw new Error(`Explicit theme directory is missing theme.json: ${configPath}`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
assertContainedPath(assetsRoot, canonicalConfigPath, "Theme config");
|
||||
let config;
|
||||
try {
|
||||
config = await fs.readFile(canonicalConfigPath, "utf8");
|
||||
} catch (error) {
|
||||
if (themeDir && error.code === "ENOENT") {
|
||||
throw new Error(`Explicit theme directory is missing theme.json: ${configPath}`);
|
||||
}
|
||||
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 (/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/u.test(raw.image)) {
|
||||
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 text = (value, fallback, max, name) => {
|
||||
if (value === undefined) return fallback;
|
||||
if (typeof value !== "string" || /[\u0000-\u001f\u007f-\u009f\u2028\u2029]/u.test(value)) {
|
||||
throw new Error(`${configPath} has an invalid ${name} field`);
|
||||
}
|
||||
return value.trim() ? Array.from(value.trim()).slice(0, max).join("") : fallback;
|
||||
};
|
||||
const color = (value, fallback) => {
|
||||
if (typeof value !== "string") return fallback;
|
||||
const normalized = value.trim();
|
||||
return /^#[0-9a-f]{6}$/i.test(normalized) || /^rgba?\([0-9., %]+\)$/i.test(normalized)
|
||||
? normalized
|
||||
: fallback;
|
||||
};
|
||||
const choice = (value, name, choices) => {
|
||||
if (value === undefined) return undefined;
|
||||
if (typeof value !== "string" || !choices.includes(value)) {
|
||||
throw new Error(`${configPath} has an invalid ${name} field`);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
const unit = (value, name) => {
|
||||
if (value === undefined) return undefined;
|
||||
if (typeof value !== "number" || !Number.isFinite(value) || value < 0 || value > 1) {
|
||||
throw new Error(`${configPath} has an invalid ${name} field`);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
const rawColors = raw.colors && typeof raw.colors === "object" && !Array.isArray(raw.colors)
|
||||
? raw.colors : null;
|
||||
const colorKeys = [
|
||||
"background", "panel", "panelAlt", "accent", "accentAlt", "secondary",
|
||||
"highlight", "text", "muted", "line",
|
||||
];
|
||||
const appearance = choice(raw.appearance, "appearance", ["auto", "light", "dark"]);
|
||||
const surfaceOpacity = unit(raw.surfaceOpacity, "surfaceOpacity");
|
||||
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"]),
|
||||
};
|
||||
const theme = {
|
||||
schemaVersion: 1,
|
||||
id: text(raw.id, "custom", 80, "id"),
|
||||
name: text(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"),
|
||||
projectLabel: text(raw.projectLabel, "◉ 选择项目", 80, "projectLabel"),
|
||||
statusText: text(raw.statusText, "DREAM SKIN ONLINE", 80, "statusText"),
|
||||
quote: text(raw.quote, "MAKE SOMETHING WONDERFUL", 80, "quote"),
|
||||
image: raw.image,
|
||||
surfaceOpacity: surfaceOpacity ?? 1,
|
||||
colorMode: rawColors ? "explicit" : "auto",
|
||||
explicitColorKeys: rawColors ? colorKeys.filter((key) => Object.hasOwn(rawColors, key)) : [],
|
||||
colors: {
|
||||
background: color(rawColors?.background, "#071116"),
|
||||
panel: color(rawColors?.panel, "#0b1a20"),
|
||||
panelAlt: color(rawColors?.panelAlt, "#10272c"),
|
||||
accent: color(rawColors?.accent, "#7cff46"),
|
||||
accentAlt: color(rawColors?.accentAlt, "#b8ff3d"),
|
||||
secondary: color(rawColors?.secondary, "#36d7e8"),
|
||||
highlight: color(rawColors?.highlight, "#642a8c"),
|
||||
text: color(rawColors?.text, "#e9fff1"),
|
||||
muted: color(rawColors?.muted, "#9ebdb3"),
|
||||
line: color(rawColors?.line, "rgba(124, 255, 70, .28)"),
|
||||
},
|
||||
};
|
||||
if (appearance !== undefined) theme.appearance = appearance;
|
||||
if (Object.values(art).some((value) => value !== undefined)) {
|
||||
theme.art = Object.fromEntries(Object.entries(art).filter(([, value]) => value !== undefined));
|
||||
}
|
||||
const requestedImagePath = path.join(assetsRoot, theme.image);
|
||||
let imagePath;
|
||||
try {
|
||||
imagePath = await fs.realpath(requestedImagePath);
|
||||
} catch (error) {
|
||||
if (error.code === "ENOENT") throw new Error(`Theme image is missing: ${requestedImagePath}`);
|
||||
throw error;
|
||||
}
|
||||
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));
|
||||
} catch (error) {
|
||||
if (error.code === "ELOOP") throw new Error("Theme image changed into a symbolic link while loading");
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
const openedStat = await imageHandle.stat();
|
||||
if (
|
||||
!imageStat.isFile()
|
||||
|| !openedStat.isFile()
|
||||
|| imageStat.dev !== openedStat.dev
|
||||
|| imageStat.ino !== openedStat.ino
|
||||
|| openedStat.size < 1
|
||||
|| openedStat.size > MAX_ART_BYTES
|
||||
) {
|
||||
throw new Error(`Theme image must be a stable non-empty file no larger than ${MAX_ART_BYTES} bytes`);
|
||||
}
|
||||
const art = await imageHandle.readFile();
|
||||
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 };
|
||||
} finally {
|
||||
await imageHandle.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function loadStaticPayloadAssets() {
|
||||
const cacheHit = Boolean(staticPayloadAssets);
|
||||
if (!staticPayloadAssets) {
|
||||
staticPayloadAssets = Promise.all([
|
||||
fs.readFile(path.join(root, "assets", "dream-skin.css"), "utf8"),
|
||||
fs.readFile(path.join(root, "assets", "renderer-inject.js"), "utf8"),
|
||||
]).catch((error) => {
|
||||
staticPayloadAssets = null;
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
const [css, template] = await staticPayloadAssets;
|
||||
return { css, template, cacheHit };
|
||||
}
|
||||
|
||||
function invalidateStaticPayloadAssets() {
|
||||
staticPayloadAssets = null;
|
||||
}
|
||||
|
||||
async function loadPayload(themeDir) {
|
||||
const startedAt = performance.now();
|
||||
const [staticAssets, loaded] = await Promise.all([
|
||||
loadStaticPayloadAssets(),
|
||||
loadTheme(themeDir),
|
||||
]);
|
||||
const { css, template } = staticAssets;
|
||||
const { art, extension, theme } = loaded;
|
||||
const styleRevision = createHash("sha256").update(css).digest("hex").slice(0, 20);
|
||||
const artMetadata = readImageMetadata(art, extension);
|
||||
if (!artMetadata) {
|
||||
throw new Error("Theme image metadata is invalid or exceeds the 16384px / 50MP safety limit");
|
||||
}
|
||||
const artKey = createHash("sha256").update(art).digest("hex").slice(0, 20);
|
||||
theme.artMetadata = artMetadata;
|
||||
theme.artKey = artKey;
|
||||
const mime = extension === ".jpg" || extension === ".jpeg" ? "image/jpeg"
|
||||
: extension === ".webp" ? "image/webp" : "image/png";
|
||||
const artDataUrl = `data:${mime};base64,${art.toString("base64")}`;
|
||||
const payload = template
|
||||
.replace("__DREAM_SKIN_CSS_JSON__", JSON.stringify(css))
|
||||
.replace("__DREAM_SKIN_ART_JSON__", JSON.stringify(artDataUrl))
|
||||
.replace("__DREAM_SKIN_THEME_JSON__", JSON.stringify(theme))
|
||||
.replace("__DREAM_SKIN_VERSION_JSON__", JSON.stringify(SKIN_VERSION))
|
||||
.replace("__DREAM_SKIN_STYLE_REVISION_JSON__", JSON.stringify(styleRevision));
|
||||
const revision = createHash("sha256")
|
||||
.update(SKIN_VERSION)
|
||||
.update(css)
|
||||
.update(template)
|
||||
.update(JSON.stringify(theme))
|
||||
.digest("hex")
|
||||
.slice(0, 20);
|
||||
return {
|
||||
imageBytes: art.length,
|
||||
payload,
|
||||
revision,
|
||||
theme,
|
||||
timings: {
|
||||
buildMs: Number((performance.now() - startedAt).toFixed(3)),
|
||||
staticCacheHit: staticAssets.cacheHit,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function applyToSession(session, payload) {
|
||||
return session.evaluate(payload);
|
||||
}
|
||||
|
||||
async function removeFromSession(session) {
|
||||
return session.evaluate(`(() => {
|
||||
window.__CODEX_DREAM_SKIN_DISABLED__ = true;
|
||||
const state = window.__CODEX_DREAM_SKIN_STATE__;
|
||||
if (state?.cleanup) return state.cleanup();
|
||||
document.documentElement?.classList.remove('codex-dream-skin');
|
||||
document.documentElement?.style.removeProperty('--dream-skin-art');
|
||||
document.getElementById('codex-dream-skin-style')?.remove();
|
||||
document.getElementById('codex-dream-skin-chrome')?.remove();
|
||||
delete window.__CODEX_DREAM_SKIN_STATE__;
|
||||
return true;
|
||||
})()`);
|
||||
}
|
||||
|
||||
async function verifyRemovedSession(session) {
|
||||
return session.evaluate(`(() =>
|
||||
!document.documentElement.classList.contains('codex-dream-skin') &&
|
||||
!document.getElementById('codex-dream-skin-style') &&
|
||||
!document.getElementById('codex-dream-skin-chrome') &&
|
||||
!window.__CODEX_DREAM_SKIN_STATE__
|
||||
)()`);
|
||||
}
|
||||
|
||||
async function verifySession(session) {
|
||||
return session.evaluate(`(() => {
|
||||
const box = (node) => {
|
||||
if (!node) return null;
|
||||
const r = node.getBoundingClientRect();
|
||||
const style = getComputedStyle(node);
|
||||
return {
|
||||
x: Math.round(r.x), y: Math.round(r.y),
|
||||
width: Math.round(r.width), height: Math.round(r.height),
|
||||
visible: r.width > 0 && r.height > 0 && style.display !== 'none' && style.visibility !== 'hidden',
|
||||
};
|
||||
};
|
||||
const homeIndicator = document.querySelector('[data-testid="home-icon"]');
|
||||
const homeSignal = homeIndicator ?? document.querySelector('[data-feature="game-source"]') ??
|
||||
document.querySelector('.group\\\\/home-suggestions');
|
||||
const homeRoute = homeSignal?.closest('[role="main"]') ?? null;
|
||||
const home = document.querySelector('[role="main"].dream-skin-home');
|
||||
const suggestions = home?.querySelector('.group\\\\/home-suggestions') ?? null;
|
||||
const cardBoxes = suggestions ? [...suggestions.querySelectorAll('button')].map(box) : [];
|
||||
const visibleCards = cardBoxes.filter((item) => item?.visible);
|
||||
const hero = box(home?.firstElementChild?.firstElementChild?.firstElementChild);
|
||||
const projectButton = box(home?.querySelector('.group\\\\/project-selector > button'));
|
||||
const shell = box(document.querySelector('main.main-surface'));
|
||||
const composer = box(document.querySelector('.composer-surface-chrome'));
|
||||
const sidebar = box(document.querySelector('aside.app-shell-left-panel'));
|
||||
const chrome = document.getElementById('codex-dream-skin-chrome');
|
||||
const result = {
|
||||
installed: document.documentElement.classList.contains('codex-dream-skin'),
|
||||
version: window.__CODEX_DREAM_SKIN_STATE__?.version ?? null,
|
||||
stylePresent: Boolean(document.getElementById('codex-dream-skin-style')),
|
||||
chromePresent: Boolean(chrome),
|
||||
chromePointerEvents: getComputedStyle(chrome || document.body).pointerEvents,
|
||||
homeRoute: Boolean(homeRoute),
|
||||
homePresent: Boolean(home),
|
||||
hero,
|
||||
cards: cardBoxes,
|
||||
visibleCardCount: visibleCards.length,
|
||||
projectButton,
|
||||
shell,
|
||||
composer,
|
||||
sidebar,
|
||||
viewport: { width: innerWidth, height: innerHeight },
|
||||
documentOverflow: {
|
||||
x: document.documentElement.scrollWidth > document.documentElement.clientWidth,
|
||||
y: document.documentElement.scrollHeight > document.documentElement.clientHeight,
|
||||
},
|
||||
};
|
||||
const basePass = result.installed && result.version === ${JSON.stringify(SKIN_VERSION)} &&
|
||||
result.stylePresent && result.chromePresent && result.chromePointerEvents === 'none' &&
|
||||
Boolean(result.shell?.visible) && Boolean(result.sidebar?.visible) && !result.documentOverflow.x;
|
||||
// Project selector markup varies across Codex builds — soft requirement.
|
||||
const homePass = !result.homeRoute || (
|
||||
result.homePresent && result.hero?.visible && result.hero.width >= 280 && result.hero.height >= 120
|
||||
);
|
||||
result.pass = Boolean(basePass && homePass);
|
||||
result.softNotes = {
|
||||
projectButtonOptional: !result.projectButton?.visible,
|
||||
composerOptionalOnNonTaskRoutes: !result.composer?.visible,
|
||||
suggestionCardsOptional: result.homeRoute && result.visibleCardCount === 0,
|
||||
};
|
||||
return result;
|
||||
})()`);
|
||||
}
|
||||
|
||||
async function waitForVerifiedSession(session, timeoutMs) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let lastResult;
|
||||
while (Date.now() < deadline) {
|
||||
lastResult = await verifySession(session);
|
||||
if (lastResult.pass) return lastResult;
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
}
|
||||
return lastResult;
|
||||
}
|
||||
|
||||
async function capture(session, outputPath) {
|
||||
await fs.mkdir(path.dirname(outputPath), { recursive: true });
|
||||
const bestEffortInput = async (method, params) => {
|
||||
try {
|
||||
await session.send(method, params, 750);
|
||||
} catch {
|
||||
// Screenshot capture is still valid when a renderer omits the Input domain.
|
||||
}
|
||||
};
|
||||
await bestEffortInput("Input.dispatchKeyEvent", {
|
||||
type: "keyDown", key: "Escape", code: "Escape", windowsVirtualKeyCode: 27,
|
||||
});
|
||||
await bestEffortInput("Input.dispatchKeyEvent", {
|
||||
type: "keyUp", key: "Escape", code: "Escape", windowsVirtualKeyCode: 27,
|
||||
});
|
||||
const viewport = await session.evaluate("({ width: innerWidth, height: innerHeight })");
|
||||
await bestEffortInput("Input.dispatchMouseEvent", {
|
||||
type: "mouseMoved",
|
||||
x: Math.round(viewport.width * 0.64),
|
||||
y: Math.round(viewport.height * 0.62),
|
||||
button: "none",
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
const result = await session.send("Page.captureScreenshot", {
|
||||
format: "png",
|
||||
fromSurface: true,
|
||||
captureBeyondViewport: false,
|
||||
});
|
||||
await fs.writeFile(outputPath, Buffer.from(result.data, "base64"));
|
||||
}
|
||||
|
||||
async function runOneShot(options) {
|
||||
const connected = await connectCodexTargets(options.port, options.timeoutMs);
|
||||
const loaded = (options.mode === "once" || options.reload) ? await loadPayload(options.themeDir) : null;
|
||||
const payload = loaded?.payload ?? null;
|
||||
const results = [];
|
||||
let screenshotCaptured = false;
|
||||
|
||||
for (const { target, session, probe } of connected) {
|
||||
try {
|
||||
if (options.mode === "remove") await removeFromSession(session);
|
||||
else if (options.mode === "once") await applyToSession(session, payload);
|
||||
|
||||
if (options.reload) {
|
||||
await session.send("Page.reload", { ignoreCache: true });
|
||||
await new Promise((resolve) => setTimeout(resolve, 1600));
|
||||
if (options.mode !== "remove") await applyToSession(session, payload);
|
||||
}
|
||||
|
||||
const result = options.mode === "remove"
|
||||
? await verifyRemovedSession(session)
|
||||
: await waitForVerifiedSession(session, options.timeoutMs);
|
||||
results.push({ targetId: target.id, title: target.title, url: target.url, probe, result });
|
||||
|
||||
if (options.screenshot && !screenshotCaptured) {
|
||||
await capture(session, options.screenshot);
|
||||
screenshotCaptured = true;
|
||||
}
|
||||
} finally {
|
||||
session.close();
|
||||
}
|
||||
}
|
||||
|
||||
console.log(JSON.stringify({ mode: options.mode, version: SKIN_VERSION, port: options.port, targets: results }, null, 2));
|
||||
const failed = results.length === 0 || results.some((item) => options.mode === "remove" ? item.result !== true : !item.result?.pass);
|
||||
if (failed) process.exitCode = 2;
|
||||
}
|
||||
|
||||
async function flushStandardStreams() {
|
||||
const flush = (stream) => new Promise((resolve) => {
|
||||
if (!stream?.writable || stream.destroyed) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
stream.write("", resolve);
|
||||
});
|
||||
await Promise.all([flush(process.stdout), flush(process.stderr)]);
|
||||
}
|
||||
|
||||
export function earlyPayloadFor(payload, revision) {
|
||||
return `(() => {
|
||||
const generationKey = "__CODEX_DREAM_SKIN_EARLY_GENERATION__";
|
||||
const appliedKey = "__CODEX_DREAM_SKIN_EARLY_APPLIED__";
|
||||
const generation = ${JSON.stringify(revision)};
|
||||
window[generationKey] = generation;
|
||||
let observer = null;
|
||||
let timeout = null;
|
||||
const stop = () => {
|
||||
observer?.disconnect();
|
||||
observer = null;
|
||||
if (timeout) clearTimeout(timeout);
|
||||
timeout = null;
|
||||
};
|
||||
const install = () => {
|
||||
if (window[generationKey] !== generation) { stop(); return true; }
|
||||
if (!document.documentElement) return false;
|
||||
const shell = document.querySelector('main.main-surface');
|
||||
const sidebar = document.querySelector('aside.app-shell-left-panel');
|
||||
if (!shell || !sidebar) return false;
|
||||
stop();
|
||||
${payload};
|
||||
window[appliedKey] = generation;
|
||||
return true;
|
||||
};
|
||||
if (install()) return;
|
||||
if (typeof MutationObserver === "function" && document.documentElement) {
|
||||
observer = new MutationObserver(install);
|
||||
observer.observe(document.documentElement, { childList: true, subtree: true });
|
||||
}
|
||||
timeout = setTimeout(stop, 10000);
|
||||
})()`;
|
||||
}
|
||||
|
||||
function watchPayloadSources(themeDir, onDirty) {
|
||||
const assetsRoot = path.join(root, "assets");
|
||||
const themeRoot = themeDir ?? assetsRoot;
|
||||
const watchers = [];
|
||||
const add = (directory, kind) => {
|
||||
let watcher;
|
||||
try {
|
||||
watcher = watchFs(directory, { persistent: false }, (_event, filename) => {
|
||||
const name = filename ? String(filename) : "";
|
||||
const staticChanged = directory === assetsRoot &&
|
||||
(!name || name === "dream-skin.css" || name === "renderer-inject.js");
|
||||
if (kind === "static" && !staticChanged) return;
|
||||
onDirty({ staticChanged });
|
||||
});
|
||||
watcher.on("error", (error) => {
|
||||
console.error(`[dream-skin] file watch unavailable for ${directory}: ${error.message}`);
|
||||
});
|
||||
watchers.push(watcher);
|
||||
} catch (error) {
|
||||
console.error(`[dream-skin] file watch unavailable for ${directory}: ${error.message}`);
|
||||
}
|
||||
};
|
||||
add(themeRoot, "theme");
|
||||
if (themeRoot !== assetsRoot) add(assetsRoot, "static");
|
||||
return () => watchers.forEach((watcher) => watcher.close());
|
||||
}
|
||||
|
||||
async function runWatch(options) {
|
||||
let current = await loadPayload(options.themeDir);
|
||||
const sessions = new Map();
|
||||
const rejected = new Set();
|
||||
let stopping = false;
|
||||
let reloadTimer = null;
|
||||
let reloadChain = Promise.resolve();
|
||||
let discoveryDelayMs = 100;
|
||||
let lastListErrorAt = 0;
|
||||
const stop = () => { stopping = true; };
|
||||
process.on("SIGINT", stop);
|
||||
process.on("SIGTERM", stop);
|
||||
|
||||
const registerEarly = async (session, payload, revision) => {
|
||||
const result = await session.send("Page.addScriptToEvaluateOnNewDocument", {
|
||||
source: earlyPayloadFor(payload, revision),
|
||||
});
|
||||
return result.identifier ?? null;
|
||||
};
|
||||
|
||||
const removeEarly = async (record) => {
|
||||
if (!record.earlyScriptId || record.session.closed) return;
|
||||
const identifier = record.earlyScriptId;
|
||||
record.earlyScriptId = null;
|
||||
await record.session.send("Page.removeScriptToEvaluateOnNewDocument", { identifier }).catch(() => {});
|
||||
};
|
||||
|
||||
const refreshPayload = async () => {
|
||||
const next = await loadPayload(options.themeDir);
|
||||
if (next.revision === current.revision) return;
|
||||
current = next;
|
||||
for (const record of sessions.values()) {
|
||||
const { session } = record;
|
||||
if (session.closed) continue;
|
||||
try {
|
||||
const nextIdentifier = await registerEarly(session, current.payload, current.revision);
|
||||
if (record.earlyScriptId) {
|
||||
await session.send("Page.removeScriptToEvaluateOnNewDocument", {
|
||||
identifier: record.earlyScriptId,
|
||||
}).catch(() => {});
|
||||
}
|
||||
record.earlyScriptId = nextIdentifier;
|
||||
record.needsLoadFallback = !nextIdentifier;
|
||||
await applyToSession(session, current.payload);
|
||||
} catch (error) {
|
||||
record.needsLoadFallback = true;
|
||||
console.error(`[dream-skin] theme refresh failed: ${error.message}`);
|
||||
}
|
||||
}
|
||||
console.log(`[dream-skin] refreshed theme ${current.theme.id} (${current.timings.buildMs}ms)`);
|
||||
};
|
||||
|
||||
const queuePayloadRefresh = ({ staticChanged = false } = {}) => {
|
||||
if (staticChanged) invalidateStaticPayloadAssets();
|
||||
if (reloadTimer) clearTimeout(reloadTimer);
|
||||
reloadTimer = setTimeout(() => {
|
||||
reloadTimer = null;
|
||||
reloadChain = reloadChain.then(refreshPayload).catch((error) => {
|
||||
console.error(`[dream-skin] theme reload failed: ${error.message}`);
|
||||
});
|
||||
}, 45);
|
||||
};
|
||||
const closePayloadWatchers = watchPayloadSources(options.themeDir, queuePayloadRefresh);
|
||||
|
||||
try {
|
||||
while (!stopping) {
|
||||
let targets = [];
|
||||
try {
|
||||
targets = await listAppTargets(options.port);
|
||||
discoveryDelayMs = 100;
|
||||
} catch (error) {
|
||||
if (Date.now() - lastListErrorAt >= 2000) {
|
||||
console.error(`[dream-skin] ${new Date().toISOString()} ${error.message}`);
|
||||
lastListErrorAt = Date.now();
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, discoveryDelayMs));
|
||||
discoveryDelayMs = Math.min(500, Math.round(discoveryDelayMs * 1.6));
|
||||
continue;
|
||||
}
|
||||
|
||||
const activeIds = new Set(targets.map((target) => target.id));
|
||||
for (const [id, record] of sessions) {
|
||||
if (!activeIds.has(id) || record.session.closed) {
|
||||
record.session.close();
|
||||
sessions.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
for (const target of targets) {
|
||||
if (sessions.has(target.id)) continue;
|
||||
let session;
|
||||
let record;
|
||||
try {
|
||||
session = await connectTarget(target, options.port);
|
||||
record = { session, earlyScriptId: null, needsLoadFallback: false };
|
||||
try {
|
||||
record.earlyScriptId = await registerEarly(session, current.payload, current.revision);
|
||||
await session.evaluate(earlyPayloadFor(current.payload, current.revision));
|
||||
} catch (error) {
|
||||
record.needsLoadFallback = true;
|
||||
console.error(`[dream-skin] early injection unavailable: ${error.message}`);
|
||||
}
|
||||
const probe = await waitForCodexProbe(session);
|
||||
if (!probe?.codex) {
|
||||
await removeEarly(record);
|
||||
session.close();
|
||||
if (!rejected.has(target.id)) {
|
||||
console.error(`[dream-skin] rejected non-Codex app target ${target.id}`);
|
||||
rejected.add(target.id);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
rejected.delete(target.id);
|
||||
session.on("Page.loadEventFired", () => {
|
||||
if (!record.needsLoadFallback) return;
|
||||
setTimeout(() => applyToSession(session, current.payload).catch((error) => {
|
||||
console.error(`[dream-skin] fallback reinject failed: ${error.message}`);
|
||||
}), 0);
|
||||
});
|
||||
const earlyApplied = await session.evaluate(
|
||||
`window.__CODEX_DREAM_SKIN_EARLY_APPLIED__ === ${JSON.stringify(current.revision)}`,
|
||||
);
|
||||
if (!earlyApplied) {
|
||||
await session.evaluate(
|
||||
`window.__CODEX_DREAM_SKIN_EARLY_GENERATION__ = ${JSON.stringify(`fallback:${current.revision}`)}`,
|
||||
);
|
||||
await applyToSession(session, current.payload);
|
||||
}
|
||||
sessions.set(target.id, record);
|
||||
console.log(`[dream-skin] injected verified Codex target ${target.id} (${target.title || target.url})`);
|
||||
} catch (error) {
|
||||
if (record) await removeEarly(record);
|
||||
session?.close();
|
||||
console.error(`[dream-skin] inject failed for ${target.id}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
const pollDelay = sessions.size ? 800 : (targets.length ? 250 : 100);
|
||||
await new Promise((resolve) => setTimeout(resolve, pollDelay));
|
||||
}
|
||||
} finally {
|
||||
if (reloadTimer) clearTimeout(reloadTimer);
|
||||
closePayloadWatchers();
|
||||
await reloadChain.catch(() => {});
|
||||
await Promise.all([...sessions.values()].map((record) => removeEarly(record)));
|
||||
for (const record of sessions.values()) record.session.close();
|
||||
}
|
||||
}
|
||||
|
||||
if (path.resolve(process.argv[1] || "") === path.resolve(scriptPath)) {
|
||||
let options = null;
|
||||
try {
|
||||
options = parseArgs(process.argv.slice(2));
|
||||
if (options.mode === "check") {
|
||||
const loaded = await loadPayload(options.themeDir);
|
||||
console.log(JSON.stringify({
|
||||
pass: true,
|
||||
version: SKIN_VERSION,
|
||||
themeId: loaded.theme.id,
|
||||
themeName: loaded.theme.name,
|
||||
imageBytes: loaded.imageBytes,
|
||||
payloadBytes: Buffer.byteLength(loaded.payload),
|
||||
artMetadata: loaded.theme.artMetadata ?? null,
|
||||
timings: loaded.timings,
|
||||
}, null, 2));
|
||||
} else if (options.mode === "watch") await runWatch(options);
|
||||
else {
|
||||
await runOneShot(options);
|
||||
await flushStandardStreams();
|
||||
process.exit(process.exitCode ?? 0);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[dream-skin] ${error.stack || error.message}`);
|
||||
process.exitCode = 1;
|
||||
if (options?.mode && options.mode !== "watch" && options.mode !== "check") {
|
||||
await flushStandardStreams();
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import fs from "node:fs/promises";
|
||||
import { constants as fsConstants } from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const [sourceDirArg, stageDirArg] = process.argv.slice(2);
|
||||
if (!sourceDirArg || !stageDirArg) {
|
||||
throw new Error("Usage: stage-theme.mjs <source-theme-dir> <stage-dir>");
|
||||
}
|
||||
|
||||
const MAX_CONFIG_BYTES = 1024 * 1024;
|
||||
const MAX_IMAGE_BYTES = 16 * 1024 * 1024;
|
||||
const OPEN_FLAGS = fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0);
|
||||
|
||||
function assertContained(rootPath, candidatePath, label) {
|
||||
const relative = path.relative(rootPath, candidatePath);
|
||||
if (
|
||||
relative === ""
|
||||
|| (!path.isAbsolute(relative) && relative !== ".." && !relative.startsWith(`..${path.sep}`))
|
||||
) return;
|
||||
throw new Error(`${label} must stay inside its theme directory`);
|
||||
}
|
||||
|
||||
function sameStat(left, right) {
|
||||
return left.isFile() && right.isFile()
|
||||
&& left.dev === right.dev
|
||||
&& left.ino === right.ino
|
||||
&& left.size === right.size
|
||||
&& left.mtimeMs === right.mtimeMs
|
||||
&& left.ctimeMs === right.ctimeMs;
|
||||
}
|
||||
|
||||
async function readStableFile(filePath, label, maxBytes) {
|
||||
let handle;
|
||||
try {
|
||||
handle = await fs.open(filePath, OPEN_FLAGS);
|
||||
} catch (error) {
|
||||
if (error.code === "ELOOP") throw new Error(`${label} must not be a symbolic link`);
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
const before = await handle.stat();
|
||||
if (!before.isFile()) throw new Error(`${label} must be a regular file`);
|
||||
if (before.size > maxBytes) throw new Error(`${label} is larger than ${maxBytes} bytes`);
|
||||
const bytes = await handle.readFile();
|
||||
const after = await handle.stat();
|
||||
if (!sameStat(before, after)) {
|
||||
throw new Error(`${label} changed while it was being staged`);
|
||||
}
|
||||
if (bytes.length > maxBytes) throw new Error(`${label} is larger than ${maxBytes} bytes`);
|
||||
return { bytes, stat: after };
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
|
||||
function decodeJson(bytes, label) {
|
||||
const text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
||||
if (text.includes("\0")) throw new Error(`${label} contains NUL characters`);
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
throw new Error(`${label} is not valid JSON`);
|
||||
}
|
||||
}
|
||||
|
||||
async function writeExclusive(filePath, bytes) {
|
||||
const temporary = `${filePath}.${process.pid}.tmp`;
|
||||
try {
|
||||
await fs.writeFile(temporary, bytes, { flag: "wx", mode: 0o600 });
|
||||
await fs.rename(temporary, filePath);
|
||||
} finally {
|
||||
await fs.rm(temporary, { force: true }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const sourceRoot = await fs.realpath(sourceDirArg);
|
||||
const sourceStat = await fs.stat(sourceRoot);
|
||||
if (!sourceStat.isDirectory()) throw new Error("Theme source must be a directory");
|
||||
|
||||
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 (path.basename(theme.image) !== theme.image) {
|
||||
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");
|
||||
const image = await readStableFile(imagePath, "Theme image", MAX_IMAGE_BYTES);
|
||||
if (image.bytes.length < 1) throw new Error("Theme image is empty");
|
||||
|
||||
const stageRoot = await fs.realpath(stageDirArg);
|
||||
const stageStat = await fs.stat(stageRoot);
|
||||
if (!stageStat.isDirectory()) throw new Error("Theme stage must be a directory");
|
||||
assertContained(stageRoot, path.join(stageRoot, "theme.json"), "Staged theme config");
|
||||
assertContained(stageRoot, path.join(stageRoot, theme.image), "Staged theme image");
|
||||
|
||||
// Write both files from the already-open, stable descriptors. The caller
|
||||
// publishes the image first and theme.json last, so the watcher only ever
|
||||
// observes a complete pair; subsequent source edits cannot race the copy.
|
||||
await writeExclusive(path.join(stageRoot, theme.image), image.bytes);
|
||||
await writeExclusive(path.join(stageRoot, "theme.json"), config.bytes);
|
||||
process.stdout.write(theme.image);
|
||||
}
|
||||
|
||||
await main();
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 117 KiB |
@@ -0,0 +1,92 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
MAX_IMAGE_DIMENSION,
|
||||
MAX_IMAGE_PIXELS,
|
||||
classifyImageDimensions,
|
||||
readImageMetadata,
|
||||
} from "../scripts/image-metadata.mjs";
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const macosRoot = path.resolve(here, "..");
|
||||
|
||||
const fixture = await fs.readFile(path.join(macosRoot, "tests", "fixtures", "background.jpg"));
|
||||
assert.deepEqual(readImageMetadata(fixture, ".jpg"), {
|
||||
width: 1920,
|
||||
height: 1200,
|
||||
ratio: 1.6,
|
||||
wide: false,
|
||||
aspect: "wide",
|
||||
taskMode: "ambient",
|
||||
});
|
||||
const malformedJpeg = Buffer.from(fixture);
|
||||
malformedJpeg[0] = 0;
|
||||
assert.equal(readImageMetadata(malformedJpeg, ".jpg"), null);
|
||||
|
||||
assert.deepEqual(classifyImageDimensions({ width: 2400, height: 1350 }), {
|
||||
width: 2400,
|
||||
height: 1350,
|
||||
ratio: 2400 / 1350,
|
||||
wide: true,
|
||||
aspect: "wide",
|
||||
taskMode: "ambient",
|
||||
});
|
||||
assert.equal(MAX_IMAGE_DIMENSION, 16384);
|
||||
assert.equal(MAX_IMAGE_PIXELS, 50_000_000);
|
||||
assert.equal(classifyImageDimensions({ width: 10000, height: 6000 }), null);
|
||||
assert.equal(classifyImageDimensions({ width: 20000, height: 1 }), null);
|
||||
assert.equal(classifyImageDimensions({ width: 2560.5, height: 1440 }), null);
|
||||
|
||||
const writeAscii = (bytes, offset, value) => {
|
||||
for (let index = 0; index < value.length; index += 1) bytes[offset + index] = value.charCodeAt(index);
|
||||
};
|
||||
const writeUint32Le = (bytes, offset, value) => {
|
||||
bytes[offset] = value & 0xff;
|
||||
bytes[offset + 1] = (value >>> 8) & 0xff;
|
||||
bytes[offset + 2] = (value >>> 16) & 0xff;
|
||||
bytes[offset + 3] = (value >>> 24) & 0xff;
|
||||
};
|
||||
const writeUint24Le = (bytes, offset, value) => {
|
||||
bytes[offset] = value & 0xff;
|
||||
bytes[offset + 1] = (value >>> 8) & 0xff;
|
||||
bytes[offset + 2] = (value >>> 16) & 0xff;
|
||||
};
|
||||
|
||||
const vp8l = new Uint8Array(26);
|
||||
writeAscii(vp8l, 0, "RIFF");
|
||||
writeUint32Le(vp8l, 4, vp8l.length - 8);
|
||||
writeAscii(vp8l, 8, "WEBP");
|
||||
writeAscii(vp8l, 12, "VP8L");
|
||||
writeUint32Le(vp8l, 16, 5);
|
||||
vp8l.set([0x2f, 0x7f, 0xc2, 0x59, 0x00], 20);
|
||||
assert.deepEqual(readImageMetadata(vp8l, ".webp"), {
|
||||
width: 640,
|
||||
height: 360,
|
||||
ratio: 640 / 360,
|
||||
wide: true,
|
||||
aspect: "wide",
|
||||
taskMode: "ambient",
|
||||
});
|
||||
|
||||
const vp8x = new Uint8Array(30);
|
||||
writeAscii(vp8x, 0, "RIFF");
|
||||
writeUint32Le(vp8x, 4, vp8x.length - 8);
|
||||
writeAscii(vp8x, 8, "WEBP");
|
||||
writeAscii(vp8x, 12, "VP8X");
|
||||
writeUint32Le(vp8x, 16, 10);
|
||||
writeUint24Le(vp8x, 24, 2559);
|
||||
writeUint24Le(vp8x, 27, 1439);
|
||||
assert.deepEqual(readImageMetadata(vp8x, ".webp"), {
|
||||
width: 2560,
|
||||
height: 1440,
|
||||
ratio: 2560 / 1440,
|
||||
wide: true,
|
||||
aspect: "wide",
|
||||
taskMode: "ambient",
|
||||
});
|
||||
|
||||
assert.equal(readImageMetadata(new Uint8Array([0, 1, 2, 3]), ".png"), null);
|
||||
|
||||
console.log("PASS: image dimensions strictly classify PNG, JPEG, VP8L, and VP8X profiles.");
|
||||
@@ -0,0 +1,85 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import vm from "node:vm";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { earlyPayloadFor } from "../scripts/injector.mjs";
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const injectorPath = path.resolve(here, "../scripts/injector.mjs");
|
||||
const source = await fs.readFile(injectorPath, "utf8");
|
||||
|
||||
function createFixture() {
|
||||
const observers = [];
|
||||
const timers = new Map();
|
||||
let nextTimer = 1;
|
||||
const markers = { shell: false, sidebar: false };
|
||||
const context = {
|
||||
window: { installs: [] },
|
||||
document: {
|
||||
documentElement: {},
|
||||
querySelector(selector) {
|
||||
if (selector === "main.main-surface") return markers.shell ? {} : null;
|
||||
if (selector === "aside.app-shell-left-panel") return markers.sidebar ? {} : null;
|
||||
return null;
|
||||
},
|
||||
},
|
||||
MutationObserver: class {
|
||||
constructor(callback) {
|
||||
this.callback = callback;
|
||||
this.connected = true;
|
||||
observers.push(this);
|
||||
}
|
||||
observe() {}
|
||||
disconnect() { this.connected = false; }
|
||||
},
|
||||
setTimeout(callback) {
|
||||
const id = nextTimer++;
|
||||
timers.set(id, callback);
|
||||
return id;
|
||||
},
|
||||
clearTimeout(id) { timers.delete(id); },
|
||||
};
|
||||
return { context, markers, observers };
|
||||
}
|
||||
|
||||
const guarded = createFixture();
|
||||
vm.runInNewContext(earlyPayloadFor('window.installs.push("guarded")', "guarded"), guarded.context);
|
||||
assert.deepEqual(guarded.context.window.installs, [], "Auxiliary app targets must remain untouched.");
|
||||
guarded.markers.shell = true;
|
||||
guarded.observers[0].callback([]);
|
||||
assert.deepEqual(guarded.context.window.installs, [], "A main surface without the Codex sidebar is not sufficient.");
|
||||
|
||||
const generations = createFixture();
|
||||
vm.runInNewContext(earlyPayloadFor('window.installs.push("old")', "old"), generations.context);
|
||||
vm.runInNewContext(earlyPayloadFor('window.installs.push("new")', "new"), generations.context);
|
||||
generations.markers.shell = true;
|
||||
generations.markers.sidebar = true;
|
||||
for (const observer of generations.observers) observer.callback([]);
|
||||
assert.deepEqual(
|
||||
generations.context.window.installs,
|
||||
["new"],
|
||||
"A stale early script must yield to the newest watcher generation.",
|
||||
);
|
||||
assert.equal(generations.context.window.__CODEX_DREAM_SKIN_EARLY_APPLIED__, "new");
|
||||
|
||||
const discoveryStart = source.indexOf("record.earlyScriptId = await registerEarly");
|
||||
const probeStart = source.indexOf("const probe = await waitForCodexProbe", discoveryStart);
|
||||
assert.ok(discoveryStart >= 0 && probeStart > discoveryStart, "Early registration must happen before full shell probing.");
|
||||
assert.match(
|
||||
source,
|
||||
/finally\s*\{[\s\S]*Promise\.all\(\[\.\.\.sessions\.values\(\)\][\s\S]*removeEarly\(record\)/,
|
||||
"Watcher shutdown must unregister persistent Page scripts before closing CDP sessions.",
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/const earlyApplied = await session\.evaluate\([\s\S]*if \(!earlyApplied\) \{[\s\S]*applyToSession/,
|
||||
"The watcher must not run the full payload twice after a successful early install.",
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/await runOneShot\(options\);[\s\S]{0,160}await flushStandardStreams\(\);[\s\S]{0,100}process\.exit\(process\.exitCode \?\? 0\)/,
|
||||
"One-shot commands must exit after flushing output so a lingering CDP close handshake cannot block Codex-X.",
|
||||
);
|
||||
|
||||
console.log("PASS: early injection is shell-guarded, generation-safe, and removed on shutdown.");
|
||||
@@ -0,0 +1,613 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import vm from "node:vm";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const macosRoot = path.resolve(here, "..");
|
||||
const template = await fs.readFile(path.join(macosRoot, "assets", "renderer-inject.js"), "utf8");
|
||||
const css = await fs.readFile(path.join(macosRoot, "assets", "dream-skin.css"), "utf8");
|
||||
|
||||
assert.doesNotMatch(
|
||||
css,
|
||||
/background-image:\s*var\(--dream-skin-art\),\s*var\(--dream-skin-art\)/,
|
||||
"The home hero must not stack duplicate copies of the selected image.",
|
||||
);
|
||||
assert.match(
|
||||
css,
|
||||
/data-dream-art-safe="left"[\s\S]{0,140}--ds-art-position:\s*100% var\(--ds-focus-y\);/,
|
||||
"A left text-safe image must preserve its right-side subject on narrower windows.",
|
||||
);
|
||||
assert.match(
|
||||
css,
|
||||
/\.dream-skin-home > div:has\(\[data-feature="game-source"\]\) > div:first-child > div:first-child\s*\{[\s\S]{0,700}background-position:\s*var\(--ds-home-art-position\) !important;/,
|
||||
"The home hero must use its route-specific focal point.",
|
||||
);
|
||||
assert.match(
|
||||
css,
|
||||
/\.dream-skin-home > div:has\(\[data-feature="game-source"\]\)\s*\{[\s\S]{0,120}min-height:\s*100% !important;/,
|
||||
"The home layout must target the content container instead of an inserted banner sibling.",
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
css,
|
||||
/\.dream-skin-home > div:first-child\s*\{[\s\S]{0,120}min-height:\s*100% !important;/,
|
||||
"An empty home banner container must not push the composer below the viewport.",
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
css,
|
||||
/background-size:\s*auto 100% !important;/,
|
||||
"Wide home artwork must not leave an unpainted half-card by fitting only to height.",
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
css,
|
||||
/background-size:\s*100% 100%,\s*100% 100%,\s*100% auto;/,
|
||||
"Wide task artwork must cover the full route instead of ending above the composer.",
|
||||
);
|
||||
assert.match(
|
||||
css,
|
||||
/data-dream-art-task-mode="ambient"[\s\S]{0,500}body\s*\{[\s\S]{0,500}background-image:\s*var\(--dream-skin-art\) !important;[\s\S]{0,200}background-size:\s*cover !important;/,
|
||||
"Wide ambient task artwork should cover the full application window.",
|
||||
);
|
||||
assert.match(
|
||||
css,
|
||||
/data-dream-task-mode="banner"[\s\S]{0,900}body\s*\{[\s\S]{0,500}background-image:\s*var\(--dream-skin-art\) !important;[\s\S]{0,200}background-size:\s*cover !important;/,
|
||||
"Wide banner task artwork should use the same full-window wallpaper contract as ambient routes.",
|
||||
);
|
||||
assert.match(
|
||||
css,
|
||||
/data-dream-art-wide="true"\]:has\(main\.main-surface\.dream-skin-home-shell\)[\s\S]{0,100}body\s*\{[\s\S]{0,300}background-image:\s*var\(--dream-skin-art\) !important;/,
|
||||
"Wide home artwork should use the same full-window image as utility routes.",
|
||||
);
|
||||
assert.match(
|
||||
css,
|
||||
/data-dream-art-wide="true"\]:has\(main\.main-surface\.dream-skin-home-shell\)[\s\S]{0,120}body\s*\{[\s\S]{0,260}background-position:\s*var\(--ds-art-position\) !important;/,
|
||||
"Wide home artwork must honor the configured focal point instead of forcing a centered crop.",
|
||||
);
|
||||
assert.match(
|
||||
css,
|
||||
/data-dream-art-task-mode="ambient"[\s\S]{0,260}data-dream-art-wide="true"\]:has\(main\.main-surface:not\(\.dream-skin-home-shell\)\)[\s\S]{0,120}body\s*\{[\s\S]{0,260}background-position:\s*var\(--ds-art-position\) !important;/,
|
||||
"Wide task artwork must retain the same focal point as the home route.",
|
||||
);
|
||||
assert.match(
|
||||
css,
|
||||
/data-dream-art-wide="true"\]\s+\.composer-surface-chrome\s*\{[\s\S]{0,500}backdrop-filter:\s*none !important;/,
|
||||
"Wide artwork should use one uniform composer surface without a split blur layer.",
|
||||
);
|
||||
assert.match(
|
||||
css,
|
||||
/--ds-immersive-composer-solid:\s*rgb\(var\(--ds-panel-rgb\) \/ \.94\);/,
|
||||
"The light composer should remain opaque enough to prevent thread text from showing through.",
|
||||
);
|
||||
assert.match(
|
||||
css,
|
||||
/--ds-task-immersive-edge:\s*rgb\(var\(--ds-panel-rgb\) \/ calc\(\.86 \* var\(--ds-surface-opacity\)\)\);/,
|
||||
"Wide task surfaces should expose the wallpaper according to the configured opacity.",
|
||||
);
|
||||
assert.match(
|
||||
css,
|
||||
/--ds-task-fade:\s*linear-gradient\(180deg,[\s\S]{0,400}var\(--ds-surface-opacity\)/,
|
||||
"Regular landscape artwork should apply the configured opacity to its task scrim.",
|
||||
);
|
||||
assert.match(
|
||||
css,
|
||||
/main\.main-surface:not\(\.dream-skin-home-shell\)::before\s*\{[\s\S]{0,420}opacity:\s*1;/,
|
||||
"The wallpaper layer must keep its original colors instead of blending through a white base.",
|
||||
);
|
||||
const homeSuggestionRuleStart = css.indexOf(
|
||||
".dream-skin-home .group\\/home-suggestions button {",
|
||||
);
|
||||
assert.notEqual(homeSuggestionRuleStart, -1, "The home suggestion card rule must exist.");
|
||||
const homeSuggestionRuleEnd = css.indexOf("}", homeSuggestionRuleStart);
|
||||
const homeSuggestionRule = css.slice(homeSuggestionRuleStart, homeSuggestionRuleEnd);
|
||||
assert.match(
|
||||
homeSuggestionRule,
|
||||
/background:\s*rgb\(var\(--ds-panel-rgb\) \/ calc\(\.56 \* var\(--ds-surface-opacity\)\)\) !important;/,
|
||||
"Home suggestion cards must use the shared surface opacity instead of a fixed white layer.",
|
||||
);
|
||||
assert.match(
|
||||
css,
|
||||
/--ds-composer-surface:\s*rgb\(var\(--ds-panel-rgb\) \/ calc\(\.82 \+ \(\.12 \* var\(--ds-surface-opacity\)\)\)\);/,
|
||||
"The composer must keep a readable minimum opacity while responding to the skin setting.",
|
||||
);
|
||||
assert.match(
|
||||
css,
|
||||
/html\.codex-dream-skin \.composer-surface-chrome\s*\{[\s\S]{0,100}overflow:\s*hidden !important;/,
|
||||
"Composer internals must be clipped to the rounded input surface.",
|
||||
);
|
||||
assert.match(
|
||||
css,
|
||||
/data-dream-shell="light"\]\[data-dream-art-wide="true"\][\s\S]{0,100}\.composer-surface-chrome\s*\{[\s\S]{0,400}backdrop-filter:\s*blur\(8px\) saturate\(102%\) !important;/,
|
||||
"The translucent light composer should softly separate text from detailed artwork.",
|
||||
);
|
||||
assert.match(
|
||||
template,
|
||||
/\[class\*="_homeUtilityBar_"\][\s\S]{0,500}dream-skin-home-utility/,
|
||||
"The renderer should give the current native home utility bar a stable theme class.",
|
||||
);
|
||||
assert.match(
|
||||
template,
|
||||
/\[data-feature="game-source"\][\s\S]{0,180}\[data-codex-composer-root\]/,
|
||||
"Home detection should use stable semantic markers instead of optional suggestion cards.",
|
||||
);
|
||||
assert.match(
|
||||
css,
|
||||
/\.dream-skin-home:has\(\.dream-skin-home-utility\)[\s\S]{0,120}\.composer-surface-chrome\s*\{[\s\S]{0,180}border-radius:\s*0 0 22px 22px !important;/,
|
||||
"The home utility bar and composer should render as one continuous control.",
|
||||
);
|
||||
assert.match(
|
||||
css,
|
||||
/\.composer-surface-chrome button:not\(\[class~="bg-token-foreground"\]\)[\s\S]{0,100}color:\s*var\(--ds-muted\) !important;/,
|
||||
"Composer controls must remain readable when Codex native tokens lag behind a forced dark appearance.",
|
||||
);
|
||||
assert.match(
|
||||
css,
|
||||
/\.composer-surface-chrome button:not\(\[class~="bg-token-foreground"\]\) \*\s*\{[\s\S]{0,80}color:\s*currentColor !important;/,
|
||||
"Nested labels inside composer controls must inherit the corrected theme color.",
|
||||
);
|
||||
assert.match(
|
||||
css,
|
||||
/\.composer-surface-chrome p\.placeholder::after\s*\{[\s\S]{0,120}color:\s*rgb\(var\(--ds-muted-rgb\) \/ \.82\) !important;[\s\S]{0,80}opacity:\s*1 !important;/,
|
||||
"Composer placeholder text must not inherit a stale native color with double opacity.",
|
||||
);
|
||||
assert.match(
|
||||
css,
|
||||
/header\.app-header-tint\s*\{[\s\S]{0,180}background:\s*transparent !important;/,
|
||||
"Wide artwork should not paint a separate opaque header band.",
|
||||
);
|
||||
assert.match(
|
||||
css,
|
||||
/\.thread-scroll-container \.bg-gradient-to-t\.from-token-main-surface-primary\s*\{[\s\S]{0,100}background:\s*transparent !important;/,
|
||||
"Wide artwork should remove the native opaque fade behind the sticky composer.",
|
||||
);
|
||||
assert.match(
|
||||
css,
|
||||
/div\.sticky:has\(input\[type="text"\]\)[\s\S]{0,100}background:\s*transparent !important;/,
|
||||
"Search routes should not retain the native opaque sticky band.",
|
||||
);
|
||||
assert.match(
|
||||
css,
|
||||
/\[class~="bg-token-main-surface-primary"\]\[class~="h-full"\]\[class~="w-full"\][\s\S]{0,100}background:\s*transparent !important;/,
|
||||
"Full-size utility route wrappers should not hide the selected artwork.",
|
||||
);
|
||||
|
||||
function createStyleDeclaration() {
|
||||
const values = new Map();
|
||||
return {
|
||||
values,
|
||||
getPropertyValue(name) { return values.get(name) ?? ""; },
|
||||
setProperty(name, value) { values.set(name, value); },
|
||||
removeProperty(name) { values.delete(name); },
|
||||
};
|
||||
}
|
||||
|
||||
function createClassList(initial = []) {
|
||||
const values = new Set(initial);
|
||||
return {
|
||||
values,
|
||||
add(...names) { for (const name of names) values.add(name); },
|
||||
remove(...names) { for (const name of names) values.delete(name); },
|
||||
contains(name) { return values.has(name); },
|
||||
toggle(name, enabled) {
|
||||
if (enabled) values.add(name);
|
||||
else values.delete(name);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createFixture(theme, {
|
||||
nativeShell = "light",
|
||||
analysisFixture = null,
|
||||
analysisCache = null,
|
||||
} = {}) {
|
||||
let fixtureShell = nativeShell;
|
||||
const nodes = new Map();
|
||||
const attributes = new Map();
|
||||
const bodyAttributes = new Map();
|
||||
const observers = [];
|
||||
const resizeObservers = [];
|
||||
const timers = new Map();
|
||||
let nextTimer = 1;
|
||||
let nextBlob = 1;
|
||||
const rootStyle = createStyleDeclaration();
|
||||
const root = {
|
||||
className: nativeShell === "dark" ? "electron-dark" : "electron-light",
|
||||
classList: createClassList(),
|
||||
style: rootStyle,
|
||||
appendChild(node) {
|
||||
node.parentElement = root;
|
||||
if (node.id) nodes.set(node.id, node);
|
||||
},
|
||||
getAttribute(name) { return attributes.get(name) ?? null; },
|
||||
setAttribute(name, value) { attributes.set(name, String(value)); },
|
||||
removeAttribute(name) { attributes.delete(name); },
|
||||
};
|
||||
const body = {
|
||||
className: "",
|
||||
appendChild(node) {
|
||||
node.parentElement = body;
|
||||
if (node.id) nodes.set(node.id, node);
|
||||
},
|
||||
getAttribute(name) { return bodyAttributes.get(name) ?? null; },
|
||||
setAttribute(name, value) { bodyAttributes.set(name, String(value)); },
|
||||
};
|
||||
const shellBox = { left: 280, top: 36, width: 1000, height: 764 };
|
||||
const shellMain = {
|
||||
classList: createClassList(),
|
||||
getBoundingClientRect() {
|
||||
return { ...shellBox };
|
||||
},
|
||||
};
|
||||
|
||||
const createElement = (tagName) => {
|
||||
if (tagName === "canvas" && analysisFixture) {
|
||||
return {
|
||||
width: 0,
|
||||
height: 0,
|
||||
getContext() {
|
||||
return {
|
||||
drawImage() {},
|
||||
getImageData() { return { data: analysisFixture.pixels }; },
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
const childNodes = new Map();
|
||||
const element = {
|
||||
id: "",
|
||||
dataset: {},
|
||||
style: createStyleDeclaration(),
|
||||
classList: createClassList(),
|
||||
parentElement: null,
|
||||
textContent: "",
|
||||
innerHTML: "",
|
||||
setAttribute() {},
|
||||
querySelector(selector) {
|
||||
if (!childNodes.has(selector)) childNodes.set(selector, { textContent: "" });
|
||||
return childNodes.get(selector);
|
||||
},
|
||||
remove() { if (element.id) nodes.delete(element.id); },
|
||||
};
|
||||
return element;
|
||||
};
|
||||
|
||||
const document = {
|
||||
documentElement: root,
|
||||
head: root,
|
||||
body,
|
||||
createElement,
|
||||
getElementById(id) { return nodes.get(id) ?? null; },
|
||||
querySelector(selector) {
|
||||
if (selector === "main.main-surface" || selector === "main") return shellMain;
|
||||
return null;
|
||||
},
|
||||
querySelectorAll() { return []; },
|
||||
};
|
||||
const mediaQuery = {
|
||||
matches: false,
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
};
|
||||
const revokedUrls = [];
|
||||
const window = {
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
matchMedia() {
|
||||
mediaQuery.matches = fixtureShell === "dark";
|
||||
return mediaQuery;
|
||||
},
|
||||
};
|
||||
if (analysisCache) window.__CODEX_DREAM_SKIN_ANALYSIS_CACHE__ = analysisCache;
|
||||
if (analysisFixture) {
|
||||
window.Image = class {
|
||||
naturalWidth = analysisFixture.naturalWidth;
|
||||
naturalHeight = analysisFixture.naturalHeight;
|
||||
set src(_) { this.onload(); }
|
||||
};
|
||||
}
|
||||
const context = {
|
||||
window,
|
||||
document,
|
||||
MutationObserver: class {
|
||||
constructor(callback) {
|
||||
this.callback = callback;
|
||||
observers.push(this);
|
||||
}
|
||||
observe() {}
|
||||
disconnect() {}
|
||||
},
|
||||
ResizeObserver: class {
|
||||
constructor(callback) {
|
||||
this.callback = callback;
|
||||
this.target = null;
|
||||
resizeObservers.push(this);
|
||||
}
|
||||
observe(target) { this.target = target; }
|
||||
disconnect() { this.target = null; }
|
||||
},
|
||||
URL: {
|
||||
createObjectURL() { return `blob:fixture-${nextBlob++}`; },
|
||||
revokeObjectURL(value) { revokedUrls.push(value); },
|
||||
},
|
||||
Blob,
|
||||
Uint8Array,
|
||||
atob,
|
||||
getComputedStyle() {
|
||||
const skinShell = root.classList.contains("codex-dream-skin")
|
||||
? (attributes.get("data-dream-shell") || "dark") : fixtureShell;
|
||||
return {
|
||||
colorScheme: skinShell,
|
||||
backgroundColor: fixtureShell === "dark" ? "rgb(24, 24, 27)" : "rgb(250, 250, 250)",
|
||||
};
|
||||
},
|
||||
setInterval: () => 1,
|
||||
clearInterval() {},
|
||||
setTimeout(callback, delay) {
|
||||
const id = ++nextTimer;
|
||||
timers.set(id, { callback, delay });
|
||||
return id;
|
||||
},
|
||||
clearTimeout(id) { timers.delete(id); },
|
||||
cancelAnimationFrame() {},
|
||||
};
|
||||
const payloadFor = (nextTheme, cssText = ".fixture { color: blue; }") => template
|
||||
.replace("__DREAM_SKIN_CSS_JSON__", JSON.stringify(cssText))
|
||||
.replace("__DREAM_SKIN_ART_JSON__", JSON.stringify("data:image/png;base64,AA=="))
|
||||
.replace("__DREAM_SKIN_THEME_JSON__", JSON.stringify(nextTheme))
|
||||
.replace("__DREAM_SKIN_VERSION_JSON__", JSON.stringify("test"))
|
||||
.replace("__DREAM_SKIN_STYLE_REVISION_JSON__", JSON.stringify(cssText));
|
||||
const flushTimers = (maximumDelay = Infinity) => {
|
||||
const pending = [...timers.entries()].filter(([, timer]) => timer.delay <= maximumDelay);
|
||||
for (const [id, timer] of pending) {
|
||||
timers.delete(id);
|
||||
timer.callback();
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
attributes,
|
||||
body,
|
||||
bodyAttributes,
|
||||
context,
|
||||
flushTimers,
|
||||
nodes,
|
||||
observers,
|
||||
payload: payloadFor(theme),
|
||||
payloadFor,
|
||||
revokedUrls,
|
||||
resizeObservers,
|
||||
root,
|
||||
rootStyle,
|
||||
shellBox,
|
||||
timers,
|
||||
window,
|
||||
setNativeShell(value) { fixtureShell = value; },
|
||||
};
|
||||
}
|
||||
|
||||
const defaults = createFixture({
|
||||
id: "default-contract",
|
||||
appearance: "auto",
|
||||
art: { safeArea: "auto", taskMode: "auto" },
|
||||
});
|
||||
const defaultResult = vm.runInNewContext(defaults.payload, defaults.context);
|
||||
assert.equal(defaultResult.installed, true);
|
||||
assert.equal(defaults.attributes.get("data-dream-shell"), "light");
|
||||
assert.equal(defaults.attributes.get("data-dream-art-safe-area"), "center");
|
||||
assert.equal(defaults.attributes.get("data-dream-art-task-mode"), "ambient");
|
||||
assert.equal(defaults.attributes.get("data-dream-art-ready"), "false");
|
||||
assert.equal(defaults.rootStyle.values.get("--dream-art-position"), "50.00% 50.00%");
|
||||
assert.equal(defaults.rootStyle.values.get("--ds-surface-opacity"), "1.00");
|
||||
const defaultMetrics = defaults.window.__CODEX_DREAM_SKIN_STATE__.metrics;
|
||||
assert.equal(defaultMetrics.rootPasses, 1);
|
||||
assert.equal(defaultMetrics.routePasses, 1);
|
||||
assert.equal(defaultMetrics.layoutReads, 1);
|
||||
for (let index = 0; index < 50; index += 1) defaults.observers[0].callback([]);
|
||||
assert.equal(defaults.timers.size, 1, "Mutation bursts should coalesce into one scheduled ensure.");
|
||||
defaults.flushTimers(64);
|
||||
assert.equal(defaultMetrics.rootPasses, 1, "Subtree mutations must not recompute root theme tokens.");
|
||||
assert.equal(defaultMetrics.routePasses, 2);
|
||||
assert.equal(defaultMetrics.layoutReads, 1, "Subtree mutations must not force shell layout reads.");
|
||||
assert.equal(defaults.resizeObservers.length, 1);
|
||||
assert.ok(defaults.resizeObservers[0].target);
|
||||
defaults.shellBox.left = 196;
|
||||
defaults.shellBox.width = 1084;
|
||||
defaults.resizeObservers[0].callback([]);
|
||||
defaults.flushTimers(64);
|
||||
assert.equal(defaultMetrics.layoutReads, 2, "Shell ResizeObserver changes must refresh chrome geometry.");
|
||||
const defaultChrome = defaults.nodes.get("codex-dream-skin-chrome");
|
||||
assert.equal(defaultChrome.style.values.get("left"), "196px");
|
||||
assert.equal(defaultChrome.style.values.get("width"), "1084px");
|
||||
|
||||
// Auto appearance must continue following the native shell after the skin is
|
||||
// already installed. The fixture makes the injected root color-scheme win
|
||||
// whenever our class remains on <html>, so a temporary native probe is needed
|
||||
// for each light → dark → light transition.
|
||||
const shellFollow = createFixture({
|
||||
id: "shell-follow",
|
||||
appearance: "auto",
|
||||
art: { safeArea: "auto", taskMode: "auto" },
|
||||
});
|
||||
shellFollow.root.className = "";
|
||||
vm.runInNewContext(shellFollow.payload, shellFollow.context);
|
||||
assert.equal(shellFollow.attributes.get("data-dream-shell"), "light");
|
||||
shellFollow.setNativeShell("dark");
|
||||
shellFollow.window.__CODEX_DREAM_SKIN_STATE__.ensure();
|
||||
assert.equal(shellFollow.attributes.get("data-dream-shell"), "dark");
|
||||
shellFollow.setNativeShell("light");
|
||||
shellFollow.window.__CODEX_DREAM_SKIN_STATE__.ensure();
|
||||
assert.equal(shellFollow.attributes.get("data-dream-shell"), "light");
|
||||
|
||||
defaults.root.className = "";
|
||||
defaults.body.setAttribute("data-theme", "dark");
|
||||
defaults.observers[1].callback([{ type: "attributes", target: defaults.body }]);
|
||||
defaults.flushTimers(64);
|
||||
assert.equal(defaults.attributes.get("data-dream-shell"), "dark", "Body theme changes must apply without the fallback interval.");
|
||||
|
||||
const synchronousWide = createFixture({
|
||||
id: "synchronous-wide",
|
||||
appearance: "auto",
|
||||
art: { safeArea: "auto", taskMode: "auto" },
|
||||
artKey: "wide-art",
|
||||
artMetadata: {
|
||||
width: 2400,
|
||||
height: 1350,
|
||||
ratio: 2400 / 1350,
|
||||
wide: true,
|
||||
aspect: "wide",
|
||||
taskMode: "ambient",
|
||||
},
|
||||
});
|
||||
vm.runInNewContext(synchronousWide.payload, synchronousWide.context);
|
||||
assert.equal(synchronousWide.attributes.get("data-dream-art-wide"), "true");
|
||||
assert.equal(synchronousWide.attributes.get("data-dream-art-aspect"), "wide");
|
||||
assert.equal(synchronousWide.attributes.get("data-dream-art-task-mode"), "ambient");
|
||||
assert.equal(synchronousWide.attributes.get("data-dream-art-ready"), "false");
|
||||
|
||||
const autoLandscape = createFixture({
|
||||
id: "auto-landscape-focus",
|
||||
appearance: "auto",
|
||||
art: { safeArea: "auto", taskMode: "auto" },
|
||||
artMetadata: {
|
||||
width: 1586,
|
||||
height: 992,
|
||||
ratio: 1586 / 992,
|
||||
wide: false,
|
||||
aspect: "wide",
|
||||
taskMode: "ambient",
|
||||
},
|
||||
});
|
||||
vm.runInNewContext(autoLandscape.payload, autoLandscape.context);
|
||||
assert.equal(autoLandscape.rootStyle.values.get("--dream-art-position"), "50.00% 50.00%");
|
||||
assert.equal(
|
||||
autoLandscape.rootStyle.values.get("--dream-skin-home-art-position"),
|
||||
"50.00% 28.00%",
|
||||
"An unconfigured landscape should keep upper subjects visible in the much wider home hero.",
|
||||
);
|
||||
|
||||
const cachedAnalysis = {
|
||||
width: 2400,
|
||||
height: 1350,
|
||||
ratio: 2400 / 1350,
|
||||
wide: true,
|
||||
aspect: "wide",
|
||||
taskMode: "ambient",
|
||||
safeArea: "left",
|
||||
focusX: 0.72,
|
||||
focusY: 0.48,
|
||||
accentRgb: { r: 180, g: 90, b: 110 },
|
||||
};
|
||||
const cached = createFixture({
|
||||
id: "cached-wide",
|
||||
appearance: "auto",
|
||||
art: { safeArea: "auto", taskMode: "auto" },
|
||||
artKey: "cached-art",
|
||||
artMetadata: synchronousWide.window.__CODEX_DREAM_SKIN_STATE__.artMetadata,
|
||||
}, { analysisCache: new Map([["cached-art", cachedAnalysis]]) });
|
||||
vm.runInNewContext(cached.payload, cached.context);
|
||||
assert.equal(cached.attributes.get("data-dream-art-ready"), "true");
|
||||
assert.equal(cached.attributes.get("data-dream-art-safe-area"), "left");
|
||||
assert.equal(cached.window.__CODEX_DREAM_SKIN_STATE__.metrics.analysisCacheHits, 1);
|
||||
assert.equal(cached.window.__CODEX_DREAM_SKIN_STATE__.metrics.analysisRuns, 0);
|
||||
|
||||
const previousWideState = synchronousWide.window.__CODEX_DREAM_SKIN_STATE__;
|
||||
const stableStyle = synchronousWide.nodes.get("codex-dream-skin-style");
|
||||
vm.runInNewContext(synchronousWide.payloadFor({
|
||||
id: "switched-wide",
|
||||
appearance: "dark",
|
||||
art: { safeArea: "right", taskMode: "ambient" },
|
||||
artKey: "switched-art",
|
||||
artMetadata: {
|
||||
width: 2400,
|
||||
height: 1350,
|
||||
ratio: 2400 / 1350,
|
||||
wide: true,
|
||||
aspect: "wide",
|
||||
taskMode: "ambient",
|
||||
},
|
||||
}, ".fixture { color: red; }"), synchronousWide.context);
|
||||
assert.equal(synchronousWide.nodes.get("codex-dream-skin-style"), stableStyle);
|
||||
assert.equal(stableStyle.textContent, ".fixture { color: red; }");
|
||||
assert.equal(stableStyle.dataset.dreamSkinVersion, "test");
|
||||
assert.equal(synchronousWide.rootStyle.values.get("--dream-skin-art"), 'url("blob:fixture-2")');
|
||||
assert.deepEqual(synchronousWide.revokedUrls, ["blob:fixture-1"]);
|
||||
assert.equal(previousWideState.cleanup(), false, "An old async cleanup must not remove the new theme.");
|
||||
|
||||
const brightPixels = new Uint8ClampedArray(96 * 32 * 4);
|
||||
for (let offset = 0; offset < brightPixels.length; offset += 4) {
|
||||
brightPixels[offset] = 245;
|
||||
brightPixels[offset + 1] = 224;
|
||||
brightPixels[offset + 2] = 224;
|
||||
brightPixels[offset + 3] = 255;
|
||||
}
|
||||
const nativeDark = createFixture({
|
||||
id: "native-dark-contract",
|
||||
appearance: "auto",
|
||||
art: { safeArea: "auto", taskMode: "auto" },
|
||||
}, {
|
||||
nativeShell: "dark",
|
||||
analysisFixture: { naturalWidth: 2400, naturalHeight: 800, pixels: brightPixels },
|
||||
});
|
||||
vm.runInNewContext(nativeDark.payload, nativeDark.context);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
nativeDark.window.__CODEX_DREAM_SKIN_STATE__.ensure();
|
||||
assert.equal(nativeDark.window.__CODEX_DREAM_SKIN_STATE__.analysis.shell, "light");
|
||||
assert.equal(nativeDark.attributes.get("data-dream-shell"), "dark");
|
||||
assert.match(nativeDark.rootStyle.values.get("--ds-bg"), /^#[0-9a-f]{6}$/);
|
||||
assert.ok(Number.parseInt(nativeDark.rootStyle.values.get("--ds-bg").slice(1), 16) < 0x303030);
|
||||
|
||||
const explicit = createFixture({
|
||||
id: "explicit-contract",
|
||||
appearance: "dark",
|
||||
surfaceOpacity: 0.55,
|
||||
art: { focusX: 0.15, focusY: 0.8, safeArea: "none", taskMode: "off" },
|
||||
});
|
||||
const explicitResult = vm.runInNewContext(explicit.payload, explicit.context);
|
||||
assert.equal(explicitResult.shell, "dark");
|
||||
assert.equal(explicit.attributes.get("data-dream-shell"), "dark");
|
||||
assert.equal(explicit.attributes.get("data-dream-art-safe-area"), "none");
|
||||
assert.equal(explicit.attributes.get("data-dream-art-safe"), "none");
|
||||
assert.equal(explicit.attributes.get("data-dream-art-task-mode"), "off");
|
||||
assert.equal(explicit.rootStyle.values.get("--dream-art-position"), "15.00% 80.00%");
|
||||
assert.equal(explicit.rootStyle.values.get("--dream-skin-home-art-position"), "15.00% 80.00%");
|
||||
assert.equal(explicit.rootStyle.values.get("--ds-surface-opacity"), "0.55");
|
||||
assert.equal(explicit.window.__CODEX_DREAM_SKIN_STATE__.analysis, null);
|
||||
|
||||
const banner = createFixture({
|
||||
id: "banner-contract",
|
||||
appearance: "auto",
|
||||
art: { safeArea: "left", taskMode: "banner" },
|
||||
artMetadata: {
|
||||
width: 2560,
|
||||
height: 1440,
|
||||
ratio: 2560 / 1440,
|
||||
wide: true,
|
||||
aspect: "ultrawide",
|
||||
taskMode: "banner",
|
||||
safeArea: "left",
|
||||
focusX: 0.72,
|
||||
focusY: 0.44,
|
||||
},
|
||||
});
|
||||
vm.runInNewContext(banner.payload, banner.context);
|
||||
assert.equal(banner.attributes.get("data-dream-art-wide"), "true");
|
||||
assert.equal(banner.attributes.get("data-dream-art-task-mode"), "banner");
|
||||
assert.equal(banner.attributes.get("data-dream-task-mode"), "banner");
|
||||
|
||||
assert.equal(explicit.window.__CODEX_DREAM_SKIN_STATE__.cleanup(), true);
|
||||
assert.equal(explicit.root.classList.contains("codex-dream-skin"), false);
|
||||
assert.equal(explicit.attributes.has("data-dream-shell"), false);
|
||||
assert.equal(explicit.attributes.has("data-dream-art-safe-area"), false);
|
||||
assert.equal(explicit.attributes.has("data-dream-art-task-mode"), false);
|
||||
assert.equal(explicit.rootStyle.values.has("--dream-art-position"), false);
|
||||
assert.equal(explicit.nodes.has("codex-dream-skin-style"), false);
|
||||
assert.equal(explicit.nodes.has("codex-dream-skin-chrome"), false);
|
||||
assert.deepEqual(explicit.revokedUrls, ["blob:fixture-1"]);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
assert.equal(explicit.root.classList.contains("codex-dream-skin"), false);
|
||||
assert.equal(explicit.nodes.has("codex-dream-skin-style"), false);
|
||||
assert.equal(explicit.window.__CODEX_DREAM_SKIN_STATE__, undefined);
|
||||
|
||||
console.log("PASS: renderer honors adaptive art metadata, fallback, and cleanup behavior.");
|
||||
@@ -0,0 +1,4 @@
|
||||
await import("./image-metadata.test.mjs");
|
||||
await import("./injector-bootstrap.test.mjs");
|
||||
await import("./renderer-inject.test.mjs");
|
||||
await import("./theme-stage.test.mjs");
|
||||
@@ -0,0 +1,85 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { spawn } from "node:child_process";
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const macosRoot = path.resolve(here, "..");
|
||||
const stageScript = path.join(macosRoot, "scripts", "stage-theme.mjs");
|
||||
const fixtureAsset = path.join(macosRoot, "tests", "fixtures", "background.jpg");
|
||||
const tempRoot = await fs.mkdtemp(path.join("/tmp", "codex-dream-skin-stage-"));
|
||||
|
||||
function runStage(source, stage) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(process.execPath, [stageScript, source, stage], {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
child.stdout.on("data", (chunk) => { stdout += chunk; });
|
||||
child.stderr.on("data", (chunk) => { stderr += chunk; });
|
||||
child.once("error", reject);
|
||||
child.once("close", (code) => {
|
||||
if (code === 0) resolve(stdout);
|
||||
else reject(new Error(stderr || `stage-theme exited with ${code}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const source = path.join(tempRoot, "themes", "preset-race");
|
||||
const stage = path.join(tempRoot, "stage");
|
||||
await fs.mkdir(source, { recursive: true });
|
||||
await fs.mkdir(stage);
|
||||
await fs.copyFile(fixtureAsset, path.join(source, "background-a.png"));
|
||||
await fs.writeFile(
|
||||
path.join(source, "theme.json"),
|
||||
`${JSON.stringify({ schemaVersion: 1, id: "preset-race", name: "A", image: "background-a.png" })}\n`,
|
||||
);
|
||||
|
||||
const imageName = await runStage(source, stage);
|
||||
assert.equal(imageName, "background-a.png");
|
||||
const stagedConfig = JSON.parse(await fs.readFile(path.join(stage, "theme.json"), "utf8"));
|
||||
assert.equal(stagedConfig.image, "background-a.png");
|
||||
const stagedBeforeMutation = await fs.readFile(path.join(stage, "background-a.png"));
|
||||
|
||||
// A source edit after staging must not change the pair that is about to be
|
||||
// published. This is the regression for switch-theme's old copy-after-
|
||||
// validation TOCTOU window.
|
||||
await fs.copyFile(fixtureAsset, path.join(source, "background-b.png"));
|
||||
await fs.writeFile(
|
||||
path.join(source, "theme.json"),
|
||||
`${JSON.stringify({ schemaVersion: 1, id: "preset-race", name: "B", image: "background-b.png" })}\n`,
|
||||
);
|
||||
await fs.writeFile(path.join(source, "background-a.png"), Buffer.from("changed-after-stage"));
|
||||
assert.deepEqual(await fs.readFile(path.join(stage, "background-a.png")), stagedBeforeMutation);
|
||||
assert.equal(JSON.parse(await fs.readFile(path.join(stage, "theme.json"), "utf8")).name, "A");
|
||||
|
||||
const outside = path.join(tempRoot, "outside.png");
|
||||
await fs.copyFile(fixtureAsset, outside);
|
||||
const traversal = path.join(tempRoot, "traversal");
|
||||
await fs.mkdir(traversal);
|
||||
await fs.writeFile(
|
||||
path.join(traversal, "theme.json"),
|
||||
`${JSON.stringify({ schemaVersion: 1, id: "bad", image: "../outside.png" })}\n`,
|
||||
);
|
||||
const traversalStage = path.join(tempRoot, "traversal-stage");
|
||||
await fs.mkdir(traversalStage);
|
||||
await assert.rejects(runStage(traversal, traversalStage), /inside its theme directory/);
|
||||
|
||||
const symlink = path.join(tempRoot, "symlink");
|
||||
await fs.mkdir(symlink);
|
||||
await fs.symlink(outside, path.join(symlink, "background.png"));
|
||||
await fs.writeFile(
|
||||
path.join(symlink, "theme.json"),
|
||||
`${JSON.stringify({ schemaVersion: 1, id: "bad-link", image: "background.png" })}\n`,
|
||||
);
|
||||
const symlinkStage = path.join(tempRoot, "symlink-stage");
|
||||
await fs.mkdir(symlinkStage);
|
||||
await assert.rejects(runStage(symlink, symlinkStage), /symbolic link/);
|
||||
|
||||
console.log("PASS: theme staging snapshots a matched, contained config/image pair.");
|
||||
} finally {
|
||||
await fs.rm(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[ValidateSet('inspect', 'selectPort', 'launch', 'stopCodex', 'launchNormal', 'verifyPort', 'processInfo', 'injectorStatus', 'stopInjector')]
|
||||
[string]$Action,
|
||||
[int]$Port = 9341,
|
||||
[int]$TargetPid = 0,
|
||||
[string]$StatePath
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$ProgressPreference = 'SilentlyContinue'
|
||||
. (Join-Path $PSScriptRoot 'common-windows.ps1')
|
||||
|
||||
function Write-CodexxJson {
|
||||
param([Parameter(Mandatory = $true)][object]$Value)
|
||||
$Value | ConvertTo-Json -Depth 8 -Compress
|
||||
}
|
||||
|
||||
function Get-CodexxState {
|
||||
if (-not $StatePath -or -not (Test-Path -LiteralPath $StatePath -PathType Leaf)) {
|
||||
throw 'Codex-X skin state file is missing.'
|
||||
}
|
||||
return Get-Content -LiteralPath $StatePath -Raw -Encoding UTF8 | ConvertFrom-Json -ErrorAction Stop
|
||||
}
|
||||
|
||||
function Get-CodexxDebugIdentity {
|
||||
param([Parameter(Mandatory = $true)][object]$Codex)
|
||||
foreach ($process in @(Get-DreamSkinCodexProcesses -Codex $Codex)) {
|
||||
$commandLine = "$($process.CommandLine)"
|
||||
foreach ($match in [regex]::Matches($commandLine, '(?i)(?:^|\s)--remote-debugging-port(?:=|\s+)(?<port>[0-9]{4,5})(?=$|\s)')) {
|
||||
$candidate = [int]$match.Groups['port'].Value
|
||||
if ($candidate -lt 1024 -or $candidate -gt 65535) { continue }
|
||||
$identity = Get-DreamSkinVerifiedCdpIdentity -Port $candidate -Codex $Codex
|
||||
if ($null -ne $identity) {
|
||||
return [pscustomobject]@{ Port = $candidate; Identity = $identity }
|
||||
}
|
||||
}
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
function Test-CodexxInjectorState {
|
||||
param([Parameter(Mandatory = $true)][object]$State)
|
||||
if (-not $State.injectorPid -or -not $State.injectorStartedAt -or
|
||||
-not $State.injectorPath -or -not $State.nodePath -or -not $State.port) {
|
||||
return $false
|
||||
}
|
||||
$processId = [int]$State.injectorPid
|
||||
$process = Get-CimInstance Win32_Process -Filter "ProcessId = $processId" -ErrorAction SilentlyContinue
|
||||
if ($null -eq $process) { return $false }
|
||||
$processPath = Get-DreamSkinProcessExecutablePath -ProcessInfo $process
|
||||
$commandLine = "$($process.CommandLine)"
|
||||
$startedAt = Get-DreamSkinProcessStartedAt -ProcessId $processId
|
||||
$portPattern = '(?i)(?:^|\s)--port(?:=|\s+)' + [regex]::Escape("$($State.port)") + '(?=$|\s)'
|
||||
return [bool](
|
||||
$processPath -and
|
||||
([System.IO.Path]::GetFileName($processPath) -ieq 'node.exe') -and
|
||||
(Test-DreamSkinPathEqual -Left $processPath -Right "$($State.nodePath)") -and
|
||||
(Test-DreamSkinCommandLineToken -CommandLine $commandLine -Token "$($State.injectorPath)") -and
|
||||
(Test-DreamSkinCommandLineToken -CommandLine $commandLine -Token '--watch') -and
|
||||
[regex]::IsMatch($commandLine, $portPattern) -and
|
||||
$startedAt -eq "$($State.injectorStartedAt)"
|
||||
)
|
||||
}
|
||||
|
||||
$operationLock = Enter-DreamSkinOperationLock
|
||||
try {
|
||||
switch ($Action) {
|
||||
'inspect' {
|
||||
$codex = Get-DreamSkinCodexInstall
|
||||
$processes = @(Get-DreamSkinCodexProcesses -Codex $codex)
|
||||
$debug = Get-CodexxDebugIdentity -Codex $codex
|
||||
$watcherPids = @(Get-CimInstance Win32_Process -Filter "Name = 'node.exe'" -ErrorAction SilentlyContinue |
|
||||
Where-Object {
|
||||
$_.CommandLine -and
|
||||
[regex]::IsMatch("$($_.CommandLine)", '(?i)(?:^|\s)--watch(?=$|\s)') -and
|
||||
"$($_.CommandLine)".IndexOf('injector.mjs', [System.StringComparison]::OrdinalIgnoreCase) -ge 0
|
||||
} | ForEach-Object { [int]$_.ProcessId })
|
||||
Write-CodexxJson ([pscustomobject]@{
|
||||
packageRoot = "$($codex.PackageRoot)"
|
||||
executable = "$($codex.Executable)"
|
||||
version = "$($codex.Version)"
|
||||
packageFullName = "$($codex.PackageFullName)"
|
||||
packageFamilyName = "$($codex.PackageFamilyName)"
|
||||
appUserModelId = "$($codex.AppUserModelId)"
|
||||
running = $processes.Count -gt 0
|
||||
debugPort = if ($null -eq $debug) { $null } else { [int]$debug.Port }
|
||||
browserId = if ($null -eq $debug) { $null } else { "$($debug.Identity.BrowserId)" }
|
||||
watcherPids = $watcherPids
|
||||
})
|
||||
break
|
||||
}
|
||||
'selectPort' {
|
||||
Assert-DreamSkinPort -Port $Port
|
||||
Write-CodexxJson ([pscustomobject]@{ port = (Select-DreamSkinPort -PreferredPort $Port) })
|
||||
break
|
||||
}
|
||||
'launch' {
|
||||
Assert-DreamSkinPort -Port $Port
|
||||
$codex = Get-DreamSkinCodexInstall
|
||||
$preserved = @(Get-DreamSkinCodexProcesses -Codex $codex | ForEach-Object { [int]$_.ProcessId })
|
||||
$arguments = @('--remote-debugging-address=127.0.0.1', "--remote-debugging-port=$Port")
|
||||
try {
|
||||
$launch = Start-DreamSkinCodexForDebugging -Codex $codex -Arguments $arguments `
|
||||
-Port $Port -PreserveProcessIds $preserved
|
||||
$deadline = (Get-Date).AddSeconds(45)
|
||||
$identity = Get-DreamSkinVerifiedCdpIdentity -Port $Port -Codex $codex
|
||||
while ($null -eq $identity -and (Get-Date) -lt $deadline) {
|
||||
Start-Sleep -Milliseconds 350
|
||||
$identity = Get-DreamSkinVerifiedCdpIdentity -Port $Port -Codex $codex
|
||||
}
|
||||
if ($null -eq $identity) {
|
||||
throw "Codex did not expose a verified loopback CDP endpoint on port $Port within 45 seconds."
|
||||
}
|
||||
Write-CodexxJson ([pscustomobject]@{
|
||||
port = $Port
|
||||
browserId = "$($identity.BrowserId)"
|
||||
strategy = "$($launch.Strategy)"
|
||||
})
|
||||
} catch {
|
||||
try { Stop-DreamSkinCodex -Codex $codex -PreserveProcessIds $preserved -AllowForce } catch {}
|
||||
if ($preserved.Count -eq 0 -and (Get-DreamSkinCodexProcesses -Codex $codex).Count -eq 0) {
|
||||
try { $null = Start-DreamSkinCodex -Codex $codex } catch {}
|
||||
}
|
||||
throw
|
||||
}
|
||||
break
|
||||
}
|
||||
'stopCodex' {
|
||||
$codex = Get-DreamSkinCodexInstall
|
||||
Stop-DreamSkinCodex -Codex $codex -AllowForce
|
||||
Write-CodexxJson ([pscustomobject]@{ stopped = $true })
|
||||
break
|
||||
}
|
||||
'launchNormal' {
|
||||
$codex = Get-DreamSkinCodexInstall
|
||||
$processId = Start-DreamSkinCodex -Codex $codex
|
||||
Write-CodexxJson ([pscustomobject]@{ processId = [int]$processId })
|
||||
break
|
||||
}
|
||||
'verifyPort' {
|
||||
Assert-DreamSkinPort -Port $Port
|
||||
$codex = Get-DreamSkinCodexInstall
|
||||
$identity = Get-DreamSkinVerifiedCdpIdentity -Port $Port -Codex $codex
|
||||
Write-CodexxJson ([pscustomobject]@{
|
||||
verified = $null -ne $identity
|
||||
browserId = if ($null -eq $identity) { $null } else { "$($identity.BrowserId)" }
|
||||
})
|
||||
break
|
||||
}
|
||||
'processInfo' {
|
||||
if ($TargetPid -le 0) { throw 'Target PID must be positive.' }
|
||||
$process = Get-CimInstance Win32_Process -Filter "ProcessId = $TargetPid" -ErrorAction SilentlyContinue
|
||||
if ($null -eq $process) {
|
||||
Write-CodexxJson ([pscustomobject]@{ alive = $false })
|
||||
break
|
||||
}
|
||||
Write-CodexxJson ([pscustomobject]@{
|
||||
alive = $true
|
||||
path = (Get-DreamSkinProcessExecutablePath -ProcessInfo $process)
|
||||
commandLine = "$($process.CommandLine)"
|
||||
startedAt = (Get-DreamSkinProcessStartedAt -ProcessId $TargetPid)
|
||||
})
|
||||
break
|
||||
}
|
||||
'injectorStatus' {
|
||||
$state = Get-CodexxState
|
||||
Write-CodexxJson ([pscustomobject]@{ active = (Test-CodexxInjectorState -State $state) })
|
||||
break
|
||||
}
|
||||
'stopInjector' {
|
||||
$state = Get-CodexxState
|
||||
if (-not (Test-CodexxInjectorState -State $state)) {
|
||||
$process = Get-Process -Id ([int]$state.injectorPid) -ErrorAction SilentlyContinue
|
||||
if ($null -ne $process) { throw 'The recorded skin injector identity does not match; it was not stopped.' }
|
||||
} else {
|
||||
$null = Stop-DreamSkinRecordedInjector -State $state
|
||||
}
|
||||
Write-CodexxJson ([pscustomobject]@{ stopped = $true })
|
||||
break
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
Exit-DreamSkinOperationLock -Mutex $operationLock
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,602 @@
|
||||
$script:DreamSkinUtf8NoBom = [System.Text.UTF8Encoding]::new($false, $true)
|
||||
$script:DreamSkinLegacyAppearanceTheme = 'appearanceTheme = "light"'
|
||||
$script:DreamSkinManagedLightCodeTheme = 'appearanceLightCodeThemeId = "codex"'
|
||||
$script:DreamSkinManagedLightChromeTheme = 'appearanceLightChromeTheme = { accent = "#B65CFF", contrast = 64, fonts = { code = "Cascadia Code", ui = "Microsoft YaHei UI" }, ink = "#4A235F", opaqueWindows = true, semanticColors = { diffAdded = "#BCE8CF", diffRemoved = "#F7B8CE", skill = "#C47BFF" }, surface = "#FFF4FA" }'
|
||||
|
||||
function ConvertFrom-DreamSkinUtf8Bytes {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][AllowEmptyCollection()][byte[]]$Bytes,
|
||||
[Parameter(Mandatory = $true)][string]$Path
|
||||
)
|
||||
|
||||
try {
|
||||
$offset = if ($Bytes.Length -ge 3 -and $Bytes[0] -eq 0xEF -and $Bytes[1] -eq 0xBB -and $Bytes[2] -eq 0xBF) { 3 } else { 0 }
|
||||
$content = $script:DreamSkinUtf8NoBom.GetString($Bytes, $offset, $Bytes.Length - $offset)
|
||||
if ($content.IndexOf([char]0) -ge 0) {
|
||||
throw "Refusing to rewrite a config file containing NUL characters (possibly BOM-less UTF-16): $Path"
|
||||
}
|
||||
return $content
|
||||
} catch [System.Text.DecoderFallbackException] {
|
||||
throw "Refusing to rewrite a config file that is not valid UTF-8: $Path"
|
||||
}
|
||||
}
|
||||
|
||||
function Test-DreamSkinBytesEqual {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][AllowEmptyCollection()][byte[]]$Left,
|
||||
[Parameter(Mandatory = $true)][AllowEmptyCollection()][byte[]]$Right
|
||||
)
|
||||
if ($Left.Length -ne $Right.Length) { return $false }
|
||||
for ($index = 0; $index -lt $Left.Length; $index++) {
|
||||
if ($Left[$index] -ne $Right[$index]) { return $false }
|
||||
}
|
||||
return $true
|
||||
}
|
||||
|
||||
function Assert-DreamSkinFileUnchanged {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Path,
|
||||
[AllowNull()][byte[]]$ExpectedBytes
|
||||
)
|
||||
if ($null -eq $ExpectedBytes) {
|
||||
if (Test-Path -LiteralPath $Path) { throw "File changed during the operation; retry without other writers: $Path" }
|
||||
return
|
||||
}
|
||||
if (-not (Test-Path -LiteralPath $Path)) { throw "File disappeared during the operation; retry: $Path" }
|
||||
$currentBytes = [System.IO.File]::ReadAllBytes($Path)
|
||||
if (-not (Test-DreamSkinBytesEqual -Left $ExpectedBytes -Right $currentBytes)) {
|
||||
throw "File changed during the operation; retry without other writers: $Path"
|
||||
}
|
||||
}
|
||||
|
||||
function Get-DreamSkinNewLine {
|
||||
param([Parameter(Mandatory = $true)][AllowEmptyString()][string]$Content)
|
||||
if ($Content.Contains("`r`n")) { return "`r`n" }
|
||||
return "`n"
|
||||
}
|
||||
|
||||
function Read-DreamSkinUtf8File {
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Path
|
||||
)
|
||||
|
||||
$bytes = [System.IO.File]::ReadAllBytes($Path)
|
||||
return (ConvertFrom-DreamSkinUtf8Bytes -Bytes $bytes -Path $Path)
|
||||
}
|
||||
|
||||
function Write-DreamSkinUtf8FileAtomically {
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Path,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[AllowEmptyString()]
|
||||
[string]$Content,
|
||||
|
||||
[AllowNull()]
|
||||
[byte[]]$ExpectedBytes
|
||||
)
|
||||
|
||||
$bytes = $script:DreamSkinUtf8NoBom.GetBytes($Content)
|
||||
if ($PSBoundParameters.ContainsKey('ExpectedBytes')) {
|
||||
Write-DreamSkinBytesAtomically -Path $Path -Bytes $bytes -ExpectedBytes $ExpectedBytes
|
||||
} else {
|
||||
Write-DreamSkinBytesAtomically -Path $Path -Bytes $bytes
|
||||
}
|
||||
}
|
||||
|
||||
function Remove-DreamSkinAtomicArtifact {
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Path
|
||||
)
|
||||
|
||||
if ([System.IO.File]::Exists($Path)) {
|
||||
[System.IO.File]::Delete($Path)
|
||||
}
|
||||
}
|
||||
|
||||
function Write-DreamSkinBytesAtomically {
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Path,
|
||||
[Parameter(Mandatory = $true)][AllowEmptyCollection()][byte[]]$Bytes,
|
||||
[AllowNull()][byte[]]$ExpectedBytes
|
||||
)
|
||||
|
||||
$fullPath = [System.IO.Path]::GetFullPath($Path)
|
||||
$directory = [System.IO.Path]::GetDirectoryName($fullPath)
|
||||
if (-not [System.IO.Directory]::Exists($directory)) {
|
||||
[System.IO.Directory]::CreateDirectory($directory) | Out-Null
|
||||
}
|
||||
$fileName = [System.IO.Path]::GetFileName($fullPath)
|
||||
$operationId = "$PID.$([guid]::NewGuid().ToString('N'))"
|
||||
$temporary = Join-Path $directory ".$fileName.$operationId.tmp"
|
||||
$replacementBackup = Join-Path $directory ".$fileName.$operationId.replace-backup"
|
||||
|
||||
try {
|
||||
[System.IO.File]::WriteAllBytes($temporary, $Bytes)
|
||||
if ($PSBoundParameters.ContainsKey('ExpectedBytes')) {
|
||||
Assert-DreamSkinFileUnchanged -Path $fullPath -ExpectedBytes $ExpectedBytes
|
||||
}
|
||||
if ([System.IO.File]::Exists($fullPath)) {
|
||||
[System.IO.File]::Replace($temporary, $fullPath, $replacementBackup)
|
||||
} else {
|
||||
[System.IO.File]::Move($temporary, $fullPath)
|
||||
}
|
||||
} finally {
|
||||
foreach ($artifact in @($temporary, $replacementBackup)) {
|
||||
try {
|
||||
Remove-DreamSkinAtomicArtifact -Path $artifact
|
||||
} catch {
|
||||
try {
|
||||
Write-Warning "Could not remove temporary atomic config artifact '$artifact': $($_.Exception.Message)"
|
||||
} catch {
|
||||
# Cleanup must never mask the result of the atomic write.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Get-DreamSkinTomlKeyTokenPattern {
|
||||
param([Parameter(Mandatory = $true)][string]$Key)
|
||||
$bare = [regex]::Escape($Key)
|
||||
$doubleQuoted = [regex]::Escape('"' + $Key + '"')
|
||||
$singleQuoted = [regex]::Escape("'" + $Key + "'")
|
||||
return "(?:$bare|$doubleQuoted|$singleQuoted)"
|
||||
}
|
||||
|
||||
function ConvertTo-DreamSkinTomlAsciiEscapeProbe {
|
||||
param([Parameter(Mandatory = $true)][AllowEmptyString()][string]$Value)
|
||||
|
||||
$result = $Value
|
||||
$characters = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-'.ToCharArray()
|
||||
foreach ($character in $characters) {
|
||||
$code = ([int][char]$character).ToString('x2')
|
||||
$pattern = '(?i)\\(?:u00' + $code + '|U000000' + $code + ')'
|
||||
$result = [regex]::Replace($result, $pattern, [string]$character)
|
||||
}
|
||||
return $result
|
||||
}
|
||||
|
||||
function Get-DreamSkinTomlArrayBracketBalance {
|
||||
param([Parameter(Mandatory = $true)][AllowEmptyString()][string]$Line)
|
||||
|
||||
$quote = $null
|
||||
$escaped = $false
|
||||
$balance = 0
|
||||
for ($index = 0; $index -lt $Line.Length; $index++) {
|
||||
$character = $Line[$index]
|
||||
if ($null -eq $quote) {
|
||||
if ($character -eq '#') { break }
|
||||
if ($character -eq '"' -or $character -eq "'") { $quote = $character }
|
||||
elseif ($character -eq '[') { $balance++ }
|
||||
elseif ($character -eq ']') { $balance-- }
|
||||
continue
|
||||
}
|
||||
if ($quote -eq '"') {
|
||||
if ($escaped) { $escaped = $false; continue }
|
||||
if ($character -eq '\') { $escaped = $true; continue }
|
||||
}
|
||||
if ($character -eq $quote) { $quote = $null }
|
||||
}
|
||||
return $balance
|
||||
}
|
||||
|
||||
function Assert-DreamSkinTomlLineEditingSafe {
|
||||
param([Parameter(Mandatory = $true)][AllowEmptyString()][string]$Content)
|
||||
|
||||
if ($Content.Contains('"""') -or $Content.Contains("'''")) {
|
||||
throw 'Refusing to rewrite TOML containing multiline strings; use single-line values before installing Dream Skin.'
|
||||
}
|
||||
foreach ($match in [regex]::Matches($Content, '(?m)^[^\r\n]*=[\t ]*\[[^\r\n]*\r?$')) {
|
||||
if ((Get-DreamSkinTomlArrayBracketBalance -Line $match.Value) -ne 0) {
|
||||
throw 'Refusing to rewrite TOML containing multiline arrays; use single-line arrays before installing Dream Skin.'
|
||||
}
|
||||
}
|
||||
|
||||
$probe = ConvertTo-DreamSkinTomlAsciiEscapeProbe -Value $Content
|
||||
if ($probe -cne $Content) {
|
||||
$desktopToken = Get-DreamSkinTomlKeyTokenPattern -Key 'desktop'
|
||||
$desktopShape = "(?m)^[\t ]*(?:\[\[?[\t ]*$desktopToken[\t ]*(?:\]|\.)|$desktopToken[\t ]*(?:\.|=))"
|
||||
$rawDesktopShapes = [regex]::Matches($Content, $desktopShape).Count
|
||||
$probedDesktopShapes = [regex]::Matches($probe, $desktopShape).Count
|
||||
if ($probedDesktopShapes -gt $rawDesktopShapes) {
|
||||
throw 'Refusing to rewrite an escaped TOML key equivalent to desktop; normalize the key spelling first.'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Get-DreamSkinDesktopSectionPattern {
|
||||
$desktopToken = Get-DreamSkinTomlKeyTokenPattern -Key 'desktop'
|
||||
return "(?ms)^[\t ]*\[[\t ]*$desktopToken[\t ]*\][\t ]*(?:#[^\r\n]*)?(?:\r?\n|(?=\z))(?<body>.*?)(?=^[\t ]*\[\[?|\z)"
|
||||
}
|
||||
|
||||
function Test-DreamSkinDesktopNestedTable {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][AllowEmptyString()][string]$Content,
|
||||
[Parameter(Mandatory = $true)][string]$Key
|
||||
)
|
||||
|
||||
$desktopToken = Get-DreamSkinTomlKeyTokenPattern -Key 'desktop'
|
||||
$keyToken = Get-DreamSkinTomlKeyTokenPattern -Key $Key
|
||||
return [regex]::IsMatch(
|
||||
$Content,
|
||||
"(?m)^[\t ]*\[[\t ]*$desktopToken[\t ]*\.[\t ]*$keyToken[\t ]*(?:\]|\.)"
|
||||
)
|
||||
}
|
||||
|
||||
function Assert-DreamSkinDesktopShapeSupported {
|
||||
param([Parameter(Mandatory = $true)][AllowEmptyString()][string]$Content)
|
||||
|
||||
Assert-DreamSkinTomlLineEditingSafe -Content $Content
|
||||
$sectionPattern = Get-DreamSkinDesktopSectionPattern
|
||||
if ([regex]::Matches($Content, $sectionPattern).Count -gt 1) {
|
||||
throw 'Refusing to rewrite multiple equivalent [desktop] tables.'
|
||||
}
|
||||
|
||||
$desktopToken = Get-DreamSkinTomlKeyTokenPattern -Key 'desktop'
|
||||
if ([regex]::IsMatch($Content, "(?m)^[\t ]*\[\[[\t ]*$desktopToken[\t ]*(?:\]\]|\.)")) {
|
||||
throw 'Refusing to rewrite a config that represents desktop as an array of tables.'
|
||||
}
|
||||
foreach ($key in @('appearanceTheme', 'appearanceLightCodeThemeId')) {
|
||||
if (Test-DreamSkinDesktopNestedTable -Content $Content -Key $key) {
|
||||
throw "Refusing to replace '$key' because it is represented as a nested desktop table."
|
||||
}
|
||||
}
|
||||
|
||||
$firstTable = [regex]::Match($Content, '(?m)^[\t ]*\[\[?')
|
||||
$rootContent = if ($firstTable.Success) { $Content.Substring(0, $firstTable.Index) } else { $Content }
|
||||
if ([regex]::IsMatch($rootContent, "(?m)^[\t ]*$desktopToken[\t ]*(?:\.|=)")) {
|
||||
throw 'Refusing to rewrite root dotted or inline desktop keys; normalize them to a [desktop] table first.'
|
||||
}
|
||||
|
||||
$desktop = Get-DreamSkinDesktopSection -Content $Content
|
||||
if ($null -ne $desktop) {
|
||||
$bodyProbe = ConvertTo-DreamSkinTomlAsciiEscapeProbe -Value $desktop.Body
|
||||
foreach ($key in @('appearanceTheme', 'appearanceLightCodeThemeId', 'appearanceLightChromeTheme')) {
|
||||
$keyToken = Get-DreamSkinTomlKeyTokenPattern -Key $key
|
||||
$settingShape = "(?m)^[\t ]*$keyToken[\t ]*(?:\.|=)"
|
||||
if ($key -eq 'appearanceLightChromeTheme' -and
|
||||
(Test-DreamSkinDesktopNestedTable -Content $Content -Key $key) -and
|
||||
[regex]::IsMatch($desktop.Body, $settingShape)) {
|
||||
throw "Refusing to rewrite '$key' because both a scalar and nested table are present."
|
||||
}
|
||||
if ([regex]::Matches($bodyProbe, $settingShape).Count -gt
|
||||
[regex]::Matches($desktop.Body, $settingShape).Count) {
|
||||
throw "Refusing to rewrite an escaped TOML key equivalent to '$key'."
|
||||
}
|
||||
if ([regex]::IsMatch($desktop.Body, "(?m)^[\t ]*$keyToken[\t ]*\.")) {
|
||||
throw "Refusing to replace dotted '$key' keys in the [desktop] table."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Get-DreamSkinDesktopSection {
|
||||
param([Parameter(Mandatory = $true)][AllowEmptyString()][string]$Content)
|
||||
|
||||
$match = [regex]::Match($Content, (Get-DreamSkinDesktopSectionPattern))
|
||||
if (-not $match.Success) { return $null }
|
||||
return [pscustomobject]@{
|
||||
Body = $match.Groups['body'].Value
|
||||
BodyStart = $match.Groups['body'].Index
|
||||
BodyLength = $match.Groups['body'].Length
|
||||
SectionStart = $match.Index
|
||||
SectionLength = $match.Length
|
||||
}
|
||||
}
|
||||
|
||||
function Add-DreamSkinDesktopSection {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][AllowEmptyString()][string]$Content,
|
||||
[Parameter(Mandatory = $true)][string]$NewLine
|
||||
)
|
||||
|
||||
if ($Content.Length -eq 0) { return "[desktop]$NewLine" }
|
||||
$separator = if ($Content.EndsWith("`n")) { $NewLine } else { $NewLine + $NewLine }
|
||||
return $Content + $separator + "[desktop]$NewLine"
|
||||
}
|
||||
|
||||
function Set-DreamSkinSectionSetting {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][AllowEmptyString()][string]$Body,
|
||||
[Parameter(Mandatory = $true)][string]$Key,
|
||||
[AllowNull()][object]$Line,
|
||||
[Parameter(Mandatory = $true)][string]$NewLine
|
||||
)
|
||||
|
||||
$keyToken = Get-DreamSkinTomlKeyTokenPattern -Key $Key
|
||||
$pattern = "(?m)^[\t ]*$keyToken[\t ]*=[^\r\n]*(?:\r?\n|(?=\z))"
|
||||
$matcher = [regex]::new($pattern)
|
||||
if ($matcher.Matches($Body).Count -gt 1) {
|
||||
throw "Refusing to rewrite duplicate '$Key' entries in the [desktop] section."
|
||||
}
|
||||
if ($null -eq $Line) { return $matcher.Replace($Body, '', 1) }
|
||||
$normalizedLine = $Line.TrimEnd("`r", "`n") + $NewLine
|
||||
if ($matcher.IsMatch($Body)) {
|
||||
$literalReplacement = $normalizedLine.Replace('$', '$$')
|
||||
return $matcher.Replace($Body, $literalReplacement, 1)
|
||||
}
|
||||
$separator = if ($Body.Length -eq 0 -or $Body.EndsWith("`n")) { '' } else { $NewLine }
|
||||
return $Body + $separator + $normalizedLine
|
||||
}
|
||||
|
||||
function Get-DreamSkinSectionSettingLine {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][AllowEmptyString()][string]$Body,
|
||||
[Parameter(Mandatory = $true)][string]$Key
|
||||
)
|
||||
$keyToken = Get-DreamSkinTomlKeyTokenPattern -Key $Key
|
||||
$matches = [regex]::Matches($Body, "(?m)^[\t ]*$keyToken[\t ]*=.*$")
|
||||
if ($matches.Count -gt 1) { throw "Refusing to inspect duplicate '$Key' entries in the [desktop] section." }
|
||||
if ($matches.Count -eq 0) { return $null }
|
||||
return $matches[0].Value.Trim()
|
||||
}
|
||||
|
||||
function Test-DreamSkinLegacyManagedLightTrio {
|
||||
param([Parameter(Mandatory = $true)][AllowEmptyString()][string]$Content)
|
||||
$desktop = Get-DreamSkinDesktopSection -Content $Content
|
||||
if ($null -eq $desktop) { return $false }
|
||||
return (
|
||||
(Get-DreamSkinSectionSettingLine -Body $desktop.Body -Key 'appearanceTheme') -ceq
|
||||
$script:DreamSkinLegacyAppearanceTheme -and
|
||||
(Get-DreamSkinSectionSettingLine -Body $desktop.Body -Key 'appearanceLightCodeThemeId') -ceq
|
||||
$script:DreamSkinManagedLightCodeTheme -and
|
||||
(Get-DreamSkinSectionSettingLine -Body $desktop.Body -Key 'appearanceLightChromeTheme') -ceq
|
||||
$script:DreamSkinManagedLightChromeTheme
|
||||
)
|
||||
}
|
||||
|
||||
function Get-DreamSkinAppearanceMarkerPath {
|
||||
param([Parameter(Mandatory = $true)][string]$BackupPath)
|
||||
return "$BackupPath.appearance.json"
|
||||
}
|
||||
|
||||
function Read-DreamSkinAppearanceMarker {
|
||||
param([Parameter(Mandatory = $true)][string]$BackupPath)
|
||||
$markerPath = Get-DreamSkinAppearanceMarkerPath -BackupPath $BackupPath
|
||||
if (-not (Test-Path -LiteralPath $markerPath)) { return $null }
|
||||
try {
|
||||
$marker = (Read-DreamSkinUtf8File -Path $markerPath) | ConvertFrom-Json -ErrorAction Stop
|
||||
} catch {
|
||||
throw "Dream Skin appearance marker is unreadable; config was preserved: $markerPath"
|
||||
}
|
||||
if ($null -eq $marker -or $marker -is [string] -or $marker -is [array]) {
|
||||
throw "Dream Skin appearance marker is invalid; config was preserved: $markerPath"
|
||||
}
|
||||
$schemaVersion = 0
|
||||
try { $schemaVersion = [int]$marker.schemaVersion } catch { $schemaVersion = 0 }
|
||||
# v1 markers are always unmanaged; v2 markers may pin appearanceTheme.
|
||||
$validUnmanagedV1 = $schemaVersion -eq 1 -and $marker.appearanceThemeManaged -is [bool] -and
|
||||
-not [bool]$marker.appearanceThemeManaged
|
||||
$validV2 = $schemaVersion -eq 2 -and $marker.appearanceThemeManaged -is [bool]
|
||||
if (-not ($validUnmanagedV1 -or $validV2)) {
|
||||
throw "Dream Skin appearance marker is invalid; config was preserved: $markerPath"
|
||||
}
|
||||
return $marker
|
||||
}
|
||||
|
||||
function Write-DreamSkinAppearanceMarker {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$BackupPath,
|
||||
[bool]$Managed = $false
|
||||
)
|
||||
$markerPath = Get-DreamSkinAppearanceMarkerPath -BackupPath $BackupPath
|
||||
if (Get-Command Assert-DreamSkinNoReparseComponents -ErrorAction SilentlyContinue) {
|
||||
Assert-DreamSkinNoReparseComponents -Path $markerPath
|
||||
}
|
||||
# Unmanaged markers keep the v1 shape older engines accept; managed pins use
|
||||
# schemaVersion 2, which older engines conservatively refuse to act on.
|
||||
$schemaVersion = 1
|
||||
if ($Managed) { $schemaVersion = 2 }
|
||||
$marker = [ordered]@{
|
||||
schemaVersion = $schemaVersion
|
||||
appearanceThemeManaged = $Managed
|
||||
} | ConvertTo-Json
|
||||
Write-DreamSkinUtf8FileAtomically -Path $markerPath -Content ($marker + "`r`n")
|
||||
}
|
||||
|
||||
function Install-DreamSkinBaseTheme {
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$ConfigPath,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$BackupPath,
|
||||
|
||||
[ValidateSet('auto', 'light', 'dark')]
|
||||
[string]$AppearanceTheme = 'auto'
|
||||
)
|
||||
|
||||
if (-not (Test-Path -LiteralPath $ConfigPath)) { throw "Codex config not found: $ConfigPath" }
|
||||
if (Get-Command Assert-DreamSkinNoReparseComponents -ErrorAction SilentlyContinue) {
|
||||
Assert-DreamSkinNoReparseComponents -Path $BackupPath
|
||||
Assert-DreamSkinNoReparseComponents -Path (Get-DreamSkinAppearanceMarkerPath -BackupPath $BackupPath)
|
||||
}
|
||||
$originalBytes = [System.IO.File]::ReadAllBytes($ConfigPath)
|
||||
$content = ConvertFrom-DreamSkinUtf8Bytes -Bytes $originalBytes -Path $ConfigPath
|
||||
$appearanceMarker = Read-DreamSkinAppearanceMarker -BackupPath $BackupPath
|
||||
$appearanceMarkerPath = Get-DreamSkinAppearanceMarkerPath -BackupPath $BackupPath
|
||||
$appearanceMarkerExisted = Test-Path -LiteralPath $appearanceMarkerPath -PathType Leaf
|
||||
$backupCreated = $false
|
||||
if (-not (Test-Path -LiteralPath $BackupPath)) {
|
||||
Write-DreamSkinBytesAtomically -Path $BackupPath -Bytes $originalBytes -ExpectedBytes $null
|
||||
$backupCreated = $true
|
||||
}
|
||||
|
||||
$writeCompleted = $false
|
||||
try {
|
||||
Assert-DreamSkinDesktopShapeSupported -Content $content
|
||||
$newLine = Get-DreamSkinNewLine -Content $content
|
||||
$desktop = Get-DreamSkinDesktopSection -Content $content
|
||||
if ($null -eq $desktop) {
|
||||
$content = Add-DreamSkinDesktopSection -Content $content -NewLine $newLine
|
||||
$desktop = Get-DreamSkinDesktopSection -Content $content
|
||||
}
|
||||
|
||||
$body = $desktop.Body
|
||||
$backupContent = $null
|
||||
$pinnedAppearance = $AppearanceTheme -ne 'auto'
|
||||
$managedByMarker = $null -ne $appearanceMarker -and [bool]$appearanceMarker.appearanceThemeManaged
|
||||
$legacyMigration = $null -eq $appearanceMarker -and (Test-Path -LiteralPath $BackupPath) -and
|
||||
(Test-DreamSkinLegacyManagedLightTrio -Content $content)
|
||||
# Put the pre-install appearanceTheme back whenever we stop managing it:
|
||||
# either migrating away from the legacy forced-light trio, or un-pinning
|
||||
# after a fixed-appearance theme is replaced by an auto one.
|
||||
if (-not $pinnedAppearance -and ($legacyMigration -or $managedByMarker)) {
|
||||
$backupContent = ConvertFrom-DreamSkinUtf8Bytes -Bytes ([System.IO.File]::ReadAllBytes($BackupPath)) -Path $BackupPath
|
||||
Assert-DreamSkinDesktopShapeSupported -Content $backupContent
|
||||
$backupDesktop = Get-DreamSkinDesktopSection -Content $backupContent
|
||||
$savedAppearance = if ($null -ne $backupDesktop) {
|
||||
Get-DreamSkinSectionSettingLine -Body $backupDesktop.Body -Key 'appearanceTheme'
|
||||
} else { $null }
|
||||
$body = Set-DreamSkinSectionSetting -Body $body -Key 'appearanceTheme' -Line $savedAppearance -NewLine $newLine
|
||||
}
|
||||
if ($pinnedAppearance) {
|
||||
# Native token surfaces (dropdowns/popovers) follow appearanceTheme, so a
|
||||
# fixed-appearance theme pins it to match; Restore puts the original back.
|
||||
$body = Set-DreamSkinSectionSetting -Body $body -Key 'appearanceTheme' `
|
||||
-Line ('appearanceTheme = "{0}"' -f $AppearanceTheme) -NewLine $newLine
|
||||
}
|
||||
$settings = [ordered]@{
|
||||
appearanceLightCodeThemeId = $script:DreamSkinManagedLightCodeTheme
|
||||
appearanceLightChromeTheme = $script:DreamSkinManagedLightChromeTheme
|
||||
}
|
||||
$hasNestedLightChromeTheme = Test-DreamSkinDesktopNestedTable `
|
||||
-Content $content -Key 'appearanceLightChromeTheme'
|
||||
foreach ($key in $settings.Keys) {
|
||||
if ($key -eq 'appearanceLightChromeTheme' -and $hasNestedLightChromeTheme) { continue }
|
||||
$body = Set-DreamSkinSectionSetting -Body $body -Key $key -Line $settings[$key] -NewLine $newLine
|
||||
}
|
||||
|
||||
$content = $content.Substring(0, $desktop.BodyStart) + $body +
|
||||
$content.Substring($desktop.BodyStart + $desktop.BodyLength)
|
||||
# Commit the metadata first. A config commit must never exist without the
|
||||
# marker that tells restore exactly which appearance keys we own.
|
||||
Write-DreamSkinAppearanceMarker -BackupPath $BackupPath -Managed $pinnedAppearance
|
||||
Write-DreamSkinUtf8FileAtomically -Path $ConfigPath -Content $content -ExpectedBytes $originalBytes
|
||||
$writeCompleted = $true
|
||||
} catch {
|
||||
if (-not $writeCompleted) {
|
||||
$configUnchanged = $false
|
||||
try {
|
||||
$configUnchanged = (Test-Path -LiteralPath $ConfigPath -PathType Leaf) -and
|
||||
(Test-DreamSkinBytesEqual -Left $originalBytes -Right ([System.IO.File]::ReadAllBytes($ConfigPath)))
|
||||
} catch {
|
||||
$configUnchanged = $false
|
||||
}
|
||||
if ($configUnchanged) {
|
||||
$markerCleanupSucceeded = $true
|
||||
if (-not $appearanceMarkerExisted -and (Test-Path -LiteralPath $appearanceMarkerPath)) {
|
||||
try {
|
||||
Remove-Item -LiteralPath $appearanceMarkerPath -Force -ErrorAction Stop
|
||||
} catch {
|
||||
$markerCleanupSucceeded = $false
|
||||
}
|
||||
}
|
||||
if ($markerCleanupSucceeded -and $backupCreated) {
|
||||
Remove-Item -LiteralPath $BackupPath -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
}
|
||||
throw
|
||||
}
|
||||
}
|
||||
|
||||
function Restore-DreamSkinBaseTheme {
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$ConfigPath,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$BackupPath
|
||||
)
|
||||
|
||||
if (-not (Test-Path -LiteralPath $BackupPath)) { throw 'No pre-install config backup is available.' }
|
||||
if (Get-Command Assert-DreamSkinNoReparseComponents -ErrorAction SilentlyContinue) {
|
||||
Assert-DreamSkinNoReparseComponents -Path $BackupPath
|
||||
Assert-DreamSkinNoReparseComponents -Path (Get-DreamSkinAppearanceMarkerPath -BackupPath $BackupPath)
|
||||
}
|
||||
$backupBytes = [System.IO.File]::ReadAllBytes($BackupPath)
|
||||
$backupContent = ConvertFrom-DreamSkinUtf8Bytes -Bytes $backupBytes -Path $BackupPath
|
||||
$currentBytes = [System.IO.File]::ReadAllBytes($ConfigPath)
|
||||
$currentContent = ConvertFrom-DreamSkinUtf8Bytes -Bytes $currentBytes -Path $ConfigPath
|
||||
Assert-DreamSkinDesktopShapeSupported -Content $backupContent
|
||||
Assert-DreamSkinDesktopShapeSupported -Content $currentContent
|
||||
$newLine = Get-DreamSkinNewLine -Content $currentContent
|
||||
$backupDesktop = Get-DreamSkinDesktopSection -Content $backupContent
|
||||
$currentDesktop = Get-DreamSkinDesktopSection -Content $currentContent
|
||||
if ($null -eq $currentDesktop) {
|
||||
$currentContent = Add-DreamSkinDesktopSection -Content $currentContent -NewLine $newLine
|
||||
$currentDesktop = Get-DreamSkinDesktopSection -Content $currentContent
|
||||
}
|
||||
|
||||
$body = $currentDesktop.Body
|
||||
$appearanceMarker = Read-DreamSkinAppearanceMarker -BackupPath $BackupPath
|
||||
$restoreLegacyAppearance = $null -eq $appearanceMarker -and
|
||||
(Test-DreamSkinLegacyManagedLightTrio -Content $currentContent)
|
||||
$restoreManagedAppearance = $null -ne $appearanceMarker -and
|
||||
[bool]$appearanceMarker.appearanceThemeManaged
|
||||
$restoreKeys = @('appearanceLightCodeThemeId', 'appearanceLightChromeTheme')
|
||||
if ($restoreLegacyAppearance -or $restoreManagedAppearance) {
|
||||
$restoreKeys = @('appearanceTheme') + $restoreKeys
|
||||
}
|
||||
$hasNestedLightChromeTheme = Test-DreamSkinDesktopNestedTable `
|
||||
-Content $currentContent -Key 'appearanceLightChromeTheme'
|
||||
foreach ($key in $restoreKeys) {
|
||||
if ($key -eq 'appearanceLightChromeTheme' -and $hasNestedLightChromeTheme) { continue }
|
||||
$keyToken = Get-DreamSkinTomlKeyTokenPattern -Key $key
|
||||
$pattern = "(?m)^[\t ]*$keyToken[\t ]*=[^\r\n]*(?:\r?\n|(?=\z))"
|
||||
$saved = if ($null -ne $backupDesktop) { [regex]::Match($backupDesktop.Body, $pattern) } else { $null }
|
||||
$line = if ($null -ne $saved -and $saved.Success) { $saved.Value } else { $null }
|
||||
$body = Set-DreamSkinSectionSetting -Body $body -Key $key -Line $line -NewLine $newLine
|
||||
}
|
||||
if ($null -eq $backupDesktop -and [string]::IsNullOrWhiteSpace($body)) {
|
||||
$currentContent = $currentContent.Remove($currentDesktop.SectionStart, $currentDesktop.SectionLength)
|
||||
} else {
|
||||
$currentContent = $currentContent.Substring(0, $currentDesktop.BodyStart) + $body +
|
||||
$currentContent.Substring($currentDesktop.BodyStart + $currentDesktop.BodyLength)
|
||||
}
|
||||
Write-DreamSkinUtf8FileAtomically -Path $ConfigPath -Content $currentContent -ExpectedBytes $currentBytes
|
||||
}
|
||||
|
||||
function Restore-DreamSkinConfigBackup {
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$ConfigPath,
|
||||
[Parameter(Mandatory = $true)][string]$BackupPath,
|
||||
[Parameter(Mandatory = $true)][string]$RecoveryBackupPath
|
||||
)
|
||||
|
||||
if (-not (Test-Path -LiteralPath $BackupPath)) { throw 'No pre-install config backup is available.' }
|
||||
$backupBytes = [System.IO.File]::ReadAllBytes($BackupPath)
|
||||
$null = ConvertFrom-DreamSkinUtf8Bytes -Bytes $backupBytes -Path $BackupPath
|
||||
$currentBytes = $null
|
||||
if (Test-Path -LiteralPath $ConfigPath) {
|
||||
$currentBytes = [System.IO.File]::ReadAllBytes($ConfigPath)
|
||||
Write-DreamSkinBytesAtomically -Path $RecoveryBackupPath -Bytes $currentBytes -ExpectedBytes $null
|
||||
}
|
||||
|
||||
Write-DreamSkinBytesAtomically -Path $ConfigPath -Bytes $backupBytes -ExpectedBytes $currentBytes
|
||||
}
|
||||
|
||||
function Archive-DreamSkinConfigBackup {
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$BackupPath,
|
||||
[Parameter(Mandatory = $true)][string]$ArchivePath
|
||||
)
|
||||
|
||||
if (-not (Test-Path -LiteralPath $BackupPath)) { return }
|
||||
if (Test-Path -LiteralPath $ArchivePath) { throw "Config backup archive already exists: $ArchivePath" }
|
||||
Move-Item -LiteralPath $BackupPath -Destination $ArchivePath -ErrorAction Stop
|
||||
Remove-Item -LiteralPath (Get-DreamSkinAppearanceMarkerPath -BackupPath $BackupPath) -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
The official Windows release workflow places the pinned Node.js runtime here.
|
||||
|
||||
Version: 22.23.1
|
||||
Archive: node-v22.23.1-win-x64.zip
|
||||
SHA-256: 7df0bc9375723f4a86b3aa1b7cc73342423d9677a8df4538aca31a049e309c29
|
||||
Source: https://nodejs.org/dist/v22.23.1/node-v22.23.1-win-x64.zip
|
||||
|
||||
Local source builds may use Node.js 22 or newer from PATH instead.
|
||||
@@ -27,6 +27,8 @@ mod providers;
|
||||
mod remote;
|
||||
mod sessions;
|
||||
mod skills_mcp;
|
||||
mod skin_presets;
|
||||
mod skin_runtime;
|
||||
mod skins;
|
||||
mod sqlite_utils;
|
||||
mod state;
|
||||
@@ -101,8 +103,10 @@ use skills_mcp::{
|
||||
sort_managed_skills, ManagedMcpServer, ManagedSkill,
|
||||
};
|
||||
use skins::{
|
||||
enable_skin_theme_inner, export_skin_theme_inner, get_skin_center_state_inner,
|
||||
import_skin_theme_zip_inner, SkinActionResult, SkinCenterState, SkinExportResult,
|
||||
create_skin_theme_from_image_inner, enable_skin_theme_inner, export_skin_theme_inner,
|
||||
get_skin_center_state_inner, import_skin_theme_zip_inner, pause_skin_theme_inner,
|
||||
restore_skin_theme_inner, update_skin_theme_settings_inner, SkinActionResult, SkinCenterState,
|
||||
SkinExportResult,
|
||||
};
|
||||
#[cfg(test)]
|
||||
use state::active_saved_provider_id_from_config;
|
||||
@@ -483,8 +487,8 @@ async fn get_skin_center_state() -> Result<SkinCenterState> {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn enable_skin_theme(id: String) -> Result<SkinActionResult> {
|
||||
tauri::async_runtime::spawn_blocking(move || enable_skin_theme_inner(id))
|
||||
async fn enable_skin_theme(id: String, restart_existing: bool) -> Result<SkinActionResult> {
|
||||
tauri::async_runtime::spawn_blocking(move || enable_skin_theme_inner(id, restart_existing))
|
||||
.await
|
||||
.map_err(|e| CodexxError::Config(format!("启用皮肤失败: {e}")))?
|
||||
}
|
||||
@@ -497,12 +501,52 @@ async fn import_skin_theme_zip(file_name: String, bytes: Vec<u8>) -> Result<Skin
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn export_skin_theme(id: String) -> Result<SkinExportResult> {
|
||||
tauri::async_runtime::spawn_blocking(move || export_skin_theme_inner(id))
|
||||
async fn create_skin_theme_from_image(
|
||||
file_name: String,
|
||||
bytes: Vec<u8>,
|
||||
) -> Result<SkinActionResult> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
create_skin_theme_from_image_inner(file_name, bytes)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| CodexxError::Config(format!("从图片创建皮肤失败: {e}")))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn update_skin_theme_settings(
|
||||
id: String,
|
||||
name: String,
|
||||
tagline: String,
|
||||
surface_opacity: f64,
|
||||
) -> Result<SkinActionResult> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
update_skin_theme_settings_inner(id, name, tagline, surface_opacity)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| CodexxError::Config(format!("保存皮肤设置失败: {e}")))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn export_skin_theme(id: String, destination_path: String) -> Result<SkinExportResult> {
|
||||
tauri::async_runtime::spawn_blocking(move || export_skin_theme_inner(id, destination_path))
|
||||
.await
|
||||
.map_err(|e| CodexxError::Config(format!("导出皮肤失败: {e}")))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn pause_skin_theme() -> Result<SkinActionResult> {
|
||||
tauri::async_runtime::spawn_blocking(pause_skin_theme_inner)
|
||||
.await
|
||||
.map_err(|e| CodexxError::Config(format!("暂停皮肤失败: {e}")))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn restore_skin_theme(restart_existing: bool) -> Result<SkinActionResult> {
|
||||
tauri::async_runtime::spawn_blocking(move || restore_skin_theme_inner(restart_existing))
|
||||
.await
|
||||
.map_err(|e| CodexxError::Config(format!("恢复官方外观失败: {e}")))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn check_skill_updates(config_dir: Option<String>) -> Result<SkillsMcpState> {
|
||||
tauri::async_runtime::spawn_blocking(move || check_skill_updates_inner(config_dir))
|
||||
@@ -1113,6 +1157,7 @@ fn open_url(url: String) -> std::result::Result<(), String> {
|
||||
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_process::init())
|
||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
@@ -1127,7 +1172,11 @@ pub fn run() {
|
||||
get_skin_center_state,
|
||||
enable_skin_theme,
|
||||
import_skin_theme_zip,
|
||||
create_skin_theme_from_image,
|
||||
update_skin_theme_settings,
|
||||
export_skin_theme,
|
||||
pause_skin_theme,
|
||||
restore_skin_theme,
|
||||
check_skill_updates,
|
||||
get_startup_diagnostics,
|
||||
get_session_sync_status,
|
||||
|
||||
@@ -6,6 +6,12 @@ use std::process::{Command, Output};
|
||||
#[cfg(target_os = "windows")]
|
||||
use std::env;
|
||||
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
const WINDOWS_CODEX_PACKAGE_IDENTITIES: &[&str] =
|
||||
&["OpenAI.Codex", "OpenAI.CodexBeta", "OpenAI.ChatGPT-Desktop"];
|
||||
#[cfg(target_os = "windows")]
|
||||
const WINDOWS_CODEX_EXECUTABLES: &[&str] = &["ChatGPT.exe", "Codex.exe", "codex.exe"];
|
||||
|
||||
fn version_line(stdout: &str, stderr: &str, success: bool) -> Option<String> {
|
||||
let lines = stdout.lines().chain(stderr.lines()).map(str::trim);
|
||||
let preferred = lines.clone().find(|line| {
|
||||
@@ -98,6 +104,62 @@ fn push_candidate(candidates: &mut Vec<PathBuf>, seen: &mut HashSet<String>, pat
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
fn numeric_version(value: &str) -> Option<Vec<u32>> {
|
||||
let parts = value
|
||||
.split('.')
|
||||
.map(str::parse::<u32>)
|
||||
.collect::<std::result::Result<Vec<_>, _>>()
|
||||
.ok()?;
|
||||
(parts.len() >= 2).then_some(parts)
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
fn windows_package_version(package_name: &str) -> Option<(Vec<u32>, String)> {
|
||||
for identity in WINDOWS_CODEX_PACKAGE_IDENTITIES {
|
||||
let prefix_len = identity.len();
|
||||
if !package_name
|
||||
.get(..prefix_len)
|
||||
.is_some_and(|prefix| prefix.eq_ignore_ascii_case(identity))
|
||||
|| package_name.as_bytes().get(prefix_len) != Some(&b'_')
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let version = package_name.get(prefix_len + 1..)?.split('_').next()?;
|
||||
return Some((numeric_version(version)?, version.to_string()));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
fn latest_windows_package_version<'a>(
|
||||
package_names: impl IntoIterator<Item = &'a str>,
|
||||
) -> Option<String> {
|
||||
package_names
|
||||
.into_iter()
|
||||
.filter_map(windows_package_version)
|
||||
.max_by(|left, right| left.0.cmp(&right.0))
|
||||
.map(|(_, version)| version)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn windows_store_app_version_from_roots(roots: &[PathBuf]) -> Option<String> {
|
||||
let mut package_names = Vec::new();
|
||||
for root in roots {
|
||||
let Ok(entries) = fs::read_dir(root) else {
|
||||
continue;
|
||||
};
|
||||
package_names.extend(entries.flatten().filter_map(|entry| {
|
||||
entry
|
||||
.path()
|
||||
.is_dir()
|
||||
.then(|| entry.file_name().to_string_lossy().to_string())
|
||||
}));
|
||||
}
|
||||
latest_windows_package_version(package_names.iter().map(String::as_str))
|
||||
.map(|version| format!("Codex app {version}"))
|
||||
}
|
||||
|
||||
fn collect_named_files(root: &Path, names: &[&str], depth: usize, output: &mut Vec<PathBuf>) {
|
||||
if depth == 0 || !root.is_dir() {
|
||||
return;
|
||||
@@ -170,8 +232,13 @@ fn platform_candidates(home: &Path) -> Vec<PathBuf> {
|
||||
PathBuf::from("/Applications/ChatGPT.app/Contents/Resources/codex"),
|
||||
home.join("Applications/ChatGPT.app/Contents/Resources/codex"),
|
||||
PathBuf::from("/Applications/Codex.app/Contents/Resources/codex"),
|
||||
home.join("Applications/Codex.app/Contents/Resources/codex"),
|
||||
PathBuf::from("/Applications/OpenAI Codex.app/Contents/Resources/codex"),
|
||||
home.join("Applications/OpenAI Codex.app/Contents/Resources/codex"),
|
||||
PathBuf::from("/Applications/OpenAI.Codex.app/Contents/Resources/codex"),
|
||||
home.join("Applications/OpenAI.Codex.app/Contents/Resources/codex"),
|
||||
PathBuf::from("/Applications/ChatGPT Codex.app/Contents/Resources/codex"),
|
||||
home.join("Applications/ChatGPT Codex.app/Contents/Resources/codex"),
|
||||
PathBuf::from("/opt/homebrew/bin/codex"),
|
||||
PathBuf::from("/usr/local/bin/codex"),
|
||||
home.join(".local/bin/codex"),
|
||||
@@ -205,7 +272,9 @@ fn platform_candidates(home: &Path) -> Vec<PathBuf> {
|
||||
for root in [
|
||||
localappdata.join("Programs/ChatGPT"),
|
||||
localappdata.join("Programs/Codex"),
|
||||
localappdata.join("Programs/OpenAI/Codex"),
|
||||
localappdata.join("OpenAI/ChatGPT"),
|
||||
localappdata.join("OpenAI/Codex"),
|
||||
] {
|
||||
collect_named_files(&root, &["codex.exe", "codex.cmd"], 7, &mut candidates);
|
||||
}
|
||||
@@ -271,38 +340,120 @@ fn windows_where_candidates() -> Vec<PathBuf> {
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn macos_app_version() -> Option<String> {
|
||||
for app in [
|
||||
"/Applications/ChatGPT.app",
|
||||
"/Applications/Codex.app",
|
||||
"/Applications/OpenAI Codex.app",
|
||||
"/Applications/ChatGPT Codex.app",
|
||||
] {
|
||||
let Some(output) =
|
||||
run_program(Path::new("mdls"), &["-name", "kMDItemVersion", "-raw", app])
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if !output.status.success() {
|
||||
continue;
|
||||
}
|
||||
let version = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
if !version.is_empty() && version != "(null)" {
|
||||
let app_name = if app.ends_with("ChatGPT.app") {
|
||||
let home = dirs::home_dir().unwrap_or_default();
|
||||
for root in [PathBuf::from("/Applications"), home.join("Applications")] {
|
||||
for name in [
|
||||
"Codex.app",
|
||||
"OpenAI Codex.app",
|
||||
"OpenAI.Codex.app",
|
||||
"ChatGPT Codex.app",
|
||||
"ChatGPT.app",
|
||||
] {
|
||||
let app = root.join(name);
|
||||
if !app.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let app_name = if name == "ChatGPT.app" {
|
||||
"ChatGPT app"
|
||||
} else {
|
||||
"Codex app"
|
||||
};
|
||||
return Some(format!("{app_name} {version}"));
|
||||
if let Some(version) = macos_info_plist_version(&app).or_else(|| {
|
||||
let app = app.to_str()?;
|
||||
let output =
|
||||
run_program(Path::new("mdls"), &["-name", "kMDItemVersion", "-raw", app])?;
|
||||
output
|
||||
.status
|
||||
.success()
|
||||
.then(|| String::from_utf8_lossy(&output.stdout).trim().to_string())
|
||||
}) {
|
||||
if !version.is_empty() && version != "(null)" {
|
||||
return Some(format!("{app_name} {version}"));
|
||||
}
|
||||
}
|
||||
return Some(format!("{app_name} installed"));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn macos_info_plist_version(app: &Path) -> Option<String> {
|
||||
let plist = fs::read_to_string(app.join("Contents/Info.plist")).ok()?;
|
||||
plist_string_value(&plist, "CFBundleShortVersionString")
|
||||
.or_else(|| plist_string_value(&plist, "CFBundleVersion"))
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "macos", test))]
|
||||
fn plist_string_value(plist: &str, key: &str) -> Option<String> {
|
||||
let (_, after_key) = plist.split_once(&format!("<key>{key}</key>"))?;
|
||||
let (_, after_open) = after_key.split_once("<string>")?;
|
||||
let (value, _) = after_open.split_once("</string>")?;
|
||||
let value = value.trim();
|
||||
(!value.is_empty()).then(|| value.to_string())
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
fn macos_app_version() -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn windows_app_version() -> Option<String> {
|
||||
let mut roots = Vec::new();
|
||||
for variable in ["ProgramFiles", "ProgramW6432"] {
|
||||
if let Ok(program_files) = env::var(variable) {
|
||||
roots.push(PathBuf::from(program_files).join("WindowsApps"));
|
||||
}
|
||||
}
|
||||
roots.push(PathBuf::from(r"C:\Program Files\WindowsApps"));
|
||||
roots.sort();
|
||||
roots.dedup();
|
||||
if let Some(version) = windows_store_app_version_from_roots(&roots) {
|
||||
return Some(version);
|
||||
}
|
||||
|
||||
let script = "Get-AppxPackage | Where-Object { $_.Name -in @('OpenAI.Codex','OpenAI.CodexBeta','OpenAI.ChatGPT-Desktop') } | ForEach-Object { $_.Version.ToString() }";
|
||||
if let Some(output) = run_program(
|
||||
Path::new("powershell.exe"),
|
||||
&["-NoProfile", "-NonInteractive", "-Command", script],
|
||||
) {
|
||||
if output.status.success() {
|
||||
let versions = String::from_utf8_lossy(&output.stdout)
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.filter(|line| !line.is_empty())
|
||||
.filter_map(|version| {
|
||||
numeric_version(version).map(|parsed| (parsed, version.to_string()))
|
||||
})
|
||||
.max_by(|left, right| left.0.cmp(&right.0));
|
||||
if let Some((_, version)) = versions {
|
||||
return Some(format!("Codex app {version}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let local_appdata = env::var("LOCALAPPDATA").ok().map(PathBuf::from)?;
|
||||
for directory in [
|
||||
local_appdata.join("OpenAI/Codex/bin"),
|
||||
local_appdata.join("OpenAI/Codex"),
|
||||
local_appdata.join("Programs/OpenAI/Codex"),
|
||||
local_appdata.join("Programs/Codex"),
|
||||
] {
|
||||
if WINDOWS_CODEX_EXECUTABLES.iter().any(|name| {
|
||||
directory.join(name).is_file() || directory.join("app").join(name).is_file()
|
||||
}) {
|
||||
return Some("Codex app installed".to_string());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
fn windows_app_version() -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
pub fn codex_executable_candidates() -> Vec<PathBuf> {
|
||||
let home = dirs::home_dir().unwrap_or_default();
|
||||
let mut candidates = ["codex", "codex.exe", "codex.cmd"]
|
||||
@@ -328,12 +479,12 @@ pub fn detect_codex_version() -> Option<String> {
|
||||
}
|
||||
}
|
||||
}
|
||||
macos_app_version()
|
||||
macos_app_version().or_else(windows_app_version)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::version_line;
|
||||
use super::{latest_windows_package_version, plist_string_value, version_line};
|
||||
|
||||
#[test]
|
||||
fn version_parser_prefers_codex_line_over_warning() {
|
||||
@@ -363,4 +514,28 @@ mod tests {
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_package_detection_accepts_supported_codex_packages() {
|
||||
assert_eq!(
|
||||
latest_windows_package_version([
|
||||
"OpenAI.Codex_1.2.3.4_x64__publisher",
|
||||
"OpenAI.CodexBeta_1.3.0.0_x64__publisher",
|
||||
"Other.App_99.0.0.0_x64__publisher",
|
||||
]),
|
||||
Some("1.3.0.0".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plist_parser_reads_codex_bundle_version() {
|
||||
let plist = r#"<plist><dict>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.2026.204</string>
|
||||
</dict></plist>"#;
|
||||
assert_eq!(
|
||||
plist_string_value(plist, "CFBundleShortVersionString").as_deref(),
|
||||
Some("1.2026.204")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ use crate::state::{build_state, ActionResult};
|
||||
use crate::toml_utils::ensure_table;
|
||||
use crate::{auth_path, config_path, resolve_codex_dir, string_value};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use serde_json::Value;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use toml_edit::{value, DocumentMut};
|
||||
@@ -307,7 +307,6 @@ where
|
||||
ensure_directory(&codex_dir)?;
|
||||
pre_persist(&codex_dir)?;
|
||||
let cfg = config_path(&codex_dir);
|
||||
let auth = auth_path(&codex_dir);
|
||||
let backup_id = create_backup(&codex_dir, "save-provider-toml")?;
|
||||
|
||||
let config_text = input.config_text.trim_end().to_string();
|
||||
@@ -326,21 +325,6 @@ where
|
||||
}
|
||||
write_text(&cfg, &(doc.to_string().trim_end().to_string() + "\n"))?;
|
||||
|
||||
if let Some(api_key) = api_key {
|
||||
let mut auth_value = if auth.exists() {
|
||||
let text = fs::read_to_string(&auth).map_err(|e| io_err(&auth, e))?;
|
||||
serde_json::from_str::<Value>(&text).unwrap_or_else(|_| json!({}))
|
||||
} else {
|
||||
json!({})
|
||||
};
|
||||
if !auth_value.is_object() {
|
||||
auth_value = json!({});
|
||||
}
|
||||
auth_value["OPENAI_API_KEY"] = Value::String(api_key);
|
||||
auth_value["auth_mode"] = Value::String("apikey".to_string());
|
||||
write_json(&auth, &auth_value)?;
|
||||
}
|
||||
|
||||
let state = build_state(codex_dir)?;
|
||||
Ok(ActionResult {
|
||||
ok: true,
|
||||
@@ -365,7 +349,6 @@ where
|
||||
ensure_directory(&codex_dir)?;
|
||||
pre_persist(&codex_dir)?;
|
||||
let cfg = config_path(&codex_dir);
|
||||
let auth = auth_path(&codex_dir);
|
||||
let backup_id = create_backup(&codex_dir, "switch-provider")?;
|
||||
|
||||
let provider_name = input.provider_name.trim();
|
||||
@@ -414,14 +397,6 @@ where
|
||||
}
|
||||
write_text(&cfg, &doc.to_string())?;
|
||||
|
||||
if let Some(api_key) = api_key {
|
||||
let auth_value = json!({
|
||||
"OPENAI_API_KEY": api_key,
|
||||
"auth_mode": "apikey",
|
||||
});
|
||||
write_json(&auth, &auth_value)?;
|
||||
}
|
||||
|
||||
let state = build_state(codex_dir)?;
|
||||
Ok(ActionResult {
|
||||
ok: true,
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
pub(crate) struct BuiltinSkinAsset {
|
||||
pub(crate) id: &'static str,
|
||||
pub(crate) manifest: &'static str,
|
||||
pub(crate) image: &'static [u8],
|
||||
}
|
||||
|
||||
pub(crate) const BUILTIN_SKIN_ID: &str = "shiina-mashiro-blossom";
|
||||
|
||||
pub(crate) fn retired_builtin_skin_ids() -> &'static [&'static str] {
|
||||
&[
|
||||
"preset-midnight-aurora",
|
||||
"preset-sakura-dawn",
|
||||
"preset-amber-dusk",
|
||||
"preset-forest-mist",
|
||||
"preset-cyber-neon",
|
||||
]
|
||||
}
|
||||
|
||||
pub(crate) fn builtin_skin_assets() -> &'static [BuiltinSkinAsset] {
|
||||
&[BuiltinSkinAsset {
|
||||
id: BUILTIN_SKIN_ID,
|
||||
manifest: include_str!("../resources/skin-presets/shiina-mashiro-blossom/theme.json"),
|
||||
image: include_bytes!("../resources/skin-presets/shiina-mashiro-blossom/background.png"),
|
||||
}]
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
use crate::error::{CodexxError, Result};
|
||||
use crate::file_io::{atomic_write, ensure_directory};
|
||||
use crate::skins::skins_root;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub(super) const RUNTIME_VERSION: &str = "1.2.2-codexx.3";
|
||||
pub(super) const UPSTREAM_COMMIT: &str = "5fd8af532efbaa87d2d0092297fd2d45cd56574e";
|
||||
|
||||
struct EmbeddedRuntimeAsset {
|
||||
relative_path: &'static str,
|
||||
bytes: &'static [u8],
|
||||
}
|
||||
|
||||
fn embedded_assets() -> &'static [EmbeddedRuntimeAsset] {
|
||||
&[
|
||||
EmbeddedRuntimeAsset {
|
||||
relative_path: "scripts/injector.mjs",
|
||||
bytes: include_bytes!("../../resources/skin-runtime/scripts/injector.mjs"),
|
||||
},
|
||||
EmbeddedRuntimeAsset {
|
||||
relative_path: "scripts/image-metadata.mjs",
|
||||
bytes: include_bytes!("../../resources/skin-runtime/scripts/image-metadata.mjs"),
|
||||
},
|
||||
EmbeddedRuntimeAsset {
|
||||
relative_path: "scripts/stage-theme.mjs",
|
||||
bytes: include_bytes!("../../resources/skin-runtime/scripts/stage-theme.mjs"),
|
||||
},
|
||||
EmbeddedRuntimeAsset {
|
||||
relative_path: "assets/dream-skin.css",
|
||||
bytes: include_bytes!("../../resources/skin-runtime/assets/dream-skin.css"),
|
||||
},
|
||||
EmbeddedRuntimeAsset {
|
||||
relative_path: "assets/renderer-inject.js",
|
||||
bytes: include_bytes!("../../resources/skin-runtime/assets/renderer-inject.js"),
|
||||
},
|
||||
EmbeddedRuntimeAsset {
|
||||
relative_path: "windows/codexx-windows.ps1",
|
||||
bytes: include_bytes!("../../resources/skin-runtime/windows/codexx-windows.ps1"),
|
||||
},
|
||||
EmbeddedRuntimeAsset {
|
||||
relative_path: "windows/common-windows.ps1",
|
||||
bytes: include_bytes!("../../resources/skin-runtime/windows/common-windows.ps1"),
|
||||
},
|
||||
EmbeddedRuntimeAsset {
|
||||
relative_path: "windows/config-utf8.ps1",
|
||||
bytes: include_bytes!("../../resources/skin-runtime/windows/config-utf8.ps1"),
|
||||
},
|
||||
EmbeddedRuntimeAsset {
|
||||
relative_path: "LICENSE",
|
||||
bytes: include_bytes!("../../resources/skin-runtime/LICENSE"),
|
||||
},
|
||||
EmbeddedRuntimeAsset {
|
||||
relative_path: "NOTICE.md",
|
||||
bytes: include_bytes!("../../resources/skin-runtime/NOTICE.md"),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
fn bytes_match(path: &Path, expected: &[u8]) -> bool {
|
||||
if !fs::symlink_metadata(path)
|
||||
.is_ok_and(|metadata| metadata.is_file() && !metadata.file_type().is_symlink())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let Ok(actual) = fs::read(path) else {
|
||||
return false;
|
||||
};
|
||||
Sha256::digest(actual) == Sha256::digest(expected)
|
||||
}
|
||||
|
||||
pub(super) fn ensure_runtime_directory(path: &Path) -> Result<()> {
|
||||
ensure_directory(path)?;
|
||||
let metadata =
|
||||
fs::symlink_metadata(path).map_err(|source| crate::file_io::io_err(path, source))?;
|
||||
if metadata.file_type().is_symlink() || !metadata.is_dir() {
|
||||
return Err(CodexxError::Config(format!(
|
||||
"皮肤运行时目录不能是符号链接或普通文件: {}",
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
set_private_permissions(path, true)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn set_private_permissions(path: &Path, directory: bool) -> Result<()> {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let mode = if directory { 0o700 } else { 0o600 };
|
||||
fs::set_permissions(path, fs::Permissions::from_mode(mode))
|
||||
.map_err(|source| crate::file_io::io_err(path, source))
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn set_private_permissions(_path: &Path, _directory: bool) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) struct RuntimeAssets {
|
||||
pub(super) injector: PathBuf,
|
||||
pub(super) stage_theme: PathBuf,
|
||||
#[cfg(any(target_os = "windows", feature = "windows-runtime-check"))]
|
||||
#[cfg_attr(feature = "windows-runtime-check", allow(dead_code))]
|
||||
pub(super) windows_adapter: PathBuf,
|
||||
}
|
||||
|
||||
pub(super) fn ensure_runtime_assets() -> Result<RuntimeAssets> {
|
||||
let base = skins_root()?.join("runtime");
|
||||
ensure_runtime_directory(&base)?;
|
||||
let root = base.join(RUNTIME_VERSION);
|
||||
ensure_runtime_directory(&root)?;
|
||||
for asset in embedded_assets() {
|
||||
let path = root.join(asset.relative_path);
|
||||
let parent = path.parent().ok_or_else(|| {
|
||||
CodexxError::Config(format!("内置皮肤资源路径无效: {}", path.display()))
|
||||
})?;
|
||||
ensure_runtime_directory(parent)?;
|
||||
if !bytes_match(&path, asset.bytes) {
|
||||
atomic_write(&path, asset.bytes)?;
|
||||
}
|
||||
set_private_permissions(&path, false)?;
|
||||
}
|
||||
Ok(RuntimeAssets {
|
||||
injector: root.join("scripts/injector.mjs"),
|
||||
stage_theme: root.join("scripts/stage-theme.mjs"),
|
||||
#[cfg(any(target_os = "windows", feature = "windows-runtime-check"))]
|
||||
windows_adapter: root.join("windows/codexx-windows.ps1"),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,786 @@
|
||||
use super::assets::{
|
||||
ensure_runtime_assets, ensure_runtime_directory, RuntimeAssets, RUNTIME_VERSION,
|
||||
UPSTREAM_COMMIT,
|
||||
};
|
||||
use super::macos_injector::{
|
||||
launch_watcher, remove_live_skin, run_injector_once, stage_theme_snapshot,
|
||||
};
|
||||
use super::macos_system::{discover_codex_runtime, CodexRuntime};
|
||||
use super::{SkinRuntimeAction, SkinRuntimeStatus};
|
||||
use crate::error::{CodexxError, Result};
|
||||
use crate::file_io::{atomic_write, io_err};
|
||||
use crate::now_rfc3339;
|
||||
use crate::skins::skins_root;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs::{self, File, OpenOptions};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Stdio};
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
const DEFAULT_CDP_PORT: u16 = 9341;
|
||||
const MAX_CDP_PORT_OFFSET: u16 = 100;
|
||||
const RUNTIME_STATE_SCHEMA: u32 = 1;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct RuntimeStateFile {
|
||||
schema_version: u32,
|
||||
runtime_version: String,
|
||||
upstream_commit: String,
|
||||
session: String,
|
||||
port: u16,
|
||||
injector_pid: u32,
|
||||
injector_started_at: String,
|
||||
injector_path: String,
|
||||
node_path: String,
|
||||
node_version: String,
|
||||
codex_bundle: String,
|
||||
codex_executable: String,
|
||||
codex_version: String,
|
||||
codex_team_id: String,
|
||||
theme_id: String,
|
||||
theme_dir: String,
|
||||
created_at: String,
|
||||
updated_at: String,
|
||||
}
|
||||
|
||||
fn runtime_root() -> Result<PathBuf> {
|
||||
Ok(skins_root()?.join("runtime"))
|
||||
}
|
||||
|
||||
fn state_path() -> Result<PathBuf> {
|
||||
Ok(runtime_root()?.join("state.json"))
|
||||
}
|
||||
|
||||
pub(super) fn snapshots_root() -> Result<PathBuf> {
|
||||
Ok(runtime_root()?.join("themes"))
|
||||
}
|
||||
|
||||
fn logs_root() -> Result<PathBuf> {
|
||||
Ok(runtime_root()?.join("logs"))
|
||||
}
|
||||
|
||||
fn read_runtime_state() -> Result<Option<RuntimeStateFile>> {
|
||||
let path = state_path()?;
|
||||
let metadata = match fs::symlink_metadata(&path) {
|
||||
Ok(metadata) => metadata,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
|
||||
Err(error) => return Err(io_err(&path, error)),
|
||||
};
|
||||
if metadata.file_type().is_symlink() || !metadata.is_file() || metadata.len() > 256 * 1024 {
|
||||
return Err(CodexxError::Config(
|
||||
"皮肤运行状态文件不是安全的普通文件,已停止自动处理".to_string(),
|
||||
));
|
||||
}
|
||||
let bytes = fs::read(&path).map_err(|source| io_err(&path, source))?;
|
||||
let state: RuntimeStateFile = serde_json::from_slice(&bytes)
|
||||
.map_err(|error| CodexxError::Config(format!("解析皮肤运行状态失败: {error}")))?;
|
||||
if state.schema_version != RUNTIME_STATE_SCHEMA || !(1024..=65535).contains(&state.port) {
|
||||
return Err(CodexxError::Config(
|
||||
"皮肤运行状态版本或端口无效,已停止自动处理".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(Some(state))
|
||||
}
|
||||
|
||||
fn write_runtime_state(state: &RuntimeStateFile) -> Result<()> {
|
||||
let path = state_path()?;
|
||||
let text = serde_json::to_string_pretty(state)
|
||||
.map_err(|error| CodexxError::Config(format!("序列化皮肤运行状态失败: {error}")))?;
|
||||
atomic_write(&path, format!("{text}\n").as_bytes())?;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
fs::set_permissions(&path, fs::Permissions::from_mode(0o600))
|
||||
.map_err(|source| io_err(&path, source))
|
||||
}
|
||||
|
||||
fn remove_runtime_state() -> Result<()> {
|
||||
let path = state_path()?;
|
||||
match fs::remove_file(&path) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||
Err(error) => Err(io_err(&path, error)),
|
||||
}
|
||||
}
|
||||
|
||||
fn process_table() -> Vec<(u32, String)> {
|
||||
let Ok(output) = Command::new("/bin/ps")
|
||||
.args(["-axo", "pid=,command="])
|
||||
.output()
|
||||
else {
|
||||
return Vec::new();
|
||||
};
|
||||
String::from_utf8_lossy(&output.stdout)
|
||||
.lines()
|
||||
.filter_map(|line| {
|
||||
let trimmed = line.trim_start();
|
||||
let split = trimmed.find(char::is_whitespace)?;
|
||||
let pid = trimmed[..split].parse::<u32>().ok()?;
|
||||
Some((pid, trimmed[split..].trim_start().to_string()))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(super) fn process_command(pid: u32) -> Option<String> {
|
||||
let output = Command::new("/bin/ps")
|
||||
.args(["-p", &pid.to_string(), "-o", "command="])
|
||||
.output()
|
||||
.ok()?;
|
||||
let command = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
(!command.is_empty()).then_some(command)
|
||||
}
|
||||
|
||||
pub(super) fn process_started_at(pid: u32) -> Option<String> {
|
||||
let output = Command::new("/bin/ps")
|
||||
.args(["-p", &pid.to_string(), "-o", "lstart="])
|
||||
.output()
|
||||
.ok()?;
|
||||
let value = String::from_utf8_lossy(&output.stdout)
|
||||
.split_whitespace()
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
(!value.is_empty()).then_some(value)
|
||||
}
|
||||
|
||||
pub(super) fn process_alive(pid: u32) -> bool {
|
||||
pid > 0
|
||||
&& Command::new("/bin/kill")
|
||||
.args(["-0", &pid.to_string()])
|
||||
.status()
|
||||
.is_ok_and(|status| status.success())
|
||||
}
|
||||
|
||||
fn codex_main_pids(runtime: &CodexRuntime) -> Vec<u32> {
|
||||
let prefix = runtime.executable.to_string_lossy();
|
||||
process_table()
|
||||
.into_iter()
|
||||
.filter_map(|(pid, command)| command.starts_with(prefix.as_ref()).then_some(pid))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn codex_is_running(runtime: &CodexRuntime) -> bool {
|
||||
!codex_main_pids(runtime).is_empty()
|
||||
}
|
||||
|
||||
fn debug_port_from_command(command: &str) -> Option<u16> {
|
||||
command.split_whitespace().find_map(|argument| {
|
||||
argument
|
||||
.strip_prefix("--remote-debugging-port=")?
|
||||
.parse::<u16>()
|
||||
.ok()
|
||||
.filter(|port| *port >= 1024)
|
||||
})
|
||||
}
|
||||
|
||||
fn process_parent(pid: u32) -> Option<u32> {
|
||||
let output = Command::new("/bin/ps")
|
||||
.args(["-p", &pid.to_string(), "-o", "ppid="])
|
||||
.output()
|
||||
.ok()?;
|
||||
String::from_utf8_lossy(&output.stdout).trim().parse().ok()
|
||||
}
|
||||
|
||||
fn pid_is_codex_descendant(runtime: &CodexRuntime, mut pid: u32) -> bool {
|
||||
let executable = runtime.executable.to_string_lossy();
|
||||
for _ in 0..32 {
|
||||
if process_command(pid).is_some_and(|command| command.starts_with(executable.as_ref())) {
|
||||
return true;
|
||||
}
|
||||
let Some(parent) = process_parent(pid) else {
|
||||
return false;
|
||||
};
|
||||
if parent <= 1 || parent == pid {
|
||||
return false;
|
||||
}
|
||||
pid = parent;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn listener_pids(port: u16) -> Vec<u32> {
|
||||
let Ok(output) = Command::new("/usr/sbin/lsof")
|
||||
.args(["-nP", &format!("-iTCP:{port}"), "-sTCP:LISTEN", "-t"])
|
||||
.output()
|
||||
else {
|
||||
return Vec::new();
|
||||
};
|
||||
String::from_utf8_lossy(&output.stdout)
|
||||
.lines()
|
||||
.filter_map(|line| line.trim().parse().ok())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn port_belongs_to_codex(runtime: &CodexRuntime, port: u16) -> bool {
|
||||
let pids = listener_pids(port);
|
||||
!pids.is_empty()
|
||||
&& pids
|
||||
.into_iter()
|
||||
.all(|pid| pid_is_codex_descendant(runtime, pid))
|
||||
}
|
||||
|
||||
fn cdp_http_ready(port: u16) -> bool {
|
||||
let Ok(client) = reqwest::blocking::Client::builder()
|
||||
.no_proxy()
|
||||
.timeout(Duration::from_secs(2))
|
||||
.build()
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
client
|
||||
.get(format!("http://127.0.0.1:{port}/json/version"))
|
||||
.send()
|
||||
.is_ok_and(|response| response.status().is_success())
|
||||
}
|
||||
|
||||
fn verified_cdp_endpoint(runtime: &CodexRuntime, port: u16) -> bool {
|
||||
port_belongs_to_codex(runtime, port) && cdp_http_ready(port)
|
||||
}
|
||||
|
||||
fn running_cdp_port(runtime: &CodexRuntime) -> Option<u16> {
|
||||
codex_main_pids(runtime)
|
||||
.into_iter()
|
||||
.filter_map(process_command)
|
||||
.filter_map(|command| debug_port_from_command(&command))
|
||||
.find(|port| verified_cdp_endpoint(runtime, *port))
|
||||
}
|
||||
|
||||
fn select_available_port(preferred: u16) -> Result<u16> {
|
||||
let last = preferred.saturating_add(MAX_CDP_PORT_OFFSET);
|
||||
(preferred..=last)
|
||||
.find(|port| listener_pids(*port).is_empty())
|
||||
.ok_or_else(|| {
|
||||
CodexxError::Config(format!("未找到可用的本机 CDP 端口: {preferred}-{last}"))
|
||||
})
|
||||
}
|
||||
|
||||
fn wait_for_cdp(runtime: &CodexRuntime, port: u16, timeout: Duration) -> Result<()> {
|
||||
let deadline = Instant::now() + timeout;
|
||||
while Instant::now() < deadline {
|
||||
if verified_cdp_endpoint(runtime, port) {
|
||||
return Ok(());
|
||||
}
|
||||
thread::sleep(Duration::from_millis(350));
|
||||
}
|
||||
Err(CodexxError::Config(format!(
|
||||
"Codex 未在 127.0.0.1:{port} 打开经过验证的调试端口"
|
||||
)))
|
||||
}
|
||||
|
||||
fn external_skin_watcher_pid(own_state: Option<&RuntimeStateFile>) -> Option<u32> {
|
||||
process_table().into_iter().find_map(|(pid, command)| {
|
||||
let is_external = command.contains("codex-dream-skin-studio")
|
||||
&& command.contains("injector.mjs")
|
||||
&& command.contains("--watch");
|
||||
(is_external && own_state.is_none_or(|state| state.injector_pid != pid)).then_some(pid)
|
||||
})
|
||||
}
|
||||
|
||||
fn canonical_managed_snapshot(path: &Path) -> Option<PathBuf> {
|
||||
let metadata = fs::symlink_metadata(path).ok()?;
|
||||
if metadata.file_type().is_symlink() || !metadata.is_dir() {
|
||||
return None;
|
||||
}
|
||||
let root = fs::canonicalize(snapshots_root().ok()?).ok()?;
|
||||
let candidate = fs::canonicalize(path).ok()?;
|
||||
candidate.starts_with(&root).then_some(candidate)
|
||||
}
|
||||
|
||||
fn is_managed_injector(path: &Path) -> bool {
|
||||
let Ok(metadata) = fs::symlink_metadata(path) else {
|
||||
return false;
|
||||
};
|
||||
if metadata.file_type().is_symlink() || !metadata.is_file() {
|
||||
return false;
|
||||
}
|
||||
let Ok(root) = runtime_root()
|
||||
.and_then(|path| fs::canonicalize(&path).map_err(|source| io_err(&path, source)))
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
fs::canonicalize(path).is_ok_and(|candidate| {
|
||||
candidate.starts_with(root)
|
||||
&& candidate.file_name().and_then(|name| name.to_str()) == Some("injector.mjs")
|
||||
})
|
||||
}
|
||||
|
||||
fn state_process_matches(state: &RuntimeStateFile, runtime: &CodexRuntime) -> bool {
|
||||
if state.injector_pid == 0
|
||||
|| state.node_path != runtime.node.to_string_lossy()
|
||||
|| !is_managed_injector(Path::new(&state.injector_path))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if canonical_managed_snapshot(Path::new(&state.theme_dir)).is_none() {
|
||||
return false;
|
||||
}
|
||||
let Some(command) = process_command(state.injector_pid) else {
|
||||
return false;
|
||||
};
|
||||
let expected = format!(
|
||||
"{} --watch --port {} --theme-dir {}",
|
||||
state.injector_path, state.port, state.theme_dir
|
||||
);
|
||||
command.starts_with(runtime.node.to_string_lossy().as_ref())
|
||||
&& command.contains(&expected)
|
||||
&& process_started_at(state.injector_pid).as_deref()
|
||||
== Some(state.injector_started_at.as_str())
|
||||
}
|
||||
|
||||
fn stop_recorded_injector(state: &RuntimeStateFile, runtime: &CodexRuntime) -> Result<()> {
|
||||
if state.injector_pid == 0 || !process_alive(state.injector_pid) {
|
||||
return Ok(());
|
||||
}
|
||||
if !state_process_matches(state, runtime) {
|
||||
return Err(CodexxError::Config(format!(
|
||||
"记录的皮肤进程 PID {} 身份不匹配,已拒绝终止",
|
||||
state.injector_pid
|
||||
)));
|
||||
}
|
||||
let pid = state.injector_pid.to_string();
|
||||
let _ = Command::new("/bin/kill").args(["-TERM", &pid]).status();
|
||||
let deadline = Instant::now() + Duration::from_secs(6);
|
||||
while Instant::now() < deadline && state_process_matches(state, runtime) {
|
||||
thread::sleep(Duration::from_millis(150));
|
||||
}
|
||||
if state_process_matches(state, runtime) {
|
||||
let _ = Command::new("/bin/kill").args(["-KILL", &pid]).status();
|
||||
}
|
||||
let deadline = Instant::now() + Duration::from_secs(2);
|
||||
while Instant::now() < deadline && state_process_matches(state, runtime) {
|
||||
thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
if state_process_matches(state, runtime) {
|
||||
return Err(CodexxError::Config(format!(
|
||||
"无法停止皮肤注入器 PID {}",
|
||||
state.injector_pid
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn stop_codex(runtime: &CodexRuntime) -> Result<()> {
|
||||
let _ = Command::new("/usr/bin/osascript")
|
||||
.args(["-e", "tell application id \"com.openai.codex\" to quit"])
|
||||
.status();
|
||||
let deadline = Instant::now() + Duration::from_secs(15);
|
||||
while Instant::now() < deadline && codex_is_running(runtime) {
|
||||
thread::sleep(Duration::from_millis(250));
|
||||
}
|
||||
if !codex_is_running(runtime) {
|
||||
return Ok(());
|
||||
}
|
||||
for pid in codex_main_pids(runtime) {
|
||||
let _ = Command::new("/bin/kill")
|
||||
.args(["-TERM", &pid.to_string()])
|
||||
.status();
|
||||
}
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
while Instant::now() < deadline && codex_is_running(runtime) {
|
||||
thread::sleep(Duration::from_millis(250));
|
||||
}
|
||||
if codex_is_running(runtime) {
|
||||
for pid in codex_main_pids(runtime) {
|
||||
let _ = Command::new("/bin/kill")
|
||||
.args(["-KILL", &pid.to_string()])
|
||||
.status();
|
||||
}
|
||||
thread::sleep(Duration::from_millis(500));
|
||||
}
|
||||
if codex_is_running(runtime) {
|
||||
Err(CodexxError::Config(
|
||||
"无法安全关闭 Codex,换肤操作已中止".to_string(),
|
||||
))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn log_files(prefix: &str) -> Result<(File, File)> {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let root = logs_root()?;
|
||||
ensure_runtime_directory(&root)?;
|
||||
let stdout_path = root.join(format!("{prefix}.log"));
|
||||
let stderr_path = root.join(format!("{prefix}-error.log"));
|
||||
let stdout = OpenOptions::new()
|
||||
.create(true)
|
||||
.write(true)
|
||||
.truncate(true)
|
||||
.open(&stdout_path)
|
||||
.map_err(|source| io_err(&stdout_path, source))?;
|
||||
let stderr = OpenOptions::new()
|
||||
.create(true)
|
||||
.write(true)
|
||||
.truncate(true)
|
||||
.open(&stderr_path)
|
||||
.map_err(|source| io_err(&stderr_path, source))?;
|
||||
fs::set_permissions(&stdout_path, fs::Permissions::from_mode(0o600))
|
||||
.map_err(|source| io_err(&stdout_path, source))?;
|
||||
fs::set_permissions(&stderr_path, fs::Permissions::from_mode(0o600))
|
||||
.map_err(|source| io_err(&stderr_path, source))?;
|
||||
Ok((stdout, stderr))
|
||||
}
|
||||
|
||||
fn launch_codex_with_cdp(runtime: &CodexRuntime, port: u16) -> Result<()> {
|
||||
let (stdout, stderr) = log_files("codex-launch")?;
|
||||
let status = Command::new("/usr/bin/open")
|
||||
.arg("-na")
|
||||
.arg(&runtime.bundle)
|
||||
.arg("--args")
|
||||
.arg("--remote-debugging-address=127.0.0.1")
|
||||
.arg(format!("--remote-debugging-port={port}"))
|
||||
.stdout(Stdio::from(stdout))
|
||||
.stderr(Stdio::from(stderr))
|
||||
.status()
|
||||
.map_err(|source| io_err(&runtime.bundle, source))?;
|
||||
if !status.success() {
|
||||
return Err(CodexxError::Config(
|
||||
"使用本机调试端口启动 Codex 失败".to_string(),
|
||||
));
|
||||
}
|
||||
if wait_for_cdp(runtime, port, Duration::from_secs(12)).is_ok() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if codex_is_running(runtime) {
|
||||
stop_codex(runtime)?;
|
||||
}
|
||||
let (stdout, stderr) = log_files("codex-launch-direct")?;
|
||||
let mut child = Command::new(&runtime.executable)
|
||||
.arg("--remote-debugging-address=127.0.0.1")
|
||||
.arg(format!("--remote-debugging-port={port}"))
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::from(stdout))
|
||||
.stderr(Stdio::from(stderr))
|
||||
.spawn()
|
||||
.map_err(|source| io_err(&runtime.executable, source))?;
|
||||
thread::Builder::new()
|
||||
.name("codex-x-codex-launch-reaper".to_string())
|
||||
.spawn(move || {
|
||||
let _ = child.wait();
|
||||
})
|
||||
.map_err(|error| CodexxError::Config(format!("启动 Codex 进程监控失败: {error}")))?;
|
||||
wait_for_cdp(runtime, port, Duration::from_secs(45))
|
||||
}
|
||||
|
||||
fn launch_codex_normally(runtime: &CodexRuntime) -> Result<()> {
|
||||
let status = Command::new("/usr/bin/open")
|
||||
.arg("-na")
|
||||
.arg(&runtime.bundle)
|
||||
.status()
|
||||
.map_err(|source| io_err(&runtime.bundle, source))?;
|
||||
if status.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(CodexxError::Config(
|
||||
"恢复官方外观后重新启动 Codex 失败".to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn active_state(
|
||||
runtime: &CodexRuntime,
|
||||
assets: &RuntimeAssets,
|
||||
port: u16,
|
||||
theme_id: &str,
|
||||
theme_dir: &Path,
|
||||
pid: u32,
|
||||
started_at: String,
|
||||
) -> RuntimeStateFile {
|
||||
let timestamp = now_rfc3339();
|
||||
RuntimeStateFile {
|
||||
schema_version: RUNTIME_STATE_SCHEMA,
|
||||
runtime_version: RUNTIME_VERSION.to_string(),
|
||||
upstream_commit: UPSTREAM_COMMIT.to_string(),
|
||||
session: "active".to_string(),
|
||||
port,
|
||||
injector_pid: pid,
|
||||
injector_started_at: started_at,
|
||||
injector_path: assets.injector.to_string_lossy().to_string(),
|
||||
node_path: runtime.node.to_string_lossy().to_string(),
|
||||
node_version: runtime.node_version.clone(),
|
||||
codex_bundle: runtime.bundle.to_string_lossy().to_string(),
|
||||
codex_executable: runtime.executable.to_string_lossy().to_string(),
|
||||
codex_version: runtime.version.clone(),
|
||||
codex_team_id: runtime.team_id.clone(),
|
||||
theme_id: theme_id.to_string(),
|
||||
theme_dir: theme_dir.to_string_lossy().to_string(),
|
||||
created_at: timestamp.clone(),
|
||||
updated_at: timestamp,
|
||||
}
|
||||
}
|
||||
|
||||
fn cleanup_snapshot(path: &str, keep: &Path) {
|
||||
let Some(path) = canonical_managed_snapshot(Path::new(path)) else {
|
||||
return;
|
||||
};
|
||||
let keep = canonical_managed_snapshot(keep);
|
||||
if keep.as_ref() != Some(&path) {
|
||||
let _ = fs::remove_dir_all(&path);
|
||||
}
|
||||
}
|
||||
|
||||
fn restore_previous_after_failure(
|
||||
runtime: &CodexRuntime,
|
||||
assets: &RuntimeAssets,
|
||||
previous: Option<&RuntimeStateFile>,
|
||||
) {
|
||||
let Some(previous) = previous else {
|
||||
return;
|
||||
};
|
||||
let theme_dir = Path::new(&previous.theme_dir);
|
||||
if previous.session != "active"
|
||||
|| !theme_dir.is_dir()
|
||||
|| !verified_cdp_endpoint(runtime, previous.port)
|
||||
|| run_injector_once(runtime, assets, previous.port, theme_dir).is_err()
|
||||
{
|
||||
return;
|
||||
}
|
||||
if let Ok((pid, started_at)) = launch_watcher(runtime, assets, previous.port, theme_dir) {
|
||||
let restored = active_state(
|
||||
runtime,
|
||||
assets,
|
||||
previous.port,
|
||||
&previous.theme_id,
|
||||
theme_dir,
|
||||
pid,
|
||||
started_at,
|
||||
);
|
||||
let _ = write_runtime_state(&restored);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn apply_theme(
|
||||
source: &Path,
|
||||
theme_id: &str,
|
||||
restart_existing: bool,
|
||||
) -> Result<SkinRuntimeAction> {
|
||||
let runtime = discover_codex_runtime()?;
|
||||
let assets = ensure_runtime_assets()?;
|
||||
let previous = read_runtime_state()?;
|
||||
if let Some(pid) = external_skin_watcher_pid(previous.as_ref()) {
|
||||
return Err(CodexxError::Config(format!(
|
||||
"检测到外部 Codex Dream Skin 注入器 PID {pid},请先在原工具中暂停或恢复官方外观"
|
||||
)));
|
||||
}
|
||||
|
||||
let running = codex_is_running(&runtime);
|
||||
let verified_port = previous
|
||||
.as_ref()
|
||||
.map(|state| state.port)
|
||||
.filter(|port| verified_cdp_endpoint(&runtime, *port))
|
||||
.or_else(|| running_cdp_port(&runtime));
|
||||
if running && verified_port.is_none() && !restart_existing {
|
||||
return Ok(SkinRuntimeAction::RestartRequired(
|
||||
"Codex 需要重启一次才能打开仅限本机的换肤调试端口".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let snapshot = stage_theme_snapshot(&runtime, &assets, source, theme_id)?;
|
||||
let apply_result = (|| -> Result<RuntimeStateFile> {
|
||||
if let Some(state) = previous.as_ref() {
|
||||
stop_recorded_injector(state, &runtime)?;
|
||||
}
|
||||
let port = if let Some(port) = verified_port {
|
||||
port
|
||||
} else {
|
||||
if running {
|
||||
stop_codex(&runtime)?;
|
||||
}
|
||||
let preferred = previous
|
||||
.as_ref()
|
||||
.map_or(DEFAULT_CDP_PORT, |state| state.port);
|
||||
let port = select_available_port(preferred)?;
|
||||
launch_codex_with_cdp(&runtime, port)?;
|
||||
port
|
||||
};
|
||||
run_injector_once(&runtime, &assets, port, &snapshot)?;
|
||||
let (pid, started_at) = launch_watcher(&runtime, &assets, port, &snapshot)?;
|
||||
let state = active_state(
|
||||
&runtime, &assets, port, theme_id, &snapshot, pid, started_at,
|
||||
);
|
||||
if let Err(error) = write_runtime_state(&state) {
|
||||
let _ = stop_recorded_injector(&state, &runtime);
|
||||
return Err(error);
|
||||
}
|
||||
Ok(state)
|
||||
})();
|
||||
|
||||
match apply_result {
|
||||
Ok(_) => {
|
||||
if let Some(previous) = previous.as_ref() {
|
||||
cleanup_snapshot(&previous.theme_dir, &snapshot);
|
||||
}
|
||||
Ok(SkinRuntimeAction::Applied("已应用 Codex 皮肤".to_string()))
|
||||
}
|
||||
Err(error) => {
|
||||
let _ = fs::remove_dir_all(&snapshot);
|
||||
restore_previous_after_failure(&runtime, &assets, previous.as_ref());
|
||||
Err(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn pause_theme() -> Result<SkinRuntimeAction> {
|
||||
let assets = ensure_runtime_assets()?;
|
||||
let Some(mut state) = read_runtime_state()? else {
|
||||
return Ok(SkinRuntimeAction::Paused(
|
||||
"Codex 皮肤当前没有运行".to_string(),
|
||||
));
|
||||
};
|
||||
let runtime = discover_codex_runtime()?;
|
||||
if let Some(pid) = external_skin_watcher_pid(Some(&state)) {
|
||||
return Err(CodexxError::Config(format!(
|
||||
"检测到外部 Codex Dream Skin 注入器 PID {pid},未处理该进程"
|
||||
)));
|
||||
}
|
||||
stop_recorded_injector(&state, &runtime)?;
|
||||
if verified_cdp_endpoint(&runtime, state.port) {
|
||||
if let Err(error) =
|
||||
remove_live_skin(&runtime, &assets, state.port, Path::new(&state.theme_dir))
|
||||
{
|
||||
restore_previous_after_failure(&runtime, &assets, Some(&state));
|
||||
return Err(error);
|
||||
}
|
||||
}
|
||||
state.session = "paused".to_string();
|
||||
state.injector_pid = 0;
|
||||
state.injector_started_at.clear();
|
||||
state.updated_at = now_rfc3339();
|
||||
write_runtime_state(&state)?;
|
||||
Ok(SkinRuntimeAction::Paused(
|
||||
"已关闭 Codex 皮肤;Codex 保持打开".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
pub(super) fn restore_official(restart_existing: bool) -> Result<SkinRuntimeAction> {
|
||||
let assets = ensure_runtime_assets()?;
|
||||
let Some(state) = read_runtime_state()? else {
|
||||
return Ok(SkinRuntimeAction::Restored(
|
||||
"Codex 当前已使用官方外观".to_string(),
|
||||
));
|
||||
};
|
||||
let runtime = discover_codex_runtime()?;
|
||||
if let Some(pid) = external_skin_watcher_pid(Some(&state)) {
|
||||
return Err(CodexxError::Config(format!(
|
||||
"检测到外部 Codex Dream Skin 注入器 PID {pid},未处理该进程"
|
||||
)));
|
||||
}
|
||||
let running = codex_is_running(&runtime);
|
||||
let debug_ready = verified_cdp_endpoint(&runtime, state.port);
|
||||
if running && (debug_ready || state.session == "active") && !restart_existing {
|
||||
return Ok(SkinRuntimeAction::RestartRequired(
|
||||
"完整恢复需要重启 Codex,以关闭本机调试端口并清除当前渲染状态".to_string(),
|
||||
));
|
||||
}
|
||||
stop_recorded_injector(&state, &runtime)?;
|
||||
if debug_ready {
|
||||
remove_live_skin(&runtime, &assets, state.port, Path::new(&state.theme_dir))?;
|
||||
}
|
||||
if running && (debug_ready || state.session == "active") {
|
||||
stop_codex(&runtime)?;
|
||||
launch_codex_normally(&runtime)?;
|
||||
}
|
||||
remove_runtime_state()?;
|
||||
cleanup_snapshot(&state.theme_dir, Path::new(""));
|
||||
Ok(SkinRuntimeAction::Restored(
|
||||
"已恢复 Codex 官方外观并关闭 Codex-X 管理的换肤运行时".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
pub(super) fn runtime_status() -> SkinRuntimeStatus {
|
||||
if let Err(error) = ensure_runtime_assets() {
|
||||
return SkinRuntimeStatus {
|
||||
supported: true,
|
||||
active: false,
|
||||
phase: "error".to_string(),
|
||||
port: None,
|
||||
theme_id: None,
|
||||
message: error.to_string(),
|
||||
};
|
||||
}
|
||||
let state = match read_runtime_state() {
|
||||
Ok(Some(state)) => state,
|
||||
Ok(None) => {
|
||||
return SkinRuntimeStatus {
|
||||
supported: true,
|
||||
active: false,
|
||||
phase: "inactive".to_string(),
|
||||
port: None,
|
||||
theme_id: None,
|
||||
message: "尚未应用 Codex 皮肤".to_string(),
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
return SkinRuntimeStatus {
|
||||
supported: true,
|
||||
active: false,
|
||||
phase: "error".to_string(),
|
||||
port: None,
|
||||
theme_id: None,
|
||||
message: error.to_string(),
|
||||
}
|
||||
}
|
||||
};
|
||||
if state.session == "paused" {
|
||||
return SkinRuntimeStatus {
|
||||
supported: true,
|
||||
active: false,
|
||||
phase: "paused".to_string(),
|
||||
port: Some(state.port),
|
||||
theme_id: Some(state.theme_id),
|
||||
message: "皮肤已暂停,Codex 保持打开".to_string(),
|
||||
};
|
||||
}
|
||||
let runtime = match discover_codex_runtime() {
|
||||
Ok(runtime) => runtime,
|
||||
Err(error) => {
|
||||
return SkinRuntimeStatus {
|
||||
supported: true,
|
||||
active: false,
|
||||
phase: "unavailable".to_string(),
|
||||
port: Some(state.port),
|
||||
theme_id: Some(state.theme_id),
|
||||
message: error.to_string(),
|
||||
}
|
||||
}
|
||||
};
|
||||
let active =
|
||||
state_process_matches(&state, &runtime) && verified_cdp_endpoint(&runtime, state.port);
|
||||
SkinRuntimeStatus {
|
||||
supported: true,
|
||||
active,
|
||||
phase: if active { "active" } else { "stale" }.to_string(),
|
||||
port: Some(state.port),
|
||||
theme_id: Some(state.theme_id),
|
||||
message: if active {
|
||||
"Codex 皮肤运行中".to_string()
|
||||
} else {
|
||||
"皮肤运行状态已失效,可重新应用或恢复官方外观".to_string()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn debug_port_parser_accepts_only_bounded_numeric_arguments() {
|
||||
assert_eq!(
|
||||
debug_port_from_command(
|
||||
"/Applications/ChatGPT.app/Contents/MacOS/ChatGPT --remote-debugging-port=9341"
|
||||
),
|
||||
Some(9341)
|
||||
);
|
||||
assert_eq!(
|
||||
debug_port_from_command("Codex --remote-debugging-port=80"),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
debug_port_from_command("Codex --remote-debugging-port=not-a-port"),
|
||||
None
|
||||
);
|
||||
assert_eq!(debug_port_from_command("Codex"), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
use super::assets::RuntimeAssets;
|
||||
use super::macos::{log_files, process_alive, process_started_at, snapshots_root};
|
||||
use super::macos_system::CodexRuntime;
|
||||
use super::node_runtime;
|
||||
use crate::error::{CodexxError, Result};
|
||||
use crate::file_io::io_err;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Stdio};
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
pub(super) fn stage_theme_snapshot(
|
||||
runtime: &CodexRuntime,
|
||||
assets: &RuntimeAssets,
|
||||
source: &Path,
|
||||
theme_id: &str,
|
||||
) -> Result<PathBuf> {
|
||||
node_runtime::stage_theme_snapshot(&runtime.node, assets, &snapshots_root()?, source, theme_id)
|
||||
}
|
||||
|
||||
pub(super) fn run_injector_once(
|
||||
runtime: &CodexRuntime,
|
||||
assets: &RuntimeAssets,
|
||||
port: u16,
|
||||
theme_dir: &Path,
|
||||
) -> Result<()> {
|
||||
node_runtime::run_injector_once(&runtime.node, assets, port, theme_dir)
|
||||
}
|
||||
|
||||
pub(super) fn remove_live_skin(
|
||||
runtime: &CodexRuntime,
|
||||
assets: &RuntimeAssets,
|
||||
port: u16,
|
||||
theme_dir: &Path,
|
||||
) -> Result<()> {
|
||||
node_runtime::remove_live_skin(&runtime.node, assets, port, theme_dir)
|
||||
}
|
||||
|
||||
pub(super) fn launch_watcher(
|
||||
runtime: &CodexRuntime,
|
||||
assets: &RuntimeAssets,
|
||||
port: u16,
|
||||
theme_dir: &Path,
|
||||
) -> Result<(u32, String)> {
|
||||
let (stdout, stderr) = log_files("injector")?;
|
||||
let mut child = Command::new(&runtime.node)
|
||||
.arg(&assets.injector)
|
||||
.args(["--watch", "--port"])
|
||||
.arg(port.to_string())
|
||||
.arg("--theme-dir")
|
||||
.arg(theme_dir)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::from(stdout))
|
||||
.stderr(Stdio::from(stderr))
|
||||
.spawn()
|
||||
.map_err(|source| io_err(&assets.injector, source))?;
|
||||
let pid = child.id();
|
||||
thread::Builder::new()
|
||||
.name("codex-x-skin-injector-reaper".to_string())
|
||||
.spawn(move || {
|
||||
let _ = child.wait();
|
||||
})
|
||||
.map_err(|error| CodexxError::Config(format!("启动皮肤进程监控失败: {error}")))?;
|
||||
thread::sleep(Duration::from_millis(180));
|
||||
if !process_alive(pid) {
|
||||
return Err(CodexxError::Config(
|
||||
"皮肤注入器启动后立即退出,请检查运行日志".to_string(),
|
||||
));
|
||||
}
|
||||
let started_at = process_started_at(pid)
|
||||
.ok_or_else(|| CodexxError::Config("无法记录皮肤注入器启动时间".to_string()))?;
|
||||
Ok((pid, started_at))
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
use super::node_runtime::command_detail;
|
||||
use crate::error::{CodexxError, Result};
|
||||
use crate::file_io::io_err;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
const EXPECTED_BUNDLE_ID: &str = "com.openai.codex";
|
||||
const EXPECTED_TEAM_ID: &str = "2DC432GLL2";
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(super) struct CodexRuntime {
|
||||
pub(super) bundle: PathBuf,
|
||||
pub(super) executable: PathBuf,
|
||||
pub(super) version: String,
|
||||
pub(super) node: PathBuf,
|
||||
pub(super) node_version: String,
|
||||
pub(super) team_id: String,
|
||||
}
|
||||
|
||||
fn output_text(program: &Path, args: &[&str], label: &str) -> Result<String> {
|
||||
let output = Command::new(program)
|
||||
.args(args)
|
||||
.output()
|
||||
.map_err(|source| io_err(program, source))?;
|
||||
if !output.status.success() {
|
||||
let detail = command_detail(&output);
|
||||
return Err(CodexxError::Config(if detail.is_empty() {
|
||||
format!("{label}失败")
|
||||
} else {
|
||||
format!("{label}失败: {detail}")
|
||||
}));
|
||||
}
|
||||
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
|
||||
}
|
||||
|
||||
fn plist_value(bundle: &Path, key: &str) -> Result<String> {
|
||||
let plist = bundle.join("Contents/Info.plist");
|
||||
output_text(
|
||||
Path::new("/usr/bin/plutil"),
|
||||
&["-extract", key, "raw", "-o", "-", &plist.to_string_lossy()],
|
||||
"读取 Codex 应用信息",
|
||||
)
|
||||
}
|
||||
|
||||
fn bundle_candidates() -> Result<Vec<PathBuf>> {
|
||||
let home = crate::paths::home_dir()?;
|
||||
let mut candidates = vec![
|
||||
PathBuf::from("/Applications/ChatGPT.app"),
|
||||
home.join("Applications/ChatGPT.app"),
|
||||
PathBuf::from("/Applications/Codex.app"),
|
||||
home.join("Applications/Codex.app"),
|
||||
];
|
||||
if let Ok(output) = Command::new("/usr/bin/mdfind")
|
||||
.arg(format!(
|
||||
"kMDItemCFBundleIdentifier == \"{EXPECTED_BUNDLE_ID}\""
|
||||
))
|
||||
.output()
|
||||
{
|
||||
if output.status.success() {
|
||||
candidates.extend(
|
||||
String::from_utf8_lossy(&output.stdout)
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.filter(|line| !line.is_empty())
|
||||
.map(PathBuf::from),
|
||||
);
|
||||
}
|
||||
}
|
||||
candidates.dedup();
|
||||
Ok(candidates)
|
||||
}
|
||||
|
||||
fn codesign_team_id(path: &Path) -> Result<String> {
|
||||
let output = Command::new("/usr/bin/codesign")
|
||||
.args(["-dv", "--verbose=4"])
|
||||
.arg(path)
|
||||
.output()
|
||||
.map_err(|source| io_err(path, source))?;
|
||||
let combined = format!(
|
||||
"{}\n{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
combined
|
||||
.lines()
|
||||
.find_map(|line| line.trim().strip_prefix("TeamIdentifier="))
|
||||
.map(str::to_string)
|
||||
.ok_or_else(|| CodexxError::Config("无法读取 Codex 签名 Team ID".to_string()))
|
||||
}
|
||||
|
||||
fn verify_signature(path: &Path, deep: bool) -> Result<()> {
|
||||
let mut command = Command::new("/usr/bin/codesign");
|
||||
command.arg("--verify");
|
||||
if deep {
|
||||
command.arg("--deep");
|
||||
}
|
||||
let output = command
|
||||
.arg("--strict")
|
||||
.arg(path)
|
||||
.output()
|
||||
.map_err(|source| io_err(path, source))?;
|
||||
if output.status.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(CodexxError::Config(format!(
|
||||
"官方 Codex 代码签名校验失败: {}",
|
||||
path.display()
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn discover_codex_runtime() -> Result<CodexRuntime> {
|
||||
let mut bundle = None;
|
||||
for candidate in bundle_candidates()? {
|
||||
if !candidate.join("Contents/Info.plist").is_file() {
|
||||
continue;
|
||||
}
|
||||
if plist_value(&candidate, "CFBundleIdentifier")
|
||||
.ok()
|
||||
.as_deref()
|
||||
== Some(EXPECTED_BUNDLE_ID)
|
||||
{
|
||||
bundle = Some(candidate);
|
||||
break;
|
||||
}
|
||||
}
|
||||
let bundle = bundle.ok_or_else(|| {
|
||||
CodexxError::Config("未找到官方 Codex Desktop (com.openai.codex)".to_string())
|
||||
})?;
|
||||
let executable_name = plist_value(&bundle, "CFBundleExecutable")?;
|
||||
if executable_name.is_empty() || executable_name.contains('/') || executable_name.contains('\\')
|
||||
{
|
||||
return Err(CodexxError::Config(
|
||||
"Codex 应用声明了无效的可执行文件名".to_string(),
|
||||
));
|
||||
}
|
||||
let executable = bundle.join("Contents/MacOS").join(executable_name);
|
||||
let node = bundle.join("Contents/Resources/cua_node/bin/node");
|
||||
if !executable.is_file() || !node.is_file() {
|
||||
return Err(CodexxError::Config(
|
||||
"Codex 可执行文件或内置 Node.js 不存在,请更新或重新安装官方 Codex".to_string(),
|
||||
));
|
||||
}
|
||||
verify_signature(&bundle, true)?;
|
||||
verify_signature(&node, false)?;
|
||||
let team_id = codesign_team_id(&bundle)?;
|
||||
let node_team_id = codesign_team_id(&node)?;
|
||||
if team_id != EXPECTED_TEAM_ID || node_team_id != team_id {
|
||||
return Err(CodexxError::Config(format!(
|
||||
"Codex 签名身份不匹配,拒绝启动换肤运行时: {team_id}"
|
||||
)));
|
||||
}
|
||||
let node_version = output_text(&node, &["--version"], "读取 Codex Node.js 版本")?;
|
||||
let node_major = node_version
|
||||
.trim_start_matches('v')
|
||||
.split('.')
|
||||
.next()
|
||||
.and_then(|value| value.parse::<u32>().ok())
|
||||
.ok_or_else(|| CodexxError::Config(format!("无法解析 Node.js 版本: {node_version}")))?;
|
||||
if node_major < 20 {
|
||||
return Err(CodexxError::Config(format!(
|
||||
"Codex 内置 Node.js 版本过低: {node_version},需要 20 或更高版本"
|
||||
)));
|
||||
}
|
||||
let machine_arch = output_text(Path::new("/usr/bin/uname"), &["-m"], "读取系统架构")?;
|
||||
let node_file = output_text(
|
||||
Path::new("/usr/bin/file"),
|
||||
&[&node.to_string_lossy()],
|
||||
"读取 Codex Node.js 架构",
|
||||
)?;
|
||||
if !node_file.contains(&machine_arch) {
|
||||
return Err(CodexxError::Config(format!(
|
||||
"Codex 内置 Node.js 与当前 Mac 架构不匹配: {machine_arch}"
|
||||
)));
|
||||
}
|
||||
Ok(CodexRuntime {
|
||||
version: plist_value(&bundle, "CFBundleShortVersionString")?,
|
||||
bundle,
|
||||
executable,
|
||||
node,
|
||||
node_version,
|
||||
team_id,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn command_detail_is_bounded() {
|
||||
let output = Command::new("/usr/bin/printf")
|
||||
.arg("%0900d")
|
||||
.arg("1")
|
||||
.output()
|
||||
.expect("create fixture output");
|
||||
assert!(command_detail(&output).chars().count() <= 800);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
|
||||
use crate::error::CodexxError;
|
||||
use crate::error::Result;
|
||||
use serde::Serialize;
|
||||
use std::path::Path;
|
||||
|
||||
mod assets;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod macos;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod macos_injector;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod macos_system;
|
||||
mod node_runtime;
|
||||
#[cfg(any(target_os = "windows", feature = "windows-runtime-check"))]
|
||||
#[cfg_attr(feature = "windows-runtime-check", allow(dead_code))]
|
||||
mod windows;
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct SkinRuntimeStatus {
|
||||
pub(crate) supported: bool,
|
||||
pub(crate) active: bool,
|
||||
pub(crate) phase: String,
|
||||
pub(crate) port: Option<u16>,
|
||||
pub(crate) theme_id: Option<String>,
|
||||
pub(crate) message: String,
|
||||
}
|
||||
|
||||
impl SkinRuntimeStatus {
|
||||
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
|
||||
fn unsupported() -> Self {
|
||||
Self {
|
||||
supported: false,
|
||||
active: false,
|
||||
phase: "unsupported".to_string(),
|
||||
port: None,
|
||||
theme_id: None,
|
||||
message: "当前版本的 Codex 实机换肤仅支持 macOS 和 Windows".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) enum SkinRuntimeAction {
|
||||
Applied(String),
|
||||
RestartRequired(String),
|
||||
Paused(String),
|
||||
Restored(String),
|
||||
}
|
||||
|
||||
pub(crate) fn skin_runtime_status() -> SkinRuntimeStatus {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
macos::runtime_status()
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
windows::runtime_status()
|
||||
}
|
||||
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
|
||||
{
|
||||
SkinRuntimeStatus::unsupported()
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn apply_skin_runtime(
|
||||
theme_dir: &Path,
|
||||
theme_id: &str,
|
||||
restart_existing: bool,
|
||||
) -> Result<SkinRuntimeAction> {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
macos::apply_theme(theme_dir, theme_id, restart_existing)
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
windows::apply_theme(theme_dir, theme_id, restart_existing)
|
||||
}
|
||||
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
|
||||
{
|
||||
let _ = (theme_dir, theme_id, restart_existing);
|
||||
Err(CodexxError::Config(
|
||||
"当前版本的 Codex 实机换肤仅支持 macOS 和 Windows".to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn pause_skin_runtime() -> Result<SkinRuntimeAction> {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
macos::pause_theme()
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
windows::pause_theme()
|
||||
}
|
||||
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
|
||||
{
|
||||
Err(CodexxError::Config(
|
||||
"当前版本的 Codex 实机换肤仅支持 macOS 和 Windows".to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn restore_skin_runtime(restart_existing: bool) -> Result<SkinRuntimeAction> {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
macos::restore_official(restart_existing)
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
windows::restore_official(restart_existing)
|
||||
}
|
||||
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
|
||||
{
|
||||
let _ = restart_existing;
|
||||
Err(CodexxError::Config(
|
||||
"当前版本的 Codex 实机换肤仅支持 macOS 和 Windows".to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
use super::assets::{ensure_runtime_directory, RuntimeAssets};
|
||||
use crate::error::{CodexxError, Result};
|
||||
use crate::file_io::io_err;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Stdio};
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
const COMMAND_POLL_INTERVAL: Duration = Duration::from_millis(25);
|
||||
const THEME_STAGE_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
const PAYLOAD_CHECK_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
const INJECTOR_ONCE_TIMEOUT: Duration = Duration::from_secs(35);
|
||||
const INJECTOR_REMOVE_TIMEOUT: Duration = Duration::from_secs(20);
|
||||
|
||||
pub(super) fn command_detail(output: &std::process::Output) -> String {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
let detail = if stderr.is_empty() { stdout } else { stderr };
|
||||
if detail.chars().count() <= 800 {
|
||||
detail
|
||||
} else {
|
||||
detail
|
||||
.chars()
|
||||
.rev()
|
||||
.take(800)
|
||||
.collect::<String>()
|
||||
.chars()
|
||||
.rev()
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn wait_for_output(
|
||||
mut child: std::process::Child,
|
||||
program: &Path,
|
||||
label: &str,
|
||||
timeout: Duration,
|
||||
) -> Result<std::process::Output> {
|
||||
let deadline = Instant::now() + timeout;
|
||||
loop {
|
||||
match child.try_wait().map_err(|source| io_err(program, source))? {
|
||||
Some(_) => {
|
||||
return child
|
||||
.wait_with_output()
|
||||
.map_err(|source| io_err(program, source))
|
||||
}
|
||||
None if Instant::now() >= deadline => {
|
||||
let _ = child.kill();
|
||||
let output = child
|
||||
.wait_with_output()
|
||||
.map_err(|source| io_err(program, source))?;
|
||||
let detail = command_detail(&output);
|
||||
let suffix = if detail.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(": {detail}")
|
||||
};
|
||||
return Err(CodexxError::Config(format!(
|
||||
"{label}超时({} 秒){suffix}",
|
||||
timeout.as_secs()
|
||||
)));
|
||||
}
|
||||
None => thread::sleep(COMMAND_POLL_INTERVAL),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn run_node(
|
||||
node: &Path,
|
||||
script: &Path,
|
||||
args: &[String],
|
||||
label: &str,
|
||||
timeout: Duration,
|
||||
) -> Result<String> {
|
||||
let child = Command::new(node)
|
||||
.arg(script)
|
||||
.args(args)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|source| io_err(script, source))?;
|
||||
let output = wait_for_output(child, script, label, timeout)?;
|
||||
if output.status.success() {
|
||||
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
|
||||
} else {
|
||||
let detail = command_detail(&output);
|
||||
Err(CodexxError::Config(if detail.is_empty() {
|
||||
format!("{label}失败")
|
||||
} else {
|
||||
format!("{label}失败: {detail}")
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn stage_theme_snapshot(
|
||||
node: &Path,
|
||||
assets: &RuntimeAssets,
|
||||
snapshots_root: &Path,
|
||||
source: &Path,
|
||||
theme_id: &str,
|
||||
) -> Result<PathBuf> {
|
||||
ensure_runtime_directory(snapshots_root)?;
|
||||
let snapshot = snapshots_root.join(format!(
|
||||
"{}-{}-{}",
|
||||
theme_id,
|
||||
std::process::id(),
|
||||
chrono::Local::now()
|
||||
.timestamp_nanos_opt()
|
||||
.unwrap_or_default()
|
||||
));
|
||||
ensure_runtime_directory(&snapshot)?;
|
||||
let result = (|| -> Result<()> {
|
||||
run_node(
|
||||
node,
|
||||
&assets.stage_theme,
|
||||
&[
|
||||
source.to_string_lossy().to_string(),
|
||||
snapshot.to_string_lossy().to_string(),
|
||||
],
|
||||
"暂存主题",
|
||||
THEME_STAGE_TIMEOUT,
|
||||
)?;
|
||||
run_node(
|
||||
node,
|
||||
&assets.injector,
|
||||
&[
|
||||
"--check-payload".to_string(),
|
||||
"--theme-dir".to_string(),
|
||||
snapshot.to_string_lossy().to_string(),
|
||||
],
|
||||
"校验主题注入负载",
|
||||
PAYLOAD_CHECK_TIMEOUT,
|
||||
)?;
|
||||
Ok(())
|
||||
})();
|
||||
if result.is_err() {
|
||||
let _ = fs::remove_dir_all(&snapshot);
|
||||
}
|
||||
result.map(|_| snapshot)
|
||||
}
|
||||
|
||||
pub(super) fn run_injector_once(
|
||||
node: &Path,
|
||||
assets: &RuntimeAssets,
|
||||
port: u16,
|
||||
theme_dir: &Path,
|
||||
) -> Result<()> {
|
||||
run_node(
|
||||
node,
|
||||
&assets.injector,
|
||||
&[
|
||||
"--once".to_string(),
|
||||
"--port".to_string(),
|
||||
port.to_string(),
|
||||
"--theme-dir".to_string(),
|
||||
theme_dir.to_string_lossy().to_string(),
|
||||
"--timeout-ms".to_string(),
|
||||
"20000".to_string(),
|
||||
],
|
||||
"注入并验证 Codex 皮肤",
|
||||
INJECTOR_ONCE_TIMEOUT,
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn remove_live_skin(
|
||||
node: &Path,
|
||||
assets: &RuntimeAssets,
|
||||
port: u16,
|
||||
theme_dir: &Path,
|
||||
) -> Result<()> {
|
||||
run_node(
|
||||
node,
|
||||
&assets.injector,
|
||||
&[
|
||||
"--remove".to_string(),
|
||||
"--port".to_string(),
|
||||
port.to_string(),
|
||||
"--theme-dir".to_string(),
|
||||
theme_dir.to_string_lossy().to_string(),
|
||||
"--timeout-ms".to_string(),
|
||||
"10000".to_string(),
|
||||
],
|
||||
"移除 Codex 皮肤",
|
||||
INJECTOR_REMOVE_TIMEOUT,
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn wait_for_output_collects_a_completed_child() {
|
||||
let child = Command::new("/bin/echo")
|
||||
.arg("skin-ready")
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.expect("spawn echo fixture");
|
||||
let output = wait_for_output(
|
||||
child,
|
||||
Path::new("/bin/echo"),
|
||||
"测试皮肤命令",
|
||||
Duration::from_secs(1),
|
||||
)
|
||||
.expect("collect output");
|
||||
assert!(output.status.success());
|
||||
assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "skin-ready");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wait_for_output_terminates_a_stuck_child() {
|
||||
let child = Command::new("/bin/sleep")
|
||||
.arg("5")
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.expect("spawn sleep fixture");
|
||||
let started = Instant::now();
|
||||
let error = wait_for_output(
|
||||
child,
|
||||
Path::new("/bin/sleep"),
|
||||
"测试皮肤命令",
|
||||
Duration::from_millis(80),
|
||||
)
|
||||
.expect_err("timeout stuck child");
|
||||
assert!(error.to_string().contains("超时"));
|
||||
assert!(started.elapsed() < Duration::from_secs(2));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,869 @@
|
||||
use super::assets::{ensure_runtime_assets, ensure_runtime_directory, RuntimeAssets};
|
||||
use super::node_runtime;
|
||||
use super::{SkinRuntimeAction, SkinRuntimeStatus};
|
||||
use crate::error::{CodexxError, Result};
|
||||
use crate::file_io::{atomic_write, io_err};
|
||||
use crate::now_rfc3339;
|
||||
use crate::skins::skins_root;
|
||||
use serde::{de::DeserializeOwned, Deserialize, Serialize};
|
||||
use std::env;
|
||||
use std::fs::{self, File, OpenOptions};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Stdio};
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
use std::os::windows::process::CommandExt;
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
|
||||
const DEFAULT_CDP_PORT: u16 = 9341;
|
||||
const RUNTIME_STATE_SCHEMA: u32 = 1;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct RuntimeStateFile {
|
||||
schema_version: u32,
|
||||
platform: String,
|
||||
session: String,
|
||||
port: u16,
|
||||
browser_id: String,
|
||||
injector_pid: u32,
|
||||
injector_started_at: String,
|
||||
injector_path: String,
|
||||
node_path: String,
|
||||
node_version: String,
|
||||
codex_package_root: String,
|
||||
codex_executable: String,
|
||||
codex_version: String,
|
||||
codex_package_full_name: String,
|
||||
codex_package_family_name: String,
|
||||
codex_app_user_model_id: String,
|
||||
theme_id: String,
|
||||
theme_dir: String,
|
||||
created_at: String,
|
||||
updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct CodexRuntime {
|
||||
package_root: PathBuf,
|
||||
executable: PathBuf,
|
||||
version: String,
|
||||
package_full_name: String,
|
||||
package_family_name: String,
|
||||
app_user_model_id: String,
|
||||
running: bool,
|
||||
debug_port: Option<u16>,
|
||||
browser_id: Option<String>,
|
||||
watcher_pids: Vec<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct PortResult {
|
||||
port: u16,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct LaunchResult {
|
||||
port: u16,
|
||||
browser_id: String,
|
||||
strategy: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct VerifyPortResult {
|
||||
verified: bool,
|
||||
browser_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ProcessInfo {
|
||||
alive: bool,
|
||||
path: Option<String>,
|
||||
command_line: Option<String>,
|
||||
started_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ActiveResult {
|
||||
active: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct NodeRuntime {
|
||||
path: PathBuf,
|
||||
version: String,
|
||||
}
|
||||
|
||||
struct ActiveStateContext<'a> {
|
||||
runtime: &'a CodexRuntime,
|
||||
node: &'a NodeRuntime,
|
||||
assets: &'a RuntimeAssets,
|
||||
port: u16,
|
||||
browser_id: String,
|
||||
theme_id: &'a str,
|
||||
theme_dir: &'a Path,
|
||||
injector_pid: u32,
|
||||
injector_started_at: String,
|
||||
}
|
||||
|
||||
fn runtime_root() -> Result<PathBuf> {
|
||||
Ok(skins_root()?.join("runtime"))
|
||||
}
|
||||
|
||||
fn state_path() -> Result<PathBuf> {
|
||||
Ok(runtime_root()?.join("state.json"))
|
||||
}
|
||||
|
||||
fn snapshots_root() -> Result<PathBuf> {
|
||||
Ok(runtime_root()?.join("themes"))
|
||||
}
|
||||
|
||||
fn logs_root() -> Result<PathBuf> {
|
||||
Ok(runtime_root()?.join("logs"))
|
||||
}
|
||||
|
||||
fn powershell_path() -> PathBuf {
|
||||
env::var_os("SystemRoot")
|
||||
.map(PathBuf::from)
|
||||
.map(|root| {
|
||||
root.join("System32")
|
||||
.join("WindowsPowerShell")
|
||||
.join("v1.0")
|
||||
.join("powershell.exe")
|
||||
})
|
||||
.filter(|path| path.is_file())
|
||||
.unwrap_or_else(|| PathBuf::from("powershell.exe"))
|
||||
}
|
||||
|
||||
fn configure_background_command(command: &mut Command) {
|
||||
#[cfg(target_os = "windows")]
|
||||
command.creation_flags(CREATE_NO_WINDOW);
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
let _ = command;
|
||||
}
|
||||
|
||||
fn parse_last_json<T: DeserializeOwned>(stdout: &[u8], label: &str) -> Result<T> {
|
||||
let text = String::from_utf8_lossy(stdout);
|
||||
text.lines()
|
||||
.rev()
|
||||
.map(str::trim)
|
||||
.filter(|line| !line.is_empty())
|
||||
.find_map(|line| serde_json::from_str(line).ok())
|
||||
.ok_or_else(|| CodexxError::Config(format!("{label}没有返回有效结果")))
|
||||
}
|
||||
|
||||
fn run_adapter<T: DeserializeOwned>(
|
||||
assets: &RuntimeAssets,
|
||||
action: &str,
|
||||
args: &[String],
|
||||
timeout: Duration,
|
||||
) -> Result<T> {
|
||||
let powershell = powershell_path();
|
||||
let mut command = Command::new(&powershell);
|
||||
command
|
||||
.args(["-NoLogo", "-NoProfile", "-NonInteractive", "-File"])
|
||||
.arg(&assets.windows_adapter)
|
||||
.args(["-Action", action])
|
||||
.args(args)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
configure_background_command(&mut command);
|
||||
let child = command
|
||||
.spawn()
|
||||
.map_err(|source| io_err(&powershell, source))?;
|
||||
let output = node_runtime::wait_for_output(child, &powershell, action, timeout)?;
|
||||
if !output.status.success() {
|
||||
let detail = node_runtime::command_detail(&output);
|
||||
return Err(CodexxError::Config(if detail.is_empty() {
|
||||
format!("Windows 皮肤操作失败: {action}")
|
||||
} else {
|
||||
format!("Windows 皮肤操作失败: {detail}")
|
||||
}));
|
||||
}
|
||||
parse_last_json(&output.stdout, action)
|
||||
}
|
||||
|
||||
fn inspect_codex(assets: &RuntimeAssets) -> Result<CodexRuntime> {
|
||||
run_adapter(assets, "inspect", &[], Duration::from_secs(15))
|
||||
}
|
||||
|
||||
fn select_port(assets: &RuntimeAssets, preferred: u16) -> Result<u16> {
|
||||
let result: PortResult = run_adapter(
|
||||
assets,
|
||||
"selectPort",
|
||||
&["-Port".to_string(), preferred.to_string()],
|
||||
Duration::from_secs(10),
|
||||
)?;
|
||||
Ok(result.port)
|
||||
}
|
||||
|
||||
fn verify_port(assets: &RuntimeAssets, port: u16) -> Result<VerifyPortResult> {
|
||||
run_adapter(
|
||||
assets,
|
||||
"verifyPort",
|
||||
&["-Port".to_string(), port.to_string()],
|
||||
Duration::from_secs(10),
|
||||
)
|
||||
}
|
||||
|
||||
fn stop_codex(assets: &RuntimeAssets) -> Result<()> {
|
||||
let _: serde_json::Value = run_adapter(assets, "stopCodex", &[], Duration::from_secs(25))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn launch_codex_normally(assets: &RuntimeAssets) -> Result<()> {
|
||||
let _: serde_json::Value = run_adapter(assets, "launchNormal", &[], Duration::from_secs(15))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn launch_codex_with_cdp(assets: &RuntimeAssets, port: u16) -> Result<LaunchResult> {
|
||||
let result: LaunchResult = run_adapter(
|
||||
assets,
|
||||
"launch",
|
||||
&["-Port".to_string(), port.to_string()],
|
||||
Duration::from_secs(60),
|
||||
)?;
|
||||
if result.port != port || result.browser_id.is_empty() {
|
||||
return Err(CodexxError::Config(
|
||||
"Windows Codex 启动器返回了无效的调试端口身份".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn read_runtime_state() -> Result<Option<RuntimeStateFile>> {
|
||||
let path = state_path()?;
|
||||
let metadata = match fs::symlink_metadata(&path) {
|
||||
Ok(metadata) => metadata,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
|
||||
Err(error) => return Err(io_err(&path, error)),
|
||||
};
|
||||
if metadata.file_type().is_symlink() || !metadata.is_file() || metadata.len() > 256 * 1024 {
|
||||
return Err(CodexxError::Config(
|
||||
"皮肤运行状态文件不是安全的普通文件,已停止自动处理".to_string(),
|
||||
));
|
||||
}
|
||||
let bytes = fs::read(&path).map_err(|source| io_err(&path, source))?;
|
||||
let state: RuntimeStateFile = serde_json::from_slice(&bytes)
|
||||
.map_err(|error| CodexxError::Config(format!("解析皮肤运行状态失败: {error}")))?;
|
||||
if state.schema_version != RUNTIME_STATE_SCHEMA
|
||||
|| state.platform != "windows"
|
||||
|| !(1024..=65535).contains(&state.port)
|
||||
{
|
||||
return Err(CodexxError::Config(
|
||||
"皮肤运行状态版本或端口无效,已停止自动处理".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(Some(state))
|
||||
}
|
||||
|
||||
fn write_runtime_state(state: &RuntimeStateFile) -> Result<()> {
|
||||
let path = state_path()?;
|
||||
let text = serde_json::to_string_pretty(state)
|
||||
.map_err(|error| CodexxError::Config(format!("序列化皮肤运行状态失败: {error}")))?;
|
||||
atomic_write(&path, format!("{text}\n").as_bytes())
|
||||
}
|
||||
|
||||
fn remove_runtime_state() -> Result<()> {
|
||||
let path = state_path()?;
|
||||
match fs::remove_file(&path) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||
Err(error) => Err(io_err(&path, error)),
|
||||
}
|
||||
}
|
||||
|
||||
fn node_version(path: &Path) -> Result<String> {
|
||||
let mut command = Command::new(path);
|
||||
command.arg("--version");
|
||||
configure_background_command(&mut command);
|
||||
let output = command.output().map_err(|source| io_err(path, source))?;
|
||||
if !output.status.success() {
|
||||
return Err(CodexxError::Config(format!(
|
||||
"无法运行 Windows 皮肤 Node.js: {}",
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
let version = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
let major = version
|
||||
.trim_start_matches('v')
|
||||
.split('.')
|
||||
.next()
|
||||
.and_then(|value| value.parse::<u32>().ok())
|
||||
.ok_or_else(|| CodexxError::Config(format!("无法解析 Node.js 版本: {version}")))?;
|
||||
if major < 22 {
|
||||
return Err(CodexxError::Config(format!(
|
||||
"Windows 皮肤运行时需要 Node.js 22 或更高版本,当前为 {version}"
|
||||
)));
|
||||
}
|
||||
Ok(version)
|
||||
}
|
||||
|
||||
fn resolve_node_candidate(path: &Path) -> Option<PathBuf> {
|
||||
if let Ok(canonical) = fs::canonicalize(path) {
|
||||
return Some(canonical);
|
||||
}
|
||||
if path.components().count() != 1 {
|
||||
return None;
|
||||
}
|
||||
let mut command = Command::new("where.exe");
|
||||
command.arg(path);
|
||||
configure_background_command(&mut command);
|
||||
let output = command.output().ok()?;
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
String::from_utf8_lossy(&output.stdout)
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.filter(|line| !line.is_empty())
|
||||
.map(PathBuf::from)
|
||||
.find_map(|candidate| fs::canonicalize(candidate).ok())
|
||||
}
|
||||
|
||||
fn discover_node_runtime() -> Result<NodeRuntime> {
|
||||
let mut candidates = Vec::new();
|
||||
if let Some(path) = env::var_os("CODEX_X_SKIN_NODE") {
|
||||
candidates.push(PathBuf::from(path));
|
||||
}
|
||||
if let Ok(executable) = env::current_exe() {
|
||||
if let Some(parent) = executable.parent() {
|
||||
candidates.push(parent.join("skin-runtime").join("node").join("node.exe"));
|
||||
}
|
||||
}
|
||||
candidates.push(PathBuf::from("node.exe"));
|
||||
for candidate in candidates {
|
||||
if let Some(path) = resolve_node_candidate(&candidate) {
|
||||
if let Ok(version) = node_version(&path) {
|
||||
return Ok(NodeRuntime { path, version });
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(CodexxError::Config(
|
||||
"Windows 皮肤运行时缺少内置 Node.js;请重新安装 Codex-X".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
fn canonical_managed_snapshot(path: &Path) -> Option<PathBuf> {
|
||||
let metadata = fs::symlink_metadata(path).ok()?;
|
||||
if metadata.file_type().is_symlink() || !metadata.is_dir() {
|
||||
return None;
|
||||
}
|
||||
let root = fs::canonicalize(snapshots_root().ok()?).ok()?;
|
||||
let candidate = fs::canonicalize(path).ok()?;
|
||||
candidate.starts_with(&root).then_some(candidate)
|
||||
}
|
||||
|
||||
fn cleanup_snapshot(path: &str, keep: &Path) {
|
||||
let Some(path) = canonical_managed_snapshot(Path::new(path)) else {
|
||||
return;
|
||||
};
|
||||
let keep = canonical_managed_snapshot(keep);
|
||||
if keep.as_ref() != Some(&path) {
|
||||
let _ = fs::remove_dir_all(path);
|
||||
}
|
||||
}
|
||||
|
||||
fn process_info(assets: &RuntimeAssets, pid: u32) -> Result<ProcessInfo> {
|
||||
run_adapter(
|
||||
assets,
|
||||
"processInfo",
|
||||
&["-TargetPid".to_string(), pid.to_string()],
|
||||
Duration::from_secs(10),
|
||||
)
|
||||
}
|
||||
|
||||
fn injector_active(assets: &RuntimeAssets) -> Result<bool> {
|
||||
let result: ActiveResult = run_adapter(
|
||||
assets,
|
||||
"injectorStatus",
|
||||
&[
|
||||
"-StatePath".to_string(),
|
||||
state_path()?.to_string_lossy().to_string(),
|
||||
],
|
||||
Duration::from_secs(10),
|
||||
)?;
|
||||
Ok(result.active)
|
||||
}
|
||||
|
||||
fn write_adapter_state(state: &RuntimeStateFile) -> Result<PathBuf> {
|
||||
let root = runtime_root()?;
|
||||
ensure_runtime_directory(&root)?;
|
||||
let path = root.join(format!(
|
||||
".process-state-{}-{}.json",
|
||||
std::process::id(),
|
||||
state.injector_pid
|
||||
));
|
||||
let text = serde_json::to_string(state)
|
||||
.map_err(|error| CodexxError::Config(format!("序列化皮肤进程状态失败: {error}")))?;
|
||||
atomic_write(&path, text.as_bytes())?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
fn stop_recorded_injector(assets: &RuntimeAssets, state: &RuntimeStateFile) -> Result<()> {
|
||||
if state.injector_pid == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
let info = process_info(assets, state.injector_pid)?;
|
||||
if !info.alive {
|
||||
return Ok(());
|
||||
}
|
||||
let adapter_state = write_adapter_state(state)?;
|
||||
let result = run_adapter::<serde_json::Value>(
|
||||
assets,
|
||||
"stopInjector",
|
||||
&[
|
||||
"-StatePath".to_string(),
|
||||
adapter_state.to_string_lossy().to_string(),
|
||||
],
|
||||
Duration::from_secs(15),
|
||||
);
|
||||
let _ = fs::remove_file(&adapter_state);
|
||||
result.map(|_| ())
|
||||
}
|
||||
|
||||
fn log_files(prefix: &str) -> Result<(File, File)> {
|
||||
let root = logs_root()?;
|
||||
ensure_runtime_directory(&root)?;
|
||||
let stdout_path = root.join(format!("{prefix}.log"));
|
||||
let stderr_path = root.join(format!("{prefix}-error.log"));
|
||||
let stdout = OpenOptions::new()
|
||||
.create(true)
|
||||
.write(true)
|
||||
.truncate(true)
|
||||
.open(&stdout_path)
|
||||
.map_err(|source| io_err(&stdout_path, source))?;
|
||||
let stderr = OpenOptions::new()
|
||||
.create(true)
|
||||
.write(true)
|
||||
.truncate(true)
|
||||
.open(&stderr_path)
|
||||
.map_err(|source| io_err(&stderr_path, source))?;
|
||||
Ok((stdout, stderr))
|
||||
}
|
||||
|
||||
fn launch_watcher(
|
||||
node: &NodeRuntime,
|
||||
assets: &RuntimeAssets,
|
||||
port: u16,
|
||||
theme_dir: &Path,
|
||||
) -> Result<(u32, String)> {
|
||||
let (stdout, stderr) = log_files("injector")?;
|
||||
let mut command = Command::new(&node.path);
|
||||
command
|
||||
.arg(&assets.injector)
|
||||
.args(["--watch", "--port"])
|
||||
.arg(port.to_string())
|
||||
.arg("--theme-dir")
|
||||
.arg(theme_dir)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::from(stdout))
|
||||
.stderr(Stdio::from(stderr));
|
||||
configure_background_command(&mut command);
|
||||
let mut child = command
|
||||
.spawn()
|
||||
.map_err(|source| io_err(&assets.injector, source))?;
|
||||
let pid = child.id();
|
||||
thread::sleep(Duration::from_millis(250));
|
||||
if child
|
||||
.try_wait()
|
||||
.map_err(|source| io_err(&assets.injector, source))?
|
||||
.is_some()
|
||||
{
|
||||
return Err(CodexxError::Config(
|
||||
"皮肤注入器启动后立即退出,请检查运行日志".to_string(),
|
||||
));
|
||||
}
|
||||
let info = process_info(assets, pid)?;
|
||||
let started_at = info
|
||||
.started_at
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| CodexxError::Config("无法记录皮肤注入器启动时间".to_string()))?;
|
||||
let expected_node = node.path.to_string_lossy();
|
||||
let path_matches = info.path.as_deref().is_some_and(|path| {
|
||||
Path::new(path)
|
||||
.canonicalize()
|
||||
.is_ok_and(|actual| actual == node.path)
|
||||
});
|
||||
let command_matches = info.command_line.as_deref().is_some_and(|command| {
|
||||
command.contains(assets.injector.to_string_lossy().as_ref())
|
||||
&& command.contains("--watch")
|
||||
&& command.contains(&format!("--port {port}"))
|
||||
});
|
||||
if !info.alive
|
||||
|| (!path_matches
|
||||
&& !expected_node.eq_ignore_ascii_case(info.path.as_deref().unwrap_or("")))
|
||||
|| !command_matches
|
||||
{
|
||||
let _ = child.kill();
|
||||
return Err(CodexxError::Config(
|
||||
"Windows 皮肤注入器进程身份校验失败".to_string(),
|
||||
));
|
||||
}
|
||||
drop(child);
|
||||
Ok((pid, started_at))
|
||||
}
|
||||
|
||||
fn active_state(context: ActiveStateContext<'_>) -> RuntimeStateFile {
|
||||
let timestamp = now_rfc3339();
|
||||
RuntimeStateFile {
|
||||
schema_version: RUNTIME_STATE_SCHEMA,
|
||||
platform: "windows".to_string(),
|
||||
session: "active".to_string(),
|
||||
port: context.port,
|
||||
browser_id: context.browser_id,
|
||||
injector_pid: context.injector_pid,
|
||||
injector_started_at: context.injector_started_at,
|
||||
injector_path: context.assets.injector.to_string_lossy().to_string(),
|
||||
node_path: context.node.path.to_string_lossy().to_string(),
|
||||
node_version: context.node.version.clone(),
|
||||
codex_package_root: context.runtime.package_root.to_string_lossy().to_string(),
|
||||
codex_executable: context.runtime.executable.to_string_lossy().to_string(),
|
||||
codex_version: context.runtime.version.clone(),
|
||||
codex_package_full_name: context.runtime.package_full_name.clone(),
|
||||
codex_package_family_name: context.runtime.package_family_name.clone(),
|
||||
codex_app_user_model_id: context.runtime.app_user_model_id.clone(),
|
||||
theme_id: context.theme_id.to_string(),
|
||||
theme_dir: context.theme_dir.to_string_lossy().to_string(),
|
||||
created_at: timestamp.clone(),
|
||||
updated_at: timestamp,
|
||||
}
|
||||
}
|
||||
|
||||
fn restore_previous_after_failure(
|
||||
runtime: &CodexRuntime,
|
||||
node: &NodeRuntime,
|
||||
assets: &RuntimeAssets,
|
||||
previous: Option<&RuntimeStateFile>,
|
||||
) {
|
||||
let Some(previous) = previous else { return };
|
||||
let theme_dir = Path::new(&previous.theme_dir);
|
||||
let verified = verify_port(assets, previous.port).is_ok_and(|result| result.verified);
|
||||
if previous.session != "active"
|
||||
|| !theme_dir.is_dir()
|
||||
|| !verified
|
||||
|| node_runtime::run_injector_once(&node.path, assets, previous.port, theme_dir).is_err()
|
||||
{
|
||||
return;
|
||||
}
|
||||
if let Ok((pid, started_at)) = launch_watcher(node, assets, previous.port, theme_dir) {
|
||||
let restored = active_state(ActiveStateContext {
|
||||
runtime,
|
||||
node,
|
||||
assets,
|
||||
port: previous.port,
|
||||
browser_id: previous.browser_id.clone(),
|
||||
theme_id: &previous.theme_id,
|
||||
theme_dir,
|
||||
injector_pid: pid,
|
||||
injector_started_at: started_at,
|
||||
});
|
||||
let _ = write_runtime_state(&restored);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn apply_theme(
|
||||
source: &Path,
|
||||
theme_id: &str,
|
||||
restart_existing: bool,
|
||||
) -> Result<SkinRuntimeAction> {
|
||||
let assets = ensure_runtime_assets()?;
|
||||
let runtime = inspect_codex(&assets)?;
|
||||
let node = discover_node_runtime()?;
|
||||
let previous = read_runtime_state()?;
|
||||
if let Some(pid) = runtime.watcher_pids.iter().copied().find(|pid| {
|
||||
previous
|
||||
.as_ref()
|
||||
.is_none_or(|state| state.injector_pid != *pid)
|
||||
}) {
|
||||
return Err(CodexxError::Config(format!(
|
||||
"检测到外部 Codex 换肤注入器 PID {pid},请先在原工具中关闭皮肤"
|
||||
)));
|
||||
}
|
||||
if runtime.running && runtime.debug_port.is_none() && !restart_existing {
|
||||
return Ok(SkinRuntimeAction::RestartRequired(
|
||||
"Codex 需要重启一次才能打开仅限本机的换肤调试端口".to_string(),
|
||||
));
|
||||
}
|
||||
let snapshot = node_runtime::stage_theme_snapshot(
|
||||
&node.path,
|
||||
&assets,
|
||||
&snapshots_root()?,
|
||||
source,
|
||||
theme_id,
|
||||
)?;
|
||||
let mut launched_new = false;
|
||||
let apply_result = (|| -> Result<RuntimeStateFile> {
|
||||
if let Some(state) = previous.as_ref() {
|
||||
stop_recorded_injector(&assets, state)?;
|
||||
}
|
||||
let (port, browser_id) = if let Some(port) = runtime.debug_port {
|
||||
let browser_id = runtime.browser_id.clone().ok_or_else(|| {
|
||||
CodexxError::Config("无法验证当前 Windows Codex 调试会话".to_string())
|
||||
})?;
|
||||
(port, browser_id)
|
||||
} else {
|
||||
if runtime.running {
|
||||
stop_codex(&assets)?;
|
||||
}
|
||||
let preferred = previous
|
||||
.as_ref()
|
||||
.map_or(DEFAULT_CDP_PORT, |state| state.port);
|
||||
let port = select_port(&assets, preferred)?;
|
||||
let launch = launch_codex_with_cdp(&assets, port)?;
|
||||
launched_new = true;
|
||||
let _strategy = launch.strategy;
|
||||
(port, launch.browser_id)
|
||||
};
|
||||
node_runtime::run_injector_once(&node.path, &assets, port, &snapshot)?;
|
||||
let (pid, started_at) = launch_watcher(&node, &assets, port, &snapshot)?;
|
||||
let state = active_state(ActiveStateContext {
|
||||
runtime: &runtime,
|
||||
node: &node,
|
||||
assets: &assets,
|
||||
port,
|
||||
browser_id,
|
||||
theme_id,
|
||||
theme_dir: &snapshot,
|
||||
injector_pid: pid,
|
||||
injector_started_at: started_at,
|
||||
});
|
||||
if let Err(error) = write_runtime_state(&state) {
|
||||
let _ = stop_recorded_injector(&assets, &state);
|
||||
return Err(error);
|
||||
}
|
||||
Ok(state)
|
||||
})();
|
||||
match apply_result {
|
||||
Ok(_) => {
|
||||
if let Some(previous) = previous.as_ref() {
|
||||
cleanup_snapshot(&previous.theme_dir, &snapshot);
|
||||
}
|
||||
Ok(SkinRuntimeAction::Applied(
|
||||
"已应用 Windows Codex 皮肤".to_string(),
|
||||
))
|
||||
}
|
||||
Err(error) => {
|
||||
let _ = fs::remove_dir_all(&snapshot);
|
||||
restore_previous_after_failure(&runtime, &node, &assets, previous.as_ref());
|
||||
if launched_new && previous.is_none() {
|
||||
let _ = stop_codex(&assets);
|
||||
let _ = launch_codex_normally(&assets);
|
||||
}
|
||||
Err(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn pause_theme() -> Result<SkinRuntimeAction> {
|
||||
let assets = ensure_runtime_assets()?;
|
||||
let Some(mut state) = read_runtime_state()? else {
|
||||
return Ok(SkinRuntimeAction::Paused(
|
||||
"Codex 皮肤当前没有运行".to_string(),
|
||||
));
|
||||
};
|
||||
let node = discover_node_runtime()?;
|
||||
stop_recorded_injector(&assets, &state)?;
|
||||
if verify_port(&assets, state.port).is_ok_and(|result| result.verified) {
|
||||
node_runtime::remove_live_skin(
|
||||
&node.path,
|
||||
&assets,
|
||||
state.port,
|
||||
Path::new(&state.theme_dir),
|
||||
)?;
|
||||
}
|
||||
state.session = "paused".to_string();
|
||||
state.injector_pid = 0;
|
||||
state.injector_started_at.clear();
|
||||
state.updated_at = now_rfc3339();
|
||||
write_runtime_state(&state)?;
|
||||
Ok(SkinRuntimeAction::Paused(
|
||||
"已关闭 Codex 皮肤;Codex 保持打开".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
pub(super) fn restore_official(restart_existing: bool) -> Result<SkinRuntimeAction> {
|
||||
let assets = ensure_runtime_assets()?;
|
||||
let Some(state) = read_runtime_state()? else {
|
||||
return Ok(SkinRuntimeAction::Restored(
|
||||
"Codex 当前已使用官方外观".to_string(),
|
||||
));
|
||||
};
|
||||
let node = discover_node_runtime()?;
|
||||
let runtime = inspect_codex(&assets)?;
|
||||
let debug_ready = verify_port(&assets, state.port).is_ok_and(|result| result.verified);
|
||||
if runtime.running && (debug_ready || state.session == "active") && !restart_existing {
|
||||
return Ok(SkinRuntimeAction::RestartRequired(
|
||||
"完整恢复需要重启 Codex,以关闭本机调试端口并清除当前渲染状态".to_string(),
|
||||
));
|
||||
}
|
||||
stop_recorded_injector(&assets, &state)?;
|
||||
if debug_ready {
|
||||
node_runtime::remove_live_skin(
|
||||
&node.path,
|
||||
&assets,
|
||||
state.port,
|
||||
Path::new(&state.theme_dir),
|
||||
)?;
|
||||
}
|
||||
if runtime.running && (debug_ready || state.session == "active") {
|
||||
stop_codex(&assets)?;
|
||||
launch_codex_normally(&assets)?;
|
||||
}
|
||||
remove_runtime_state()?;
|
||||
cleanup_snapshot(&state.theme_dir, Path::new(""));
|
||||
Ok(SkinRuntimeAction::Restored(
|
||||
"已恢复 Windows Codex 官方外观并关闭换肤运行时".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
pub(super) fn runtime_status() -> SkinRuntimeStatus {
|
||||
let assets = match ensure_runtime_assets() {
|
||||
Ok(assets) => assets,
|
||||
Err(error) => {
|
||||
return SkinRuntimeStatus {
|
||||
supported: true,
|
||||
active: false,
|
||||
phase: "error".to_string(),
|
||||
port: None,
|
||||
theme_id: None,
|
||||
message: error.to_string(),
|
||||
}
|
||||
}
|
||||
};
|
||||
let state = match read_runtime_state() {
|
||||
Ok(Some(state)) => state,
|
||||
Ok(None) => {
|
||||
return SkinRuntimeStatus {
|
||||
supported: true,
|
||||
active: false,
|
||||
phase: "inactive".to_string(),
|
||||
port: None,
|
||||
theme_id: None,
|
||||
message: "尚未应用 Codex 皮肤".to_string(),
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
return SkinRuntimeStatus {
|
||||
supported: true,
|
||||
active: false,
|
||||
phase: "error".to_string(),
|
||||
port: None,
|
||||
theme_id: None,
|
||||
message: error.to_string(),
|
||||
}
|
||||
}
|
||||
};
|
||||
if state.session == "paused" {
|
||||
return SkinRuntimeStatus {
|
||||
supported: true,
|
||||
active: false,
|
||||
phase: "paused".to_string(),
|
||||
port: Some(state.port),
|
||||
theme_id: Some(state.theme_id),
|
||||
message: "皮肤已关闭,Codex 保持打开".to_string(),
|
||||
};
|
||||
}
|
||||
if let Err(error) = inspect_codex(&assets) {
|
||||
return SkinRuntimeStatus {
|
||||
supported: true,
|
||||
active: false,
|
||||
phase: "unavailable".to_string(),
|
||||
port: Some(state.port),
|
||||
theme_id: Some(state.theme_id),
|
||||
message: error.to_string(),
|
||||
};
|
||||
}
|
||||
let active = injector_active(&assets).unwrap_or(false)
|
||||
&& verify_port(&assets, state.port).is_ok_and(|result| {
|
||||
result.verified && result.browser_id.as_deref() == Some(state.browser_id.as_str())
|
||||
});
|
||||
SkinRuntimeStatus {
|
||||
supported: true,
|
||||
active,
|
||||
phase: if active { "active" } else { "stale" }.to_string(),
|
||||
port: Some(state.port),
|
||||
theme_id: Some(state.theme_id),
|
||||
message: if active {
|
||||
"Windows Codex 皮肤运行中".to_string()
|
||||
} else {
|
||||
"皮肤运行状态已失效,可重新应用主题".to_string()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn test_state() -> RuntimeStateFile {
|
||||
RuntimeStateFile {
|
||||
schema_version: 1,
|
||||
platform: "windows".to_string(),
|
||||
session: "active".to_string(),
|
||||
port: 9341,
|
||||
browser_id: "browser-1".to_string(),
|
||||
injector_pid: 42,
|
||||
injector_started_at: "2026-07-24T00:00:00.0000000Z".to_string(),
|
||||
injector_path: r"C:\Users\test\.codexx\runtime\injector.mjs".to_string(),
|
||||
node_path: r"C:\Program Files\Codex-X\skin-runtime\node\node.exe".to_string(),
|
||||
node_version: "v22.23.1".to_string(),
|
||||
codex_package_root: r"C:\Program Files\WindowsApps\OpenAI.Codex_1.0.0.0_x64"
|
||||
.to_string(),
|
||||
codex_executable:
|
||||
r"C:\Program Files\WindowsApps\OpenAI.Codex_1.0.0.0_x64\app\ChatGPT.exe".to_string(),
|
||||
codex_version: "1.0.0.0".to_string(),
|
||||
codex_package_full_name: "OpenAI.Codex_1.0.0.0_x64__test".to_string(),
|
||||
codex_package_family_name: "OpenAI.Codex_test".to_string(),
|
||||
codex_app_user_model_id: "OpenAI.Codex_test!App".to_string(),
|
||||
theme_id: "theme".to_string(),
|
||||
theme_dir: r"C:\Users\test\.codexx\runtime\themes\theme".to_string(),
|
||||
created_at: "2026-07-24T00:00:00Z".to_string(),
|
||||
updated_at: "2026-07-24T00:00:00Z".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_last_json_line_after_powershell_warnings() {
|
||||
let result: PortResult = parse_last_json(
|
||||
b"WARNING: package activation fallback\r\n{\"port\":9342}\r\n",
|
||||
"test",
|
||||
)
|
||||
.expect("parse adapter JSON");
|
||||
assert_eq!(result.port, 9342);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_state_uses_powershell_contract_field_names() {
|
||||
let value = serde_json::to_value(test_state()).expect("serialize state");
|
||||
assert_eq!(value["platform"], "windows");
|
||||
assert_eq!(value["injectorPid"], 42);
|
||||
assert_eq!(
|
||||
value["injectorPath"],
|
||||
r"C:\Users\test\.codexx\runtime\injector.mjs"
|
||||
);
|
||||
assert_eq!(
|
||||
value["nodePath"],
|
||||
r"C:\Program Files\Codex-X\skin-runtime\node\node.exe"
|
||||
);
|
||||
assert_eq!(value["browserId"], "browser-1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adapter_keeps_store_identity_and_loopback_guards() {
|
||||
let source = include_str!("../../resources/skin-runtime/windows/codexx-windows.ps1");
|
||||
assert!(source.contains("Get-DreamSkinCodexInstall"));
|
||||
assert!(source.contains("Start-DreamSkinCodexForDebugging"));
|
||||
assert!(source.contains("--remote-debugging-address=127.0.0.1"));
|
||||
assert!(source.contains("Get-DreamSkinVerifiedCdpIdentity"));
|
||||
assert!(!source.contains("takeown"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
static FIXTURE_COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
fn temp_theme_dir(name: &str) -> PathBuf {
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"codex-x-skin-{name}-{}-{}",
|
||||
std::process::id(),
|
||||
FIXTURE_COUNTER.fetch_add(1, Ordering::Relaxed)
|
||||
));
|
||||
let _ = fs::remove_dir_all(&path);
|
||||
fs::create_dir_all(&path).expect("create theme fixture");
|
||||
path
|
||||
}
|
||||
|
||||
fn write_theme_fixture(dir: &Path, manifest: &str) {
|
||||
fs::write(dir.join("theme.json"), manifest).expect("write manifest");
|
||||
fs::write(dir.join("background.jpg"), [0xff, 0xd8, 0xff, 0xd9]).expect("write image");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manifest_accepts_adaptive_fields_and_preserves_extensions() {
|
||||
let dir = temp_theme_dir("adaptive");
|
||||
write_theme_fixture(
|
||||
&dir,
|
||||
r##"{
|
||||
"schemaVersion": 1,
|
||||
"id": "adaptive",
|
||||
"name": "Adaptive",
|
||||
"image": "background.jpg",
|
||||
"appearance": "auto",
|
||||
"art": { "focusX": 0.72, "safeArea": "left", "taskMode": "ambient" },
|
||||
"promoTitle": "kept"
|
||||
}"##,
|
||||
);
|
||||
|
||||
let manifest = read_manifest(&dir).expect("parse adaptive manifest");
|
||||
|
||||
assert!(manifest.colors.is_none());
|
||||
assert_eq!(
|
||||
manifest.art.as_ref().and_then(|art| art.focus_x),
|
||||
Some(0.72)
|
||||
);
|
||||
assert_eq!(
|
||||
manifest.extra.get("promoTitle"),
|
||||
Some(&Value::String("kept".to_string()))
|
||||
);
|
||||
let serialized = serde_json::to_value(manifest).expect("serialize manifest");
|
||||
assert_eq!(serialized["promoTitle"], "kept");
|
||||
fs::remove_dir_all(dir).expect("remove theme fixture");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manifest_rejects_out_of_range_art_focus() {
|
||||
let dir = temp_theme_dir("focus");
|
||||
write_theme_fixture(
|
||||
&dir,
|
||||
r#"{
|
||||
"schemaVersion": 1,
|
||||
"id": "bad-focus",
|
||||
"name": "Bad focus",
|
||||
"image": "background.jpg",
|
||||
"art": { "focusX": 2 }
|
||||
}"#,
|
||||
);
|
||||
|
||||
let error = read_manifest(&dir).expect_err("reject invalid focus");
|
||||
|
||||
assert!(error.to_string().contains("focusX"));
|
||||
fs::remove_dir_all(dir).expect("remove theme fixture");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn manifest_rejects_symlinked_images() {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let dir = temp_theme_dir("symlink");
|
||||
let outside = dir.with_extension("outside.jpg");
|
||||
fs::write(&outside, [0xff, 0xd8, 0xff, 0xd9]).expect("write outside image");
|
||||
fs::write(
|
||||
dir.join("theme.json"),
|
||||
r#"{"schemaVersion":1,"id":"linked","name":"Linked","image":"background.jpg"}"#,
|
||||
)
|
||||
.expect("write manifest");
|
||||
symlink(&outside, dir.join("background.jpg")).expect("create image symlink");
|
||||
|
||||
let error = read_manifest(&dir).expect_err("reject image symlink");
|
||||
|
||||
assert!(error.to_string().contains("普通文件"));
|
||||
fs::remove_dir_all(dir).expect("remove theme fixture");
|
||||
fs::remove_file(outside).expect("remove outside image");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zip_import_rejects_parent_traversal() {
|
||||
let mut archive = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
archive
|
||||
.start_file("../theme.json", SimpleFileOptions::default())
|
||||
.expect("start unsafe entry");
|
||||
archive.write_all(b"{}").expect("write unsafe entry");
|
||||
let bytes = archive.finish().expect("finish archive").into_inner();
|
||||
|
||||
let error = import_skin_theme_zip_inner("unsafe.zip".to_string(), bytes)
|
||||
.expect_err("reject traversal archive");
|
||||
|
||||
assert!(error.to_string().contains("越界路径"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn theme_id_length_is_bounded() {
|
||||
let error =
|
||||
normalize_theme_id(&"a".repeat(MAX_THEME_ID_BYTES + 1)).expect_err("reject long theme id");
|
||||
assert!(error.to_string().contains("主题 ID"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uploaded_image_requires_matching_supported_signature() {
|
||||
assert_eq!(
|
||||
uploaded_image_extension(
|
||||
"wallpaper.png",
|
||||
&[0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a]
|
||||
)
|
||||
.expect("accept PNG signature"),
|
||||
"png"
|
||||
);
|
||||
assert_eq!(
|
||||
uploaded_image_extension("wallpaper.jpeg", &[0xff, 0xd8, 0xff, 0xd9])
|
||||
.expect("accept JPEG signature"),
|
||||
"jpg"
|
||||
);
|
||||
assert!(uploaded_image_extension("wallpaper.png", b"not an image")
|
||||
.expect_err("reject disguised image")
|
||||
.to_string()
|
||||
.contains("格式不匹配"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_theme_names_and_ids_are_safe_and_unique() {
|
||||
assert_eq!(uploaded_theme_name(" sakura night.webp "), "sakura night");
|
||||
let root = temp_theme_dir("unique-image-id");
|
||||
fs::create_dir_all(root.join("image-sakura-night")).expect("create existing theme");
|
||||
assert_eq!(
|
||||
unique_theme_id_in(&root, "image-sakura-night").expect("create unique id"),
|
||||
"image-sakura-night-2"
|
||||
);
|
||||
fs::remove_dir_all(root).expect("remove unique id fixture");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_upload_creates_an_adaptive_theme_without_overwriting() {
|
||||
let file_name = format!(
|
||||
"direct-image-test-{}.png",
|
||||
FIXTURE_COUNTER.fetch_add(1, Ordering::Relaxed)
|
||||
);
|
||||
let bytes = vec![0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a];
|
||||
let result = create_skin_theme_from_image_inner(file_name, bytes)
|
||||
.expect("create adaptive theme from image");
|
||||
let theme = result
|
||||
.state
|
||||
.themes
|
||||
.iter()
|
||||
.find(|theme| theme.name.starts_with("direct-image-test-"))
|
||||
.expect("created theme is listed");
|
||||
let theme_dir = themes_root().expect("theme root").join(&theme.id);
|
||||
let manifest = read_manifest(&theme_dir).expect("read created theme");
|
||||
|
||||
assert_eq!(manifest.appearance.as_deref(), Some("auto"));
|
||||
assert_eq!(manifest.surface_opacity, Some(IMAGE_THEME_SURFACE_OPACITY));
|
||||
assert!(manifest.colors.is_none());
|
||||
assert!(theme.adaptive);
|
||||
assert_eq!(theme.surface_opacity, IMAGE_THEME_SURFACE_OPACITY);
|
||||
assert_eq!(
|
||||
manifest
|
||||
.art
|
||||
.as_ref()
|
||||
.and_then(|art| art.safe_area.as_deref()),
|
||||
Some("auto")
|
||||
);
|
||||
fs::remove_dir_all(theme_dir).expect("remove created image theme");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imported_theme_metadata_can_be_edited_without_changing_theme_assets() {
|
||||
let id = format!(
|
||||
"metadata-edit-test-{}",
|
||||
FIXTURE_COUNTER.fetch_add(1, Ordering::Relaxed)
|
||||
);
|
||||
let theme_dir = themes_root().expect("theme root").join(&id);
|
||||
fs::create_dir_all(&theme_dir).expect("create editable theme");
|
||||
fs::write(theme_dir.join("background.jpg"), [0xff, 0xd8, 0xff, 0xd9])
|
||||
.expect("write editable theme image");
|
||||
let manifest = SkinThemeManifest {
|
||||
schema_version: 1,
|
||||
id: id.clone(),
|
||||
name: "Before".to_string(),
|
||||
brand_subtitle: "Keep brand".to_string(),
|
||||
tagline: "Before tagline".to_string(),
|
||||
project_prefix: String::new(),
|
||||
project_label: String::new(),
|
||||
status_text: String::new(),
|
||||
quote: "Keep quote".to_string(),
|
||||
image: "background.jpg".to_string(),
|
||||
appearance: Some("auto".to_string()),
|
||||
surface_opacity: None,
|
||||
art: Some(SkinThemeArt {
|
||||
safe_area: Some("left".to_string()),
|
||||
..SkinThemeArt::default()
|
||||
}),
|
||||
colors: None,
|
||||
extra: BTreeMap::from([("customField".to_string(), Value::Bool(true))]),
|
||||
};
|
||||
write_manifest(&theme_dir, &manifest).expect("write editable manifest");
|
||||
|
||||
let result = update_skin_theme_settings_inner(
|
||||
id.clone(),
|
||||
" After ".to_string(),
|
||||
" Updated tagline ".to_string(),
|
||||
0.55,
|
||||
)
|
||||
.expect("update metadata");
|
||||
let updated = read_manifest(&theme_dir).expect("read updated manifest");
|
||||
|
||||
assert_eq!(updated.name, "After");
|
||||
assert_eq!(updated.tagline, "Updated tagline");
|
||||
assert_eq!(updated.brand_subtitle, "Keep brand");
|
||||
assert_eq!(updated.quote, "Keep quote");
|
||||
assert_eq!(updated.image, "background.jpg");
|
||||
assert_eq!(updated.surface_opacity, Some(0.55));
|
||||
assert_eq!(
|
||||
updated.art.and_then(|art| art.safe_area),
|
||||
Some("left".to_string())
|
||||
);
|
||||
assert_eq!(updated.extra.get("customField"), Some(&Value::Bool(true)));
|
||||
assert_eq!(
|
||||
result
|
||||
.state
|
||||
.themes
|
||||
.iter()
|
||||
.find(|theme| theme.id == id)
|
||||
.map(|theme| theme.name.as_str()),
|
||||
Some("After")
|
||||
);
|
||||
fs::remove_dir_all(theme_dir).expect("remove editable theme");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builtin_theme_only_updates_surface_opacity() {
|
||||
ensure_builtin_themes().expect("install built-in theme");
|
||||
let before = read_manifest(&themes_root().expect("theme root").join(BUILTIN_SKIN_ID))
|
||||
.expect("read built-in theme");
|
||||
update_skin_theme_settings_inner(
|
||||
BUILTIN_SKIN_ID.to_string(),
|
||||
"Changed".to_string(),
|
||||
"Changed tagline".to_string(),
|
||||
0.48,
|
||||
)
|
||||
.expect("update built-in opacity");
|
||||
let updated = read_manifest(&themes_root().expect("theme root").join(BUILTIN_SKIN_ID))
|
||||
.expect("read updated built-in theme");
|
||||
|
||||
assert_eq!(updated.name, before.name);
|
||||
assert_eq!(updated.tagline, before.tagline);
|
||||
assert_eq!(updated.surface_opacity, Some(0.48));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builtin_catalog_contains_only_localized_shiina_theme() {
|
||||
let assets = builtin_skin_assets();
|
||||
assert_eq!(assets.len(), 1);
|
||||
assert_eq!(assets[0].id, BUILTIN_SKIN_ID);
|
||||
let manifest: SkinThemeManifest =
|
||||
serde_json::from_str(assets[0].manifest).expect("parse built-in Shiina manifest");
|
||||
assert_eq!(manifest.id, BUILTIN_SKIN_ID);
|
||||
assert_eq!(manifest.name, "椎名真白·樱花画室");
|
||||
assert_eq!(manifest.quote, "安静创作,也会发光");
|
||||
assert!(retired_builtin_skin_ids()
|
||||
.iter()
|
||||
.all(|retired| *retired != manifest.id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_destination_adds_zip_extension_and_rejects_other_types() {
|
||||
let dir = temp_theme_dir("export-destination");
|
||||
let without_extension = dir.join("my-theme");
|
||||
let normalized = normalize_export_destination(without_extension.to_string_lossy().as_ref())
|
||||
.expect("append zip extension");
|
||||
assert_eq!(normalized, dir.join("my-theme.zip"));
|
||||
|
||||
let error = normalize_export_destination(dir.join("my-theme.txt").to_string_lossy().as_ref())
|
||||
.expect_err("reject non-zip extension");
|
||||
assert!(error.to_string().contains(".zip"));
|
||||
fs::remove_dir_all(dir).expect("remove export fixture");
|
||||
}
|
||||
+789
-178
File diff suppressed because it is too large
Load Diff
@@ -1107,8 +1107,19 @@ description: Keep long investigations aligned.
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn switch_provider_writes_scoped_bearer_and_api_key_auth_mode() {
|
||||
fn switch_provider_writes_scoped_bearer_without_overwriting_official_auth() {
|
||||
let codex_dir = temp_codex_dir("switch-provider");
|
||||
let official_auth = json!({
|
||||
"auth_mode": "chatgpt",
|
||||
"tokens": {
|
||||
"access_token": "official-access-token",
|
||||
"refresh_token": "official-refresh-token"
|
||||
},
|
||||
"last_refresh": "2026-07-24T00:00:00Z"
|
||||
});
|
||||
write_json(&auth_path(&codex_dir), &official_auth).expect("write official auth");
|
||||
let auth_before = fs::read(auth_path(&codex_dir)).expect("read official auth before switch");
|
||||
|
||||
let result = switch_provider_inner(ProviderInput {
|
||||
config_dir: Some(codex_dir.display().to_string()),
|
||||
_provider_id: Some("magicai".to_string()),
|
||||
@@ -1139,16 +1150,8 @@ fn switch_provider_writes_scoped_bearer_and_api_key_auth_mode() {
|
||||
);
|
||||
assert!(config_doc.get("experimental_bearer_token").is_none());
|
||||
|
||||
let auth_text = fs::read_to_string(auth_path(&codex_dir)).expect("read auth");
|
||||
let auth: Value = serde_json::from_str(&auth_text).expect("parse auth");
|
||||
assert_eq!(
|
||||
auth.get("OPENAI_API_KEY").and_then(Value::as_str),
|
||||
Some("sk-test")
|
||||
);
|
||||
assert_eq!(
|
||||
auth.get("auth_mode").and_then(Value::as_str),
|
||||
Some("apikey")
|
||||
);
|
||||
let auth_after = fs::read(auth_path(&codex_dir)).expect("read official auth after switch");
|
||||
assert_eq!(auth_after, auth_before);
|
||||
|
||||
let _ = fs::remove_dir_all(codex_dir);
|
||||
}
|
||||
@@ -1170,7 +1173,14 @@ experimental_bearer_token = "sk-a-scoped"
|
||||
"#,
|
||||
)
|
||||
.expect("write provider A config");
|
||||
write_text(&auth_path(&codex_dir), "{ invalid auth").expect("write malformed provider A auth");
|
||||
write_json(
|
||||
&auth_path(&codex_dir),
|
||||
&json!({
|
||||
"auth_mode": "chatgpt",
|
||||
"tokens": {"access_token": "official-access-token"}
|
||||
}),
|
||||
)
|
||||
.expect("write official auth");
|
||||
|
||||
let persisted = std::cell::RefCell::new(None);
|
||||
let result = switch_provider_with_pre_persist(
|
||||
@@ -1431,6 +1441,13 @@ requires_openai_auth = true
|
||||
#[test]
|
||||
fn save_provider_toml_config_writes_provider_scoped_bearer_token() {
|
||||
let codex_dir = temp_codex_dir("save-provider-toml-token");
|
||||
let official_auth = json!({
|
||||
"auth_mode": "chatgpt",
|
||||
"tokens": {"access_token": "official-access-token"}
|
||||
});
|
||||
write_json(&auth_path(&codex_dir), &official_auth).expect("write official auth");
|
||||
let auth_before = fs::read(auth_path(&codex_dir)).expect("read auth before save");
|
||||
|
||||
let result = save_provider_toml_config_inner(ProviderTomlInput {
|
||||
config_dir: Some(codex_dir.display().to_string()),
|
||||
config_text: r#"model_provider = "proxy"
|
||||
@@ -1451,6 +1468,8 @@ requires_openai_auth = false
|
||||
let config_text = fs::read_to_string(config_path(&codex_dir)).expect("read config");
|
||||
assert!(config_text.contains("[model_providers.proxy]"));
|
||||
assert!(config_text.contains("experimental_bearer_token = \"sk-provider-table\""));
|
||||
let auth_after = fs::read(auth_path(&codex_dir)).expect("read auth after save");
|
||||
assert_eq!(auth_after, auth_before);
|
||||
|
||||
let _ = fs::remove_dir_all(codex_dir);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Codex-X",
|
||||
"version": "0.3.0",
|
||||
"version": "0.3.1",
|
||||
"identifier": "com.yynxxxxx.codexx",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
@@ -29,7 +29,11 @@
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src 'self' ipc: http://ipc.localhost"
|
||||
"csp": "default-src 'self'; img-src 'self' data: asset: http://asset.localhost https://asset.localhost; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src 'self' ipc: http://ipc.localhost",
|
||||
"assetProtocol": {
|
||||
"enable": true,
|
||||
"scope": ["$HOME/.codexx/codex-x-skins/**"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"bundle": {
|
||||
"resources": {
|
||||
"runtime/windows/node/*": "skin-runtime/node/"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -310,6 +310,70 @@ export function UpdateDialog({
|
||||
);
|
||||
}
|
||||
|
||||
export type SkinRestartDialogProps = {
|
||||
open: boolean;
|
||||
lang: Lang;
|
||||
themeName?: string | null;
|
||||
busy: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void | Promise<void>;
|
||||
};
|
||||
|
||||
export function SkinRestartDialog({
|
||||
open,
|
||||
lang,
|
||||
themeName,
|
||||
busy,
|
||||
onClose,
|
||||
onConfirm,
|
||||
}: SkinRestartDialogProps) {
|
||||
const isChinese = lang === "zh";
|
||||
const title = isChinese ? "重启 Codex 并应用皮肤" : "Restart Codex and apply skin";
|
||||
const description = isChinese
|
||||
? `Codex 当前未启用换肤调试端口。继续后会关闭并重新打开 Codex${themeName ? `,然后应用“${themeName}”` : ""}。`
|
||||
: `Codex is not running with the skin debug endpoint. It will be closed and reopened${themeName ? `, then “${themeName}” will be applied` : ""}.`;
|
||||
const warning = isChinese
|
||||
? "继续前请保存 Codex 中尚未发送的输入和正在进行的工作。本机调试端口仅绑定 127.0.0.1,但启用期间仍应避免运行不可信的本机程序。"
|
||||
: "Save unsent input and in-progress work in Codex first. The debug endpoint binds only to 127.0.0.1, but untrusted local programs should still be avoided while it is active.";
|
||||
|
||||
return (
|
||||
<ModalShell
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title={title}
|
||||
description={description}
|
||||
size="sm"
|
||||
closeOnBackdrop={!busy}
|
||||
closeOnEscape={!busy}
|
||||
showCloseButton={!busy}
|
||||
closeLabel={isChinese ? "关闭" : "Close"}
|
||||
className="cx-skin-restart-dialog"
|
||||
footer={(
|
||||
<>
|
||||
<Button variant="secondary" onClick={onClose} disabled={busy}>
|
||||
{isChinese ? "取消" : "Cancel"}
|
||||
</Button>
|
||||
<Button
|
||||
icon={busy ? <Loader2 size={16} className="cx-dialog-spin" /> : <RotateCcw size={16} />}
|
||||
onClick={() => void onConfirm()}
|
||||
disabled={busy}
|
||||
data-initial-focus
|
||||
>
|
||||
{busy
|
||||
? (isChinese ? "正在处理" : "Working")
|
||||
: (isChinese ? "确认重启" : "Restart Codex")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<div className="cx-skin-restart-warning">
|
||||
<AlertCircle size={20} aria-hidden="true" />
|
||||
<p>{warning}</p>
|
||||
</div>
|
||||
</ModalShell>
|
||||
);
|
||||
}
|
||||
|
||||
export type StartupWizardDialogProps = {
|
||||
open: boolean;
|
||||
closing: boolean;
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
ChevronRight,
|
||||
Edit2,
|
||||
FileText,
|
||||
Palette,
|
||||
Plus,
|
||||
Settings2,
|
||||
Trash2,
|
||||
@@ -31,6 +32,7 @@ type PromptCategoryManagerProps = {
|
||||
onRenameCategory: (categoryId: string, name: string) => boolean;
|
||||
onDeleteCategory: (categoryId: string) => boolean;
|
||||
onMovePrompt: (promptKey: string, categoryId: string) => void;
|
||||
subject?: "prompt" | "skin";
|
||||
};
|
||||
|
||||
export function PromptCategoryManager({
|
||||
@@ -44,6 +46,7 @@ export function PromptCategoryManager({
|
||||
onRenameCategory,
|
||||
onDeleteCategory,
|
||||
onMovePrompt,
|
||||
subject = "prompt",
|
||||
}: PromptCategoryManagerProps) {
|
||||
const isChinese = lang === "zh";
|
||||
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set());
|
||||
@@ -72,11 +75,38 @@ export function PromptCategoryManager({
|
||||
return grouped;
|
||||
}, [categories, categoryForPrompt, prompts]);
|
||||
|
||||
const subjectCopy = isChinese
|
||||
? subject === "skin"
|
||||
? {
|
||||
deleteTitle: "删除皮肤分类",
|
||||
deleteDescription: (name: string) => `“${name}”中的皮肤会移到默认分类,皮肤本身不会被删除。`,
|
||||
empty: "该分类下暂无皮肤",
|
||||
move: (title: string) => `移动“${title}”到分类`,
|
||||
}
|
||||
: {
|
||||
deleteTitle: "删除提示词分类",
|
||||
deleteDescription: (name: string) => `“${name}”中的提示词会移到默认分类,提示词本身不会被删除。`,
|
||||
empty: "该分类下暂无提示词",
|
||||
move: (title: string) => `移动“${title}”到分类`,
|
||||
}
|
||||
: subject === "skin"
|
||||
? {
|
||||
deleteTitle: "Delete skin category",
|
||||
deleteDescription: (name: string) => `Skins in “${name}” will move to the default category. The skins are not deleted.`,
|
||||
empty: "No skins in this category",
|
||||
move: (title: string) => `Move “${title}” to category`,
|
||||
}
|
||||
: {
|
||||
deleteTitle: "Delete prompt category",
|
||||
deleteDescription: (name: string) => `Prompts in “${name}” will move to the default category. The prompts are not deleted.`,
|
||||
empty: "No prompts in this category",
|
||||
move: (title: string) => `Move “${title}” to category`,
|
||||
};
|
||||
const copy = isChinese
|
||||
? {
|
||||
title: "分类管理",
|
||||
deleteTitle: "删除提示词分类",
|
||||
deleteDescription: (name: string) => `“${name}”中的提示词会移到默认分类,提示词本身不会被删除。`,
|
||||
deleteTitle: subjectCopy.deleteTitle,
|
||||
deleteDescription: subjectCopy.deleteDescription,
|
||||
addCategory: "新增分类",
|
||||
namePlaceholder: "输入分类名称",
|
||||
cancel: "取消",
|
||||
@@ -86,16 +116,16 @@ export function PromptCategoryManager({
|
||||
edit: "编辑分类",
|
||||
remove: "删除分类",
|
||||
keepOne: "至少保留一个分类",
|
||||
empty: "该分类下暂无提示词",
|
||||
move: (title: string) => `移动“${title}”到分类`,
|
||||
empty: subjectCopy.empty,
|
||||
move: subjectCopy.move,
|
||||
duplicate: `分类名称不能为空、重复或超过 ${PROMPT_CATEGORY_NAME_MAX_LENGTH} 个字符`,
|
||||
expand: (name: string) => `展开${name}`,
|
||||
collapse: (name: string) => `收起${name}`,
|
||||
}
|
||||
: {
|
||||
title: "Manage categories",
|
||||
deleteTitle: "Delete prompt category",
|
||||
deleteDescription: (name: string) => `Prompts in “${name}” will move to the default category. The prompts are not deleted.`,
|
||||
deleteTitle: subjectCopy.deleteTitle,
|
||||
deleteDescription: subjectCopy.deleteDescription,
|
||||
addCategory: "Add category",
|
||||
namePlaceholder: "Category name",
|
||||
cancel: "Cancel",
|
||||
@@ -105,12 +135,13 @@ export function PromptCategoryManager({
|
||||
edit: "Edit category",
|
||||
remove: "Delete category",
|
||||
keepOne: "Keep at least one category",
|
||||
empty: "No prompts in this category",
|
||||
move: (title: string) => `Move “${title}” to category`,
|
||||
empty: subjectCopy.empty,
|
||||
move: subjectCopy.move,
|
||||
duplicate: `Category names cannot be empty, duplicated, or longer than ${PROMPT_CATEGORY_NAME_MAX_LENGTH} characters`,
|
||||
expand: (name: string) => `Expand ${name}`,
|
||||
collapse: (name: string) => `Collapse ${name}`,
|
||||
};
|
||||
const ItemIcon = subject === "skin" ? Palette : FileText;
|
||||
|
||||
const close = () => {
|
||||
if (categoryToDelete) {
|
||||
@@ -261,7 +292,7 @@ export function PromptCategoryManager({
|
||||
<div className="cx-prompt-category-prompts">
|
||||
{categoryPrompts.length ? categoryPrompts.map((prompt) => (
|
||||
<div className="cx-prompt-category-prompt" key={prompt.key}>
|
||||
<FileText size={14} aria-hidden="true" />
|
||||
<ItemIcon size={14} aria-hidden="true" />
|
||||
<span title={prompt.title}>{prompt.title}</span>
|
||||
<select
|
||||
value={category.id}
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Loader2, Pencil, Save } from "lucide-react";
|
||||
|
||||
import type { Lang, SkinThemeSummary } from "../types";
|
||||
import { Button, ModalShell } from "./ui";
|
||||
|
||||
export type SkinThemeEditorProps = {
|
||||
lang: Lang;
|
||||
open: boolean;
|
||||
theme: SkinThemeSummary | null;
|
||||
busy: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (id: string, name: string, tagline: string, surfaceOpacity: number) => Promise<boolean>;
|
||||
};
|
||||
|
||||
function getCopy(lang: Lang) {
|
||||
return lang === "zh"
|
||||
? {
|
||||
title: "编辑皮肤",
|
||||
description: "调整皮肤信息和壁纸上方界面的透明度。",
|
||||
builtinDescription: "调整壁纸上方界面的透明度。",
|
||||
name: "皮肤名称",
|
||||
namePlaceholder: "输入皮肤名称",
|
||||
tagline: "皮肤简介",
|
||||
taglinePlaceholder: "简要描述这套皮肤",
|
||||
transparency: "界面透明度",
|
||||
cancel: "取消",
|
||||
save: "保存",
|
||||
saving: "保存中",
|
||||
close: "关闭",
|
||||
}
|
||||
: {
|
||||
title: "Edit skin",
|
||||
description: "Change skin details and the transparency of surfaces above the wallpaper.",
|
||||
builtinDescription: "Adjust the transparency of surfaces above the wallpaper.",
|
||||
name: "Skin name",
|
||||
namePlaceholder: "Enter a skin name",
|
||||
tagline: "Description",
|
||||
taglinePlaceholder: "Briefly describe this skin",
|
||||
transparency: "Interface transparency",
|
||||
cancel: "Cancel",
|
||||
save: "Save",
|
||||
saving: "Saving",
|
||||
close: "Close",
|
||||
};
|
||||
}
|
||||
|
||||
export function SkinThemeEditor({
|
||||
lang,
|
||||
open,
|
||||
theme,
|
||||
busy,
|
||||
onClose,
|
||||
onSave,
|
||||
}: SkinThemeEditorProps) {
|
||||
const copy = getCopy(lang);
|
||||
const [name, setName] = useState("");
|
||||
const [tagline, setTagline] = useState("");
|
||||
const [surfaceOpacity, setSurfaceOpacity] = useState(1);
|
||||
const editableMetadata = theme?.source !== "builtin";
|
||||
const transparency = Math.round((1 - surfaceOpacity) * 100);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !theme) return;
|
||||
setName(theme.name);
|
||||
setTagline(theme.tagline);
|
||||
setSurfaceOpacity(theme.surfaceOpacity);
|
||||
}, [open, theme]);
|
||||
|
||||
const close = () => {
|
||||
if (!busy) onClose();
|
||||
};
|
||||
const save = async () => {
|
||||
if (!theme || (editableMetadata && !name.trim()) || busy) return;
|
||||
if (await onSave(theme.id, name, tagline, surfaceOpacity)) onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<ModalShell
|
||||
open={open && Boolean(theme)}
|
||||
onClose={close}
|
||||
title={<span className="cx-skin-editor-title"><Pencil size={18} aria-hidden="true" />{copy.title}</span>}
|
||||
description={editableMetadata ? copy.description : copy.builtinDescription}
|
||||
closeLabel={copy.close}
|
||||
closeOnBackdrop={!busy}
|
||||
closeOnEscape={!busy}
|
||||
className="cx-skin-editor-modal"
|
||||
bodyClassName="cx-skin-editor-body"
|
||||
footer={(
|
||||
<>
|
||||
<Button variant="secondary" onClick={close} disabled={busy}>{copy.cancel}</Button>
|
||||
<Button
|
||||
icon={busy ? <Loader2 className="cx-skins-spin" /> : <Save />}
|
||||
onClick={() => void save()}
|
||||
disabled={busy || (editableMetadata && !name.trim())}
|
||||
>
|
||||
{busy ? copy.saving : copy.save}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
{editableMetadata && (
|
||||
<>
|
||||
<label className="cx-skin-editor-field">
|
||||
<span>{copy.name}</span>
|
||||
<input
|
||||
value={name}
|
||||
onChange={(event) => setName(event.currentTarget.value)}
|
||||
placeholder={copy.namePlaceholder}
|
||||
maxLength={80}
|
||||
disabled={busy}
|
||||
data-initial-focus
|
||||
/>
|
||||
</label>
|
||||
<label className="cx-skin-editor-field">
|
||||
<span>{copy.tagline}</span>
|
||||
<textarea
|
||||
value={tagline}
|
||||
onChange={(event) => setTagline(event.currentTarget.value)}
|
||||
placeholder={copy.taglinePlaceholder}
|
||||
maxLength={160}
|
||||
disabled={busy}
|
||||
rows={4}
|
||||
/>
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
<label className="cx-skin-editor-field">
|
||||
<span>{copy.transparency}</span>
|
||||
<div className="cx-skin-editor-range">
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="65"
|
||||
step="1"
|
||||
value={transparency}
|
||||
onChange={(event) => {
|
||||
const value = Number(event.currentTarget.value);
|
||||
setSurfaceOpacity(Number((1 - value / 100).toFixed(2)));
|
||||
}}
|
||||
disabled={busy}
|
||||
aria-label={copy.transparency}
|
||||
data-initial-focus={!editableMetadata || undefined}
|
||||
/>
|
||||
<output>{transparency}%</output>
|
||||
</div>
|
||||
</label>
|
||||
</ModalShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
import React from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { save } from "@tauri-apps/plugin-dialog";
|
||||
|
||||
import type { AppTab } from "../components/AppShell";
|
||||
import type {
|
||||
Lang,
|
||||
SkinActionResult,
|
||||
SkinCenterState,
|
||||
SkinExportResult,
|
||||
} from "../types";
|
||||
|
||||
export type SkinRestartRequest = {
|
||||
themeId?: string;
|
||||
themeName?: string;
|
||||
};
|
||||
|
||||
function exportFileName(value: string) {
|
||||
const normalized = value
|
||||
.trim()
|
||||
.replace(/[<>:"/\\|?*\u0000-\u001f]/g, "-")
|
||||
.replace(/[. ]+$/g, "")
|
||||
.slice(0, 80);
|
||||
return `${normalized || "codex-x-theme"}.zip`;
|
||||
}
|
||||
|
||||
type UseSkinCenterOptions = {
|
||||
lang: Lang;
|
||||
tab: AppTab;
|
||||
ready: boolean;
|
||||
setActionBusy: React.Dispatch<React.SetStateAction<string>>;
|
||||
setError: React.Dispatch<React.SetStateAction<string>>;
|
||||
setToast: React.Dispatch<React.SetStateAction<string>>;
|
||||
};
|
||||
|
||||
export function useSkinCenter({
|
||||
lang,
|
||||
tab,
|
||||
ready,
|
||||
setActionBusy,
|
||||
setError,
|
||||
setToast,
|
||||
}: UseSkinCenterOptions) {
|
||||
const [state, setState] = React.useState<SkinCenterState | null>(null);
|
||||
const [restartRequest, setRestartRequest] = React.useState<SkinRestartRequest | null>(null);
|
||||
const zipInputRef = React.useRef<HTMLInputElement | null>(null);
|
||||
const imageInputRef = React.useRef<HTMLInputElement | null>(null);
|
||||
const loadedRef = React.useRef(false);
|
||||
|
||||
const load = React.useCallback(async ({
|
||||
quiet = false,
|
||||
notify = false,
|
||||
}: { quiet?: boolean; notify?: boolean } = {}) => {
|
||||
if (!quiet) {
|
||||
setActionBusy("loadSkins");
|
||||
setError("");
|
||||
}
|
||||
try {
|
||||
setState(await invoke<SkinCenterState>("get_skin_center_state"));
|
||||
if (notify) {
|
||||
setToast(lang === "zh" ? "皮肤库已刷新" : "Skin library refreshed");
|
||||
}
|
||||
} catch (error) {
|
||||
if (!quiet) setError(String(error));
|
||||
} finally {
|
||||
if (!quiet) setActionBusy("");
|
||||
}
|
||||
}, [lang, setActionBusy, setError, setToast]);
|
||||
|
||||
const refresh = React.useCallback(() => load({ notify: true }), [load]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (tab !== "skins" || loadedRef.current) return;
|
||||
loadedRef.current = true;
|
||||
void load();
|
||||
}, [load, tab]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!ready || loadedRef.current) return;
|
||||
loadedRef.current = true;
|
||||
void load({ quiet: true });
|
||||
}, [load, ready]);
|
||||
|
||||
const importZip = async (file?: File | null): Promise<string | null> => {
|
||||
if (!file) return null;
|
||||
if (!file.name.toLowerCase().endsWith(".zip")) {
|
||||
setError(lang === "zh" ? "请选择 .zip 主题包" : "Please choose a .zip theme pack");
|
||||
return null;
|
||||
}
|
||||
if (file.size > 24 * 1024 * 1024) {
|
||||
setError(lang === "zh" ? "主题包不能超过 24MB" : "Theme ZIP must be smaller than 24MB");
|
||||
return null;
|
||||
}
|
||||
setActionBusy("importSkinZip");
|
||||
setError("");
|
||||
try {
|
||||
const previousIds = new Set(state?.themes.map((theme) => theme.id) ?? []);
|
||||
const bytes = Array.from(new Uint8Array(await file.arrayBuffer()));
|
||||
const result = await invoke<SkinActionResult>("import_skin_theme_zip", {
|
||||
fileName: file.name,
|
||||
bytes,
|
||||
});
|
||||
setState(result.state);
|
||||
setToast(result.message);
|
||||
return result.state.themes.find((theme) => !previousIds.has(theme.id))?.id ?? null;
|
||||
} catch (error) {
|
||||
setError(String(error));
|
||||
return null;
|
||||
} finally {
|
||||
setActionBusy("");
|
||||
if (zipInputRef.current) zipInputRef.current.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
const createFromImage = async (file?: File | null): Promise<string | null> => {
|
||||
if (!file) return null;
|
||||
if (!/\.(png|jpe?g|webp)$/i.test(file.name)) {
|
||||
setError(lang === "zh"
|
||||
? "请选择 PNG、JPEG 或 WebP 图片"
|
||||
: "Please choose a PNG, JPEG, or WebP image");
|
||||
return null;
|
||||
}
|
||||
if (file.size > 16 * 1024 * 1024) {
|
||||
setError(lang === "zh" ? "主题图片不能超过 16MB" : "Theme image must be smaller than 16MB");
|
||||
return null;
|
||||
}
|
||||
setActionBusy("createSkinFromImage");
|
||||
setError("");
|
||||
try {
|
||||
const previousIds = new Set(state?.themes.map((theme) => theme.id) ?? []);
|
||||
const bytes = Array.from(new Uint8Array(await file.arrayBuffer()));
|
||||
const result = await invoke<SkinActionResult>("create_skin_theme_from_image", {
|
||||
fileName: file.name,
|
||||
bytes,
|
||||
});
|
||||
setState(result.state);
|
||||
setToast(result.message);
|
||||
return result.state.themes.find((theme) => !previousIds.has(theme.id))?.id ?? null;
|
||||
} catch (error) {
|
||||
setError(String(error));
|
||||
return null;
|
||||
} finally {
|
||||
setActionBusy("");
|
||||
if (imageInputRef.current) imageInputRef.current.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
const apply = async (id: string, restartExisting = false) => {
|
||||
setActionBusy(`skin:${id}`);
|
||||
setError("");
|
||||
try {
|
||||
const result = await invoke<SkinActionResult>("enable_skin_theme", { id, restartExisting });
|
||||
setState(result.state);
|
||||
if (result.restartRequired) {
|
||||
const themeName = result.state.themes.find((theme) => theme.id === id)?.name;
|
||||
setRestartRequest({ themeId: id, themeName });
|
||||
} else {
|
||||
setRestartRequest(null);
|
||||
setToast(result.message);
|
||||
}
|
||||
} catch (error) {
|
||||
setError(String(error));
|
||||
void load({ quiet: true });
|
||||
} finally {
|
||||
setActionBusy("");
|
||||
}
|
||||
};
|
||||
|
||||
const updateSettings = async (
|
||||
id: string,
|
||||
name: string,
|
||||
tagline: string,
|
||||
surfaceOpacity: number,
|
||||
): Promise<boolean> => {
|
||||
setActionBusy(`skinEdit:${id}`);
|
||||
setError("");
|
||||
try {
|
||||
const result = await invoke<SkinActionResult>("update_skin_theme_settings", {
|
||||
id,
|
||||
name,
|
||||
tagline,
|
||||
surfaceOpacity,
|
||||
});
|
||||
setState(result.state);
|
||||
setToast(result.message);
|
||||
return true;
|
||||
} catch (error) {
|
||||
setError(String(error));
|
||||
return false;
|
||||
} finally {
|
||||
setActionBusy("");
|
||||
}
|
||||
};
|
||||
|
||||
const pause = async () => {
|
||||
setActionBusy("pauseSkin");
|
||||
setError("");
|
||||
try {
|
||||
const result = await invoke<SkinActionResult>("pause_skin_theme");
|
||||
setState(result.state);
|
||||
setToast(result.message);
|
||||
} catch (error) {
|
||||
setError(String(error));
|
||||
void load({ quiet: true });
|
||||
} finally {
|
||||
setActionBusy("");
|
||||
}
|
||||
};
|
||||
|
||||
const confirmRestart = async () => {
|
||||
if (restartRequest?.themeId) {
|
||||
await apply(restartRequest.themeId, true);
|
||||
}
|
||||
};
|
||||
|
||||
const exportTheme = async (id: string) => {
|
||||
setError("");
|
||||
try {
|
||||
const theme = state?.themes.find((item) => item.id === id);
|
||||
const destinationPath = await save({
|
||||
title: lang === "zh" ? "导出皮肤主题" : "Export skin theme",
|
||||
defaultPath: exportFileName(theme?.name || id),
|
||||
filters: [{ name: "ZIP", extensions: ["zip"] }],
|
||||
});
|
||||
if (!destinationPath) return;
|
||||
setActionBusy(`skinExport:${id}`);
|
||||
const result = await invoke<SkinExportResult>("export_skin_theme", { id, destinationPath });
|
||||
setToast(result.message || (lang === "zh" ? `主题包已导出:${result.path}` : `Theme exported: ${result.path}`));
|
||||
} catch (error) {
|
||||
setError(String(error));
|
||||
} finally {
|
||||
setActionBusy("");
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
state,
|
||||
restartRequest,
|
||||
zipInputRef,
|
||||
imageInputRef,
|
||||
refresh,
|
||||
importZip,
|
||||
createFromImage,
|
||||
updateSettings,
|
||||
apply,
|
||||
pause,
|
||||
confirmRestart,
|
||||
closeRestart: () => setRestartRequest(null),
|
||||
exportTheme,
|
||||
};
|
||||
}
|
||||
+43
-84
@@ -14,10 +14,16 @@ import { SkillsMcpPage } from "./pages/SkillsMcpPage";
|
||||
import { SkinsPage } from "./pages/SkinsPage";
|
||||
import { ProvidersPage, type ProviderCopy, type ProviderRow } from "./pages/ProvidersPage";
|
||||
import { AppShell, type AppTab, type AppTheme } from "./components/AppShell";
|
||||
import { AppToast, StartupWizardDialog, UpdateDialog } from "./components/AppDialogs";
|
||||
import {
|
||||
AppToast,
|
||||
SkinRestartDialog,
|
||||
StartupWizardDialog,
|
||||
UpdateDialog,
|
||||
} from "./components/AppDialogs";
|
||||
import { PageTransition } from "./components/PageTransition";
|
||||
import { cx } from "./components/ui";
|
||||
import { appUpdater, useAppUpdater } from "./appUpdater";
|
||||
import { useSkinCenter } from "./hooks/useSkinCenter";
|
||||
import type {
|
||||
AboutInfo,
|
||||
ActionResult,
|
||||
@@ -38,9 +44,6 @@ import type {
|
||||
SavedProvider,
|
||||
SessionDeleteResult,
|
||||
SessionSyncResult,
|
||||
SkinActionResult,
|
||||
SkinCenterState,
|
||||
SkinExportResult,
|
||||
SkillsMcpActionResult,
|
||||
SkillsMcpImportPreview,
|
||||
SkillsMcpState,
|
||||
@@ -751,7 +754,6 @@ function App() {
|
||||
const [skillsMcpState, setSkillsMcpState] = React.useState<SkillsMcpState | null>(null);
|
||||
const [skillsMcpImportPreview, setSkillsMcpImportPreview] = React.useState<SkillsMcpImportPreview | null>(null);
|
||||
const [skillsMcpImportOpen, setSkillsMcpImportOpen] = React.useState(false);
|
||||
const [skinCenterState, setSkinCenterState] = React.useState<SkinCenterState | null>(null);
|
||||
const [startupDiagnostics, setStartupDiagnostics] = React.useState<StartupDiagnostics | null>(null);
|
||||
const [startupWizardOpen, setStartupWizardOpen] = React.useState(() => localStorage.getItem(STARTUP_WIZARD_SEEN_KEY) !== "1");
|
||||
const [startupClosing, setStartupClosing] = React.useState(false);
|
||||
@@ -784,7 +786,6 @@ function App() {
|
||||
const autoUpdateCheckedRef = React.useRef(false);
|
||||
const promptImportRef = React.useRef<HTMLInputElement | null>(null);
|
||||
const skillZipImportRef = React.useRef<HTMLInputElement | null>(null);
|
||||
const skinZipImportRef = React.useRef<HTMLInputElement | null>(null);
|
||||
const providerTomlEditorRef = React.useRef<HTMLTextAreaElement | null>(null);
|
||||
const providerModelsRequestRef = React.useRef(0);
|
||||
const promptModeHelpRef = React.useRef<HTMLDivElement | null>(null);
|
||||
@@ -794,8 +795,29 @@ function App() {
|
||||
const promptCatalogReadyRef = React.useRef(false);
|
||||
const promptModeSyncedRef = React.useRef("");
|
||||
const skillsMcpLoadedRef = React.useRef(false);
|
||||
const skinCenterLoadedRef = React.useRef(false);
|
||||
const themeTransitionTimerRef = React.useRef<number | null>(null);
|
||||
const {
|
||||
state: skinCenterState,
|
||||
restartRequest: skinRestartRequest,
|
||||
zipInputRef: skinZipImportRef,
|
||||
imageInputRef: skinImageInputRef,
|
||||
refresh: refreshSkinCenter,
|
||||
importZip: importSkinThemeZip,
|
||||
createFromImage: createSkinThemeFromImage,
|
||||
updateSettings: updateSkinThemeSettings,
|
||||
apply: enableSkinTheme,
|
||||
pause: pauseSkinTheme,
|
||||
confirmRestart: confirmSkinRestart,
|
||||
closeRestart: closeSkinRestart,
|
||||
exportTheme: exportSkinTheme,
|
||||
} = useSkinCenter({
|
||||
lang,
|
||||
tab,
|
||||
ready: Boolean(state),
|
||||
setActionBusy,
|
||||
setError,
|
||||
setToast,
|
||||
});
|
||||
const providerTomlPreview = React.useMemo(() => buildProviderTomlPreview(providerForm, state), [providerForm, state]);
|
||||
const providerAuthPreview = React.useMemo(() => buildProviderAuthPreview(providerForm), [providerForm]);
|
||||
const activeBuiltinTemplateId = state?.instructionTemplateKey?.startsWith("builtin:")
|
||||
@@ -1651,79 +1673,6 @@ function App() {
|
||||
void loadSkillsMcp();
|
||||
}, [tab, loadSkillsMcp]);
|
||||
|
||||
const loadSkinCenter = React.useCallback(async ({ quiet = false }: { quiet?: boolean } = {}) => {
|
||||
if (!quiet) {
|
||||
setActionBusy("loadSkins");
|
||||
setError("");
|
||||
}
|
||||
try {
|
||||
const result = await invoke<SkinCenterState>("get_skin_center_state");
|
||||
setSkinCenterState(result);
|
||||
} catch (e) {
|
||||
if (!quiet) setError(String(e));
|
||||
} finally {
|
||||
if (!quiet) setActionBusy("");
|
||||
}
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (tab !== "skins" || skinCenterLoadedRef.current) return;
|
||||
skinCenterLoadedRef.current = true;
|
||||
void loadSkinCenter();
|
||||
}, [tab, loadSkinCenter]);
|
||||
|
||||
const importSkinThemeZip = async (file?: File | null) => {
|
||||
if (!file) return;
|
||||
if (!file.name.toLowerCase().endsWith(".zip")) {
|
||||
setError(lang === "zh" ? "请选择 .zip 主题包" : "Please choose a .zip theme pack");
|
||||
return;
|
||||
}
|
||||
if (file.size > 24 * 1024 * 1024) {
|
||||
setError(lang === "zh" ? "主题包不能超过 24MB" : "Theme ZIP must be smaller than 24MB");
|
||||
return;
|
||||
}
|
||||
setActionBusy("importSkinZip");
|
||||
setError("");
|
||||
try {
|
||||
const bytes = Array.from(new Uint8Array(await file.arrayBuffer()));
|
||||
const result = await invoke<SkinActionResult>("import_skin_theme_zip", { fileName: file.name, bytes });
|
||||
setSkinCenterState(result.state);
|
||||
setToast(result.message);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setActionBusy("");
|
||||
if (skinZipImportRef.current) skinZipImportRef.current.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
const enableSkinTheme = async (id: string) => {
|
||||
setActionBusy(`skin:${id}`);
|
||||
setError("");
|
||||
try {
|
||||
const result = await invoke<SkinActionResult>("enable_skin_theme", { id });
|
||||
setSkinCenterState(result.state);
|
||||
setToast(result.message);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setActionBusy("");
|
||||
}
|
||||
};
|
||||
|
||||
const exportSkinTheme = async (id: string) => {
|
||||
setActionBusy(`skinExport:${id}`);
|
||||
setError("");
|
||||
try {
|
||||
const result = await invoke<SkinExportResult>("export_skin_theme", { id });
|
||||
setToast(result.message || (lang === "zh" ? `主题包已导出:${result.path}` : `Theme exported: ${result.path}`));
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setActionBusy("");
|
||||
}
|
||||
};
|
||||
|
||||
const openImportExistingSkillsMcpPreview = async () => {
|
||||
setActionBusy("previewExistingSkillsMcp");
|
||||
setError("");
|
||||
@@ -2032,8 +1981,6 @@ function App() {
|
||||
(
|
||||
(tab === "provider" && providerMode === "list")
|
||||
|| tab === "skillsMcp"
|
||||
|| tab === "skins"
|
||||
|| (tab === "instruction" && instructionMode === "list")
|
||||
) && "cx-app-content--fixed",
|
||||
skillsMcpImportOpen && Boolean(skillsMcpImportPreview) && "cx-app-content--modal-locked",
|
||||
)}
|
||||
@@ -2061,6 +2008,14 @@ function App() {
|
||||
openExternalUrl(releaseInfo.htmlUrl);
|
||||
}}
|
||||
/>
|
||||
<SkinRestartDialog
|
||||
open={Boolean(skinRestartRequest)}
|
||||
lang={lang}
|
||||
themeName={skinRestartRequest?.themeName}
|
||||
busy={actionBusy.startsWith("skin:")}
|
||||
onClose={closeSkinRestart}
|
||||
onConfirm={confirmSkinRestart}
|
||||
/>
|
||||
<StartupWizardDialog
|
||||
open={startupWizardOpen}
|
||||
closing={startupClosing}
|
||||
@@ -2278,16 +2233,20 @@ function App() {
|
||||
)}
|
||||
|
||||
{state && (tab === "skins" || visitedTabs.has("skins")) && (
|
||||
<div className={tab !== "skins" ? "page-pane-hidden" : undefined}>
|
||||
<div className={cx("cx-skins-pane", tab !== "skins" && "page-pane-hidden")}>
|
||||
<SkinsPage
|
||||
lang={lang}
|
||||
state={skinCenterState}
|
||||
actionBusy={actionBusy}
|
||||
zipInputRef={skinZipImportRef}
|
||||
onLoad={loadSkinCenter}
|
||||
imageInputRef={skinImageInputRef}
|
||||
onLoad={refreshSkinCenter}
|
||||
onImportZip={importSkinThemeZip}
|
||||
onCreateFromImage={createSkinThemeFromImage}
|
||||
onUpdateThemeSettings={updateSkinThemeSettings}
|
||||
onEnableTheme={enableSkinTheme}
|
||||
onExportTheme={exportSkinTheme}
|
||||
onPauseTheme={pauseSkinTheme}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,29 +1,60 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import type { CSSProperties, ChangeEvent, RefObject } from "react";
|
||||
import { convertFileSrc } from "@tauri-apps/api/core";
|
||||
import {
|
||||
Check,
|
||||
Download,
|
||||
FolderArchive,
|
||||
ImagePlus,
|
||||
Loader2,
|
||||
Palette,
|
||||
Pencil,
|
||||
Play,
|
||||
Power,
|
||||
RefreshCw,
|
||||
Settings2,
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
|
||||
import { Button, StatusBadge, cx } from "../components/ui";
|
||||
import { PromptCategoryManager } from "../components/PromptCategoryManager";
|
||||
import type { PromptCategoryItem } from "../components/PromptCategoryManager";
|
||||
import { PageTransition } from "../components/PageTransition";
|
||||
import { SkinThemeEditor } from "../components/SkinThemeEditor";
|
||||
import { Button, IconButton, StatusBadge, cx } from "../components/ui";
|
||||
import { useManagedCategories } from "../promptCategories";
|
||||
import type { ManagedCategoryOptions } from "../promptCategories";
|
||||
import type { Lang, SkinCenterState, SkinThemeSummary } from "../types";
|
||||
import "../styles/skins-page.css";
|
||||
|
||||
type MaybeAsyncAction = () => void | Promise<void>;
|
||||
|
||||
const SKIN_CATEGORY_OPTIONS: ManagedCategoryOptions = {
|
||||
storageKey: "codexx.skinCategories.v1",
|
||||
defaultCategories: (lang) => lang === "zh"
|
||||
? [
|
||||
{ id: "anime", name: "动漫" },
|
||||
{ id: "scenery", name: "风景" },
|
||||
{ id: "minimal", name: "简约" },
|
||||
]
|
||||
: [
|
||||
{ id: "anime", name: "Anime" },
|
||||
{ id: "scenery", name: "Scenery" },
|
||||
{ id: "minimal", name: "Minimal" },
|
||||
],
|
||||
};
|
||||
|
||||
export type SkinsPageProps = {
|
||||
lang: Lang;
|
||||
state: SkinCenterState | null;
|
||||
actionBusy: string;
|
||||
zipInputRef: RefObject<HTMLInputElement>;
|
||||
imageInputRef: RefObject<HTMLInputElement>;
|
||||
onLoad: MaybeAsyncAction;
|
||||
onImportZip: (file?: File | null) => void | Promise<void>;
|
||||
onImportZip: (file?: File | null) => Promise<string | null>;
|
||||
onCreateFromImage: (file?: File | null) => Promise<string | null>;
|
||||
onUpdateThemeSettings: (id: string, name: string, tagline: string, surfaceOpacity: number) => Promise<boolean>;
|
||||
onEnableTheme: (id: string) => void | Promise<void>;
|
||||
onExportTheme: (id: string) => void | Promise<void>;
|
||||
onPauseTheme: MaybeAsyncAction;
|
||||
};
|
||||
|
||||
function getCopy(lang: Lang) {
|
||||
@@ -31,46 +62,64 @@ function getCopy(lang: Lang) {
|
||||
? {
|
||||
eyebrow: "SKIN CENTER",
|
||||
title: "皮肤中心",
|
||||
description: "兼容 Codex Dream Skin 的 theme.json + 图片格式。先管理主题包、切换当前主题,后续接入图片生成与实机注入。",
|
||||
description: "在 Codex-X 中管理主题包,并直接应用到官方 Codex Desktop。",
|
||||
refresh: "刷新",
|
||||
refreshingButton: "刷新中",
|
||||
refreshing: "正在刷新主题库...",
|
||||
importZip: "导入主题包",
|
||||
createFromImage: "从图片创建皮肤",
|
||||
creatingFromImage: "正在创建",
|
||||
pause: "关闭皮肤",
|
||||
pausing: "关闭中",
|
||||
loading: "正在读取皮肤库...",
|
||||
builtin: "内置",
|
||||
imported: "导入",
|
||||
enabled: "已启用",
|
||||
enable: "启用",
|
||||
selected: "已选择",
|
||||
applied: "已启用",
|
||||
apply: "启用",
|
||||
export: "导出",
|
||||
edit: "编辑",
|
||||
applying: "启用中",
|
||||
exporting: "导出中",
|
||||
current: "当前主题",
|
||||
storage: "皮肤目录",
|
||||
currentPath: "当前 theme.json",
|
||||
noCurrent: "尚未启用主题",
|
||||
noThemes: "还没有主题。导入 .zip 主题包或使用内置主题开始。",
|
||||
zipHint: "主题包结构:theme.json + background.png/jpg/webp",
|
||||
current: "当前皮肤",
|
||||
runtime: "Codex 运行状态",
|
||||
noCurrent: "尚未启用皮肤",
|
||||
noThemes: "还没有皮肤。选择一张图片即可创建。",
|
||||
manageCategories: "分类管理",
|
||||
emptyCategory: "该分类下暂无皮肤",
|
||||
statusReady: "主题库已就绪",
|
||||
selectTheme: (name: string) => `选择皮肤:${name}`,
|
||||
}
|
||||
: {
|
||||
eyebrow: "SKIN CENTER",
|
||||
title: "Skin Center",
|
||||
description: "Compatible with the Codex Dream Skin theme.json + image format. Manage packs and switch the current theme first; image generation and live injection can follow.",
|
||||
description: "Manage theme packs in Codex-X and apply them directly to the official Codex Desktop app.",
|
||||
refresh: "Refresh",
|
||||
refreshingButton: "Refreshing",
|
||||
refreshing: "Refreshing skin library...",
|
||||
importZip: "Import theme ZIP",
|
||||
createFromImage: "Create from image",
|
||||
creatingFromImage: "Creating",
|
||||
pause: "Turn off skin",
|
||||
pausing: "Turning off",
|
||||
loading: "Loading skin library...",
|
||||
builtin: "Built-in",
|
||||
imported: "Imported",
|
||||
enabled: "Enabled",
|
||||
enable: "Enable",
|
||||
selected: "Selected",
|
||||
applied: "Enabled",
|
||||
apply: "Enable",
|
||||
export: "Export",
|
||||
edit: "Edit",
|
||||
applying: "Enabling",
|
||||
exporting: "Exporting",
|
||||
current: "Current theme",
|
||||
storage: "Skin directory",
|
||||
currentPath: "Current theme.json",
|
||||
noCurrent: "No theme enabled yet",
|
||||
noThemes: "No themes yet. Import a .zip theme pack or start with a built-in theme.",
|
||||
zipHint: "Theme pack: theme.json + background.png/jpg/webp",
|
||||
current: "Current skin",
|
||||
runtime: "Codex runtime",
|
||||
noCurrent: "No skin enabled yet",
|
||||
noThemes: "No skins yet. Choose an image to create one.",
|
||||
manageCategories: "Manage categories",
|
||||
emptyCategory: "No skins in this category",
|
||||
statusReady: "Theme library ready",
|
||||
selectTheme: (name: string) => `Select skin: ${name}`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -85,20 +134,26 @@ function sourceLabel(theme: SkinThemeSummary, copy: ReturnType<typeof getCopy>)
|
||||
function ThemeCard({
|
||||
theme,
|
||||
copy,
|
||||
busy,
|
||||
onEnableTheme,
|
||||
onExportTheme,
|
||||
runtimeActive,
|
||||
activeThemeId,
|
||||
selected,
|
||||
onSelect,
|
||||
}: {
|
||||
theme: SkinThemeSummary;
|
||||
copy: ReturnType<typeof getCopy>;
|
||||
busy: string;
|
||||
onEnableTheme: SkinsPageProps["onEnableTheme"];
|
||||
onExportTheme: SkinsPageProps["onExportTheme"];
|
||||
runtimeActive: boolean;
|
||||
activeThemeId?: string | null;
|
||||
selected: boolean;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
const enabling = busy === `skin:${theme.id}`;
|
||||
const exporting = busy === `skinExport:${theme.id}`;
|
||||
const disabled = Boolean(busy);
|
||||
const applied = runtimeActive && activeThemeId === theme.id;
|
||||
const focusX = Math.round((theme.art.focusX ?? 0.5) * 100);
|
||||
const focusY = Math.round((theme.art.focusY ?? 0.5) * 100);
|
||||
const safeArea = theme.art.safeArea === "left" || theme.art.safeArea === "right" || theme.art.safeArea === "center"
|
||||
? theme.art.safeArea
|
||||
: "center";
|
||||
const style = {
|
||||
"--skin-image": `url("${convertFileSrc(theme.imagePath || `${theme.directory}/${theme.image}`)}")`,
|
||||
"--skin-bg": theme.colors.background,
|
||||
"--skin-panel": theme.colors.panel,
|
||||
"--skin-accent": theme.colors.accent,
|
||||
@@ -106,20 +161,47 @@ function ThemeCard({
|
||||
"--skin-highlight": theme.colors.highlight,
|
||||
"--skin-text": theme.colors.text,
|
||||
"--skin-muted": theme.colors.muted,
|
||||
"--skin-line": theme.colors.line,
|
||||
"--skin-focus-x": `${focusX}%`,
|
||||
"--skin-focus-y": `${focusY}%`,
|
||||
} as CSSProperties;
|
||||
|
||||
return (
|
||||
<article className={cx("cx-skins-card", theme.enabled && "cx-skins-card--enabled")} style={style}>
|
||||
<div className="cx-skins-preview" aria-hidden="true">
|
||||
<div className="cx-skins-preview-sidebar" />
|
||||
<div className="cx-skins-preview-main">
|
||||
<span />
|
||||
<strong />
|
||||
<p />
|
||||
<div>
|
||||
<i />
|
||||
<i />
|
||||
<i />
|
||||
<article
|
||||
className={cx(
|
||||
"cx-skins-card",
|
||||
(theme.enabled || applied) && "cx-skins-card--enabled",
|
||||
selected && "cx-skins-card--selected",
|
||||
)}
|
||||
style={style}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-pressed={selected}
|
||||
aria-label={copy.selectTheme(theme.name)}
|
||||
onClick={onSelect}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
onSelect();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className={cx("cx-skins-preview", theme.adaptive && "cx-skins-preview--adaptive")} aria-hidden="true">
|
||||
<div className={cx("cx-skins-preview-app", `cx-skins-preview-app--${safeArea}`)}>
|
||||
<aside className="cx-skins-preview-sidebar">
|
||||
<div className="cx-skins-preview-brand"><i /><span /></div>
|
||||
<div className="cx-skins-preview-nav">
|
||||
<span /><span className="is-active" /><span /><span />
|
||||
</div>
|
||||
<div className="cx-skins-preview-profile" />
|
||||
</aside>
|
||||
<div className="cx-skins-preview-main">
|
||||
<div className="cx-skins-preview-topbar"><span /><i /></div>
|
||||
<div className="cx-skins-preview-chat">
|
||||
<div className="cx-skins-preview-message"><i /><span /></div>
|
||||
<div className="cx-skins-preview-message cx-skins-preview-message--user"><span /></div>
|
||||
</div>
|
||||
<div className="cx-skins-preview-composer"><span /><i /></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -130,37 +212,11 @@ function ThemeCard({
|
||||
<strong>{theme.name}</strong>
|
||||
<span>{theme.tagline}</span>
|
||||
</div>
|
||||
<StatusBadge tone={theme.enabled ? "success" : "neutral"} dot={theme.enabled}>
|
||||
{theme.enabled ? copy.enabled : sourceLabel(theme, copy)}
|
||||
<StatusBadge tone={applied ? "success" : theme.enabled ? "info" : "neutral"} dot={applied || theme.enabled}>
|
||||
{applied ? copy.applied : theme.enabled ? copy.selected : sourceLabel(theme, copy)}
|
||||
</StatusBadge>
|
||||
</div>
|
||||
<p>{theme.quote}</p>
|
||||
</div>
|
||||
|
||||
<div className="cx-skins-swatches" aria-hidden="true">
|
||||
<span style={{ background: theme.colors.accent }} />
|
||||
<span style={{ background: theme.colors.secondary }} />
|
||||
<span style={{ background: theme.colors.highlight }} />
|
||||
<span style={{ background: theme.colors.panel }} />
|
||||
</div>
|
||||
|
||||
<div className="cx-skins-card-actions">
|
||||
<Button
|
||||
variant={theme.enabled ? "secondary" : "primary"}
|
||||
onClick={() => run(() => onEnableTheme(theme.id))}
|
||||
disabled={disabled || theme.enabled}
|
||||
icon={enabling ? <Loader2 className="cx-skins-spin" size={15} /> : theme.enabled ? <Check size={15} /> : <Play size={15} />}
|
||||
>
|
||||
{enabling ? copy.applying : theme.enabled ? copy.enabled : copy.enable}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => run(() => onExportTheme(theme.id))}
|
||||
disabled={disabled}
|
||||
icon={exporting ? <Loader2 className="cx-skins-spin" size={15} /> : <Download size={15} />}
|
||||
>
|
||||
{exporting ? copy.exporting : copy.export}
|
||||
</Button>
|
||||
<p aria-hidden={!theme.quote}>{theme.quote || "\u00a0"}</p>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
@@ -171,22 +227,79 @@ export function SkinsPage({
|
||||
state,
|
||||
actionBusy,
|
||||
zipInputRef,
|
||||
imageInputRef,
|
||||
onLoad,
|
||||
onImportZip,
|
||||
onCreateFromImage,
|
||||
onUpdateThemeSettings,
|
||||
onEnableTheme,
|
||||
onExportTheme,
|
||||
onPauseTheme,
|
||||
}: SkinsPageProps) {
|
||||
const copy = getCopy(lang);
|
||||
const themes = state?.themes ?? [];
|
||||
const current = themes.find((theme) => theme.enabled);
|
||||
const loading = actionBusy === "loadSkins";
|
||||
const runtime = state?.runtime;
|
||||
const [categoryManagerOpen, setCategoryManagerOpen] = useState(false);
|
||||
const [editorOpen, setEditorOpen] = useState(false);
|
||||
const [selectedThemeId, setSelectedThemeId] = useState<string | null>(null);
|
||||
const skinCategories = useManagedCategories(lang, SKIN_CATEGORY_OPTIONS);
|
||||
const categoryItems = useMemo<PromptCategoryItem[]>(() => themes.map((theme) => ({
|
||||
key: `skin:${theme.id.trim().toLowerCase()}`,
|
||||
title: theme.name,
|
||||
})), [themes]);
|
||||
const visibleThemes = themes.filter((theme) =>
|
||||
skinCategories.categoryForPrompt(`skin:${theme.id.trim().toLowerCase()}`) === skinCategories.activeCategoryId);
|
||||
const selectedTheme = themes.find((theme) => theme.id === selectedThemeId);
|
||||
const selectedThemeApplied = Boolean(
|
||||
selectedTheme && runtime?.active && runtime.themeId === selectedTheme.id,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedThemeId && !themes.some((theme) => theme.id === selectedThemeId)) {
|
||||
setSelectedThemeId(null);
|
||||
}
|
||||
}, [selectedThemeId, themes]);
|
||||
|
||||
const selectCreatedTheme = (themeId: string | null) => {
|
||||
if (!themeId) return;
|
||||
const categoryId = skinCategories.categoryForPrompt(`skin:${themeId.trim().toLowerCase()}`);
|
||||
skinCategories.setActiveCategoryId(categoryId);
|
||||
setSelectedThemeId(themeId);
|
||||
};
|
||||
|
||||
const handleZipChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
void onImportZip(event.currentTarget.files?.[0] ?? null);
|
||||
void onImportZip(event.currentTarget.files?.[0] ?? null).then(selectCreatedTheme);
|
||||
};
|
||||
|
||||
const handleImageChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
void onCreateFromImage(event.currentTarget.files?.[0] ?? null).then(selectCreatedTheme);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="cx-skins-page">
|
||||
<section className={cx("cx-skins-page", loading && "cx-skins-page--refreshing")} aria-busy={loading}>
|
||||
<PromptCategoryManager
|
||||
open={categoryManagerOpen}
|
||||
lang={lang}
|
||||
categories={skinCategories.categories}
|
||||
prompts={categoryItems}
|
||||
categoryForPrompt={skinCategories.categoryForPrompt}
|
||||
onClose={() => setCategoryManagerOpen(false)}
|
||||
onAddCategory={skinCategories.addCategory}
|
||||
onRenameCategory={skinCategories.renameCategory}
|
||||
onDeleteCategory={skinCategories.deleteCategory}
|
||||
onMovePrompt={skinCategories.movePrompt}
|
||||
subject="skin"
|
||||
/>
|
||||
<SkinThemeEditor
|
||||
lang={lang}
|
||||
open={editorOpen}
|
||||
theme={selectedTheme || null}
|
||||
busy={Boolean(selectedTheme && actionBusy === `skinEdit:${selectedTheme.id}`)}
|
||||
onClose={() => setEditorOpen(false)}
|
||||
onSave={onUpdateThemeSettings}
|
||||
/>
|
||||
<input
|
||||
ref={zipInputRef}
|
||||
className="cx-skins-file-input"
|
||||
@@ -194,6 +307,13 @@ export function SkinsPage({
|
||||
accept=".zip,application/zip"
|
||||
onChange={handleZipChange}
|
||||
/>
|
||||
<input
|
||||
ref={imageInputRef}
|
||||
className="cx-skins-file-input"
|
||||
type="file"
|
||||
accept=".png,.jpg,.jpeg,.webp,image/png,image/jpeg,image/webp"
|
||||
onChange={handleImageChange}
|
||||
/>
|
||||
|
||||
<header className="cx-skins-header">
|
||||
<div className="cx-skins-heading">
|
||||
@@ -202,22 +322,66 @@ export function SkinsPage({
|
||||
<span>{copy.description}</span>
|
||||
</div>
|
||||
<div className="cx-skins-actions">
|
||||
{selectedTheme && (
|
||||
<>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => setEditorOpen(true)}
|
||||
disabled={Boolean(actionBusy)}
|
||||
icon={<Pencil size={15} />}
|
||||
>
|
||||
{copy.edit}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => run(() => onEnableTheme(selectedTheme.id))}
|
||||
disabled={Boolean(actionBusy) || selectedThemeApplied}
|
||||
icon={actionBusy === `skin:${selectedTheme.id}`
|
||||
? <Loader2 className="cx-skins-spin" size={15} />
|
||||
: selectedThemeApplied ? <Check size={15} /> : <Play size={15} />}
|
||||
>
|
||||
{actionBusy === `skin:${selectedTheme.id}`
|
||||
? copy.applying
|
||||
: selectedThemeApplied ? copy.applied : copy.apply}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => run(() => onExportTheme(selectedTheme.id))}
|
||||
disabled={Boolean(actionBusy)}
|
||||
icon={actionBusy === `skinExport:${selectedTheme.id}`
|
||||
? <Loader2 className="cx-skins-spin" size={15} />
|
||||
: <Download size={15} />}
|
||||
>
|
||||
{actionBusy === `skinExport:${selectedTheme.id}` ? copy.exporting : copy.export}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => run(onLoad)}
|
||||
disabled={Boolean(actionBusy)}
|
||||
icon={loading ? <Loader2 className="cx-skins-spin" size={15} /> : <FolderArchive size={15} />}
|
||||
onClick={() => run(onPauseTheme)}
|
||||
disabled={Boolean(actionBusy) || !runtime?.supported || !runtime.active}
|
||||
icon={actionBusy === "pauseSkin" ? <Loader2 className="cx-skins-spin" size={15} /> : <Power size={15} />}
|
||||
>
|
||||
{copy.refresh}
|
||||
{actionBusy === "pauseSkin" ? copy.pausing : copy.pause}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
variant="secondary"
|
||||
onClick={() => zipInputRef.current?.click()}
|
||||
disabled={Boolean(actionBusy)}
|
||||
disabled={Boolean(actionBusy) || !state}
|
||||
icon={actionBusy === "importSkinZip" ? <Loader2 className="cx-skins-spin" size={15} /> : <Upload size={15} />}
|
||||
>
|
||||
{copy.importZip}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => imageInputRef.current?.click()}
|
||||
disabled={Boolean(actionBusy) || !state}
|
||||
icon={actionBusy === "createSkinFromImage"
|
||||
? <Loader2 className="cx-skins-spin" size={15} />
|
||||
: <ImagePlus size={15} />}
|
||||
>
|
||||
{actionBusy === "createSkinFromImage" ? copy.creatingFromImage : copy.createFromImage}
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -227,36 +391,82 @@ export function SkinsPage({
|
||||
<strong>{current?.name || copy.noCurrent}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>{copy.storage}</span>
|
||||
<strong title={state?.skinsDir || ""}>{state?.skinsDir || "-"}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>{copy.currentPath}</span>
|
||||
<strong title={state?.currentThemePath || ""}>{state?.currentThemePath || "-"}</strong>
|
||||
<span>{copy.runtime}</span>
|
||||
<strong title={runtime?.message || ""}>{runtime?.message || "-"}</strong>
|
||||
</div>
|
||||
<IconButton
|
||||
className="cx-skins-refresh-button"
|
||||
icon={loading ? <Loader2 className="cx-skins-spin" /> : <RefreshCw />}
|
||||
label={loading ? copy.refreshingButton : copy.refresh}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => run(onLoad)}
|
||||
disabled={Boolean(actionBusy)}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<div className="cx-skins-hint">{copy.zipHint}</div>
|
||||
|
||||
{themes.length === 0 ? (
|
||||
<div className="cx-skins-empty">
|
||||
<Palette size={24} strokeWidth={1.7} aria-hidden="true" />
|
||||
<span>{loading ? copy.loading : copy.noThemes}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="cx-skins-grid">
|
||||
{themes.map((theme) => (
|
||||
<ThemeCard
|
||||
key={theme.id}
|
||||
theme={theme}
|
||||
copy={copy}
|
||||
busy={actionBusy}
|
||||
onEnableTheme={onEnableTheme}
|
||||
onExportTheme={onExportTheme}
|
||||
/>
|
||||
))}
|
||||
{loading && state && (
|
||||
<div className="cx-skins-refresh-status" role="status" aria-live="polite">
|
||||
<Loader2 className="cx-skins-spin" size={15} aria-hidden="true" />
|
||||
<span>{copy.refreshing}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{themes.length > 0 && (
|
||||
<div className="cx-prompt-category-toolbar cx-skins-category-toolbar">
|
||||
<div className="cx-skills-tabs cx-prompt-category-tabs" role="group" aria-label={copy.manageCategories}>
|
||||
{skinCategories.categories.map((category) => (
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={category.id === skinCategories.activeCategoryId}
|
||||
className={cx(
|
||||
"cx-skills-tab",
|
||||
"cx-prompt-category-tab",
|
||||
category.id === skinCategories.activeCategoryId && "cx-skills-tab--active",
|
||||
)}
|
||||
key={category.id}
|
||||
onClick={() => {
|
||||
setSelectedThemeId(null);
|
||||
skinCategories.setActiveCategoryId(category.id);
|
||||
}}
|
||||
>
|
||||
{category.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button type="button" className="cx-prompt-category-manage" onClick={() => setCategoryManagerOpen(true)}>
|
||||
<Settings2 size={16} aria-hidden="true" />{copy.manageCategories}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<PageTransition pageKey={`skins-category:${skinCategories.activeCategoryId}`}>
|
||||
{themes.length === 0 ? (
|
||||
<div className="cx-skins-empty">
|
||||
<Palette size={24} strokeWidth={1.7} aria-hidden="true" />
|
||||
<span>{loading ? copy.loading : copy.noThemes}</span>
|
||||
</div>
|
||||
) : visibleThemes.length === 0 ? (
|
||||
<div className="cx-skins-empty cx-skins-empty--category">
|
||||
<Palette size={24} strokeWidth={1.7} aria-hidden="true" />
|
||||
<span>{copy.emptyCategory}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className={cx("cx-skins-grid", `cx-skins-grid--${Math.min(visibleThemes.length, 4)}`)}>
|
||||
{visibleThemes.map((theme) => (
|
||||
<ThemeCard
|
||||
key={theme.id}
|
||||
theme={theme}
|
||||
copy={copy}
|
||||
runtimeActive={Boolean(runtime?.active)}
|
||||
activeThemeId={runtime?.themeId}
|
||||
selected={theme.id === selectedThemeId}
|
||||
onSelect={() => setSelectedThemeId(theme.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</PageTransition>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import * as React from "react";
|
||||
|
||||
import type { Lang } from "./types";
|
||||
|
||||
const STORAGE_KEY = "codexx.promptCategories.v1";
|
||||
const PROMPT_STORAGE_KEY = "codexx.promptCategories.v1";
|
||||
const STATE_VERSION = 1 as const;
|
||||
export const PROMPT_CATEGORY_NAME_MAX_LENGTH = 40;
|
||||
|
||||
@@ -19,7 +19,12 @@ type PromptCategoryState = {
|
||||
assignments: Record<string, string>;
|
||||
};
|
||||
|
||||
function defaultCategories(lang: Lang): PromptCategory[] {
|
||||
export type ManagedCategoryOptions = {
|
||||
storageKey: string;
|
||||
defaultCategories: (lang: Lang) => PromptCategory[];
|
||||
};
|
||||
|
||||
function defaultPromptCategories(lang: Lang): PromptCategory[] {
|
||||
return lang === "zh"
|
||||
? [
|
||||
{ id: "security-reverse", name: "破甲/逆向" },
|
||||
@@ -33,8 +38,13 @@ function defaultCategories(lang: Lang): PromptCategory[] {
|
||||
];
|
||||
}
|
||||
|
||||
function initialState(lang: Lang): PromptCategoryState {
|
||||
const categories = defaultCategories(lang);
|
||||
const PROMPT_CATEGORY_OPTIONS: ManagedCategoryOptions = {
|
||||
storageKey: PROMPT_STORAGE_KEY,
|
||||
defaultCategories: defaultPromptCategories,
|
||||
};
|
||||
|
||||
function initialState(lang: Lang, options: ManagedCategoryOptions): PromptCategoryState {
|
||||
const categories = options.defaultCategories(lang);
|
||||
return {
|
||||
version: STATE_VERSION,
|
||||
categories,
|
||||
@@ -48,8 +58,12 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export function sanitizePromptCategoryState(value: unknown, lang: Lang): PromptCategoryState {
|
||||
const fallback = initialState(lang);
|
||||
export function sanitizePromptCategoryState(
|
||||
value: unknown,
|
||||
lang: Lang,
|
||||
options: ManagedCategoryOptions = PROMPT_CATEGORY_OPTIONS,
|
||||
): PromptCategoryState {
|
||||
const fallback = initialState(lang, options);
|
||||
if (!isRecord(value) || value.version !== STATE_VERSION || !Array.isArray(value.categories)) {
|
||||
return fallback;
|
||||
}
|
||||
@@ -91,19 +105,19 @@ export function sanitizePromptCategoryState(value: unknown, lang: Lang): PromptC
|
||||
};
|
||||
}
|
||||
|
||||
function loadState(lang: Lang): PromptCategoryState {
|
||||
function loadState(lang: Lang, options: ManagedCategoryOptions): PromptCategoryState {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return initialState(lang);
|
||||
return sanitizePromptCategoryState(JSON.parse(raw), lang);
|
||||
const raw = localStorage.getItem(options.storageKey);
|
||||
if (!raw) return initialState(lang, options);
|
||||
return sanitizePromptCategoryState(JSON.parse(raw), lang, options);
|
||||
} catch {
|
||||
return initialState(lang);
|
||||
return initialState(lang, options);
|
||||
}
|
||||
}
|
||||
|
||||
function saveState(state: PromptCategoryState) {
|
||||
function saveState(storageKey: string, state: PromptCategoryState) {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
|
||||
localStorage.setItem(storageKey, JSON.stringify(state));
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
@@ -122,12 +136,12 @@ function nextCategoryId(categories: PromptCategory[]) {
|
||||
return id;
|
||||
}
|
||||
|
||||
export function usePromptCategories(lang: Lang) {
|
||||
const [state, setState] = React.useState<PromptCategoryState>(() => loadState(lang));
|
||||
export function useManagedCategories(lang: Lang, options: ManagedCategoryOptions) {
|
||||
const [state, setState] = React.useState<PromptCategoryState>(() => loadState(lang, options));
|
||||
|
||||
React.useEffect(() => {
|
||||
saveState(state);
|
||||
}, [state]);
|
||||
saveState(options.storageKey, state);
|
||||
}, [options.storageKey, state]);
|
||||
|
||||
const categoryIds = React.useMemo(
|
||||
() => new Set(state.categories.map((category) => category.id)),
|
||||
@@ -238,3 +252,7 @@ export function usePromptCategories(lang: Lang) {
|
||||
forgetPrompt,
|
||||
};
|
||||
}
|
||||
|
||||
export function usePromptCategories(lang: Lang) {
|
||||
return useManagedCategories(lang, PROMPT_CATEGORY_OPTIONS);
|
||||
}
|
||||
|
||||
@@ -350,3 +350,33 @@
|
||||
transform: translateX(80%);
|
||||
}
|
||||
}
|
||||
|
||||
.cx-dialog-spin {
|
||||
animation: cx-app-dialog-spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
.cx-skin-restart-warning {
|
||||
display: grid;
|
||||
grid-template-columns: 24px minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
align-items: start;
|
||||
padding: 12px;
|
||||
border: 1px solid #ead6a9;
|
||||
border-radius: 8px;
|
||||
color: #76531c;
|
||||
background: #fff9eb;
|
||||
}
|
||||
|
||||
.cx-skin-restart-warning svg {
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.cx-skin-restart-warning p {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
@keyframes cx-app-dialog-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@@ -345,6 +345,67 @@
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
/* Skins */
|
||||
:root[data-theme="dark"] .cx-skins-page {
|
||||
--cx-skins-text: var(--cx-dark-text);
|
||||
--cx-skins-muted: var(--cx-dark-muted);
|
||||
--cx-skins-line: var(--cx-dark-border);
|
||||
--cx-skins-surface: var(--cx-dark-surface);
|
||||
--cx-skins-surface-muted: var(--cx-dark-surface-recessed);
|
||||
--cx-skins-blue: var(--cx-dark-blue);
|
||||
--cx-skins-blue-soft: var(--cx-dark-blue-surface);
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .cx-skins-header {
|
||||
border-color: var(--cx-dark-border);
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .cx-skins-heading h2,
|
||||
:root[data-theme="dark"] .cx-skins-status strong,
|
||||
:root[data-theme="dark"] .cx-skins-card-title strong {
|
||||
color: #f1f2f4;
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .cx-skins-status,
|
||||
:root[data-theme="dark"] .cx-skins-card {
|
||||
background: var(--cx-dark-surface);
|
||||
border-color: var(--cx-dark-border);
|
||||
box-shadow: 0 5px 18px rgba(0, 0, 0, 0.14);
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .cx-skins-card--enabled {
|
||||
border-color: color-mix(in srgb, var(--skin-accent) 48%, var(--cx-dark-border));
|
||||
box-shadow: 0 8px 24px color-mix(in srgb, var(--skin-accent) 18%, transparent);
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .cx-skins-card--selected {
|
||||
border-color: var(--cx-dark-blue);
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--cx-dark-blue) 28%, transparent),
|
||||
0 10px 26px rgba(0, 0, 0, 0.22);
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .cx-skins-card-body p {
|
||||
color: #c7cbd2;
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .cx-skins-empty {
|
||||
color: var(--cx-dark-muted);
|
||||
background: var(--cx-dark-surface-recessed);
|
||||
border-color: var(--cx-dark-border);
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .cx-skins-refresh-status {
|
||||
color: #a9c6ff;
|
||||
background: var(--cx-dark-blue-surface);
|
||||
border-color: #4d6691;
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .cx-skin-restart-warning {
|
||||
border-color: #6f5a2f;
|
||||
color: #ead8ac;
|
||||
background: #302817;
|
||||
}
|
||||
|
||||
/* Providers */
|
||||
:root[data-theme="dark"] .cx-providers {
|
||||
--cx-provider-text: var(--cx-dark-text);
|
||||
|
||||
@@ -24,13 +24,18 @@
|
||||
color: var(--cx-tool-text);
|
||||
}
|
||||
|
||||
.cx-skills-page,
|
||||
.cx-prompts-page--list {
|
||||
.cx-skills-page {
|
||||
height: 100%;
|
||||
flex: 1 1 auto;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.cx-prompts-page--list {
|
||||
min-height: 100%;
|
||||
flex: 0 0 auto;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.cx-prompts-page--form {
|
||||
min-height: 100%;
|
||||
}
|
||||
@@ -1074,7 +1079,7 @@
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex: 0 0 auto;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
@@ -1082,12 +1087,12 @@
|
||||
.cx-prompts-list {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
flex: 1 1 auto;
|
||||
flex: 0 0 auto;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
align-content: start;
|
||||
gap: 16px;
|
||||
overflow-y: auto;
|
||||
overflow: visible;
|
||||
padding: 0 8px 32px 0;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,12 @@
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.cx-skins-pane {
|
||||
min-width: 0;
|
||||
min-height: 100%;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.cx-skins-page {
|
||||
--cx-skins-text: #202226;
|
||||
--cx-skins-muted: #737982;
|
||||
@@ -23,7 +29,7 @@
|
||||
min-height: 40px;
|
||||
padding-right: 15px;
|
||||
padding-left: 15px;
|
||||
border-radius: 10px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 6px rgba(32, 34, 38, 0.06);
|
||||
font-size: 13px;
|
||||
letter-spacing: 0;
|
||||
@@ -104,20 +110,31 @@
|
||||
}
|
||||
|
||||
.cx-skins-status {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(160px, 0.8fr) minmax(220px, 1.1fr) minmax(220px, 1.1fr);
|
||||
gap: 10px;
|
||||
min-height: 52px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
padding: 0 16px;
|
||||
border: 1px solid var(--cx-skins-line);
|
||||
border-radius: 8px;
|
||||
background: var(--cx-skins-surface-muted);
|
||||
}
|
||||
|
||||
.cx-skins-status > div {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
padding: 13px 14px;
|
||||
border: 1px solid var(--cx-skins-line);
|
||||
border-radius: 10px;
|
||||
background: var(--cx-skins-surface);
|
||||
box-shadow: 0 2px 8px rgba(32, 34, 38, 0.035);
|
||||
flex: 1 1 0;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.cx-skins-status > div:first-child {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.cx-skins-status > div + div {
|
||||
border-left: 1px solid var(--cx-skins-line);
|
||||
}
|
||||
|
||||
.cx-skins-status span {
|
||||
@@ -136,31 +153,70 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cx-skins-hint {
|
||||
width: fit-content;
|
||||
padding: 7px 10px;
|
||||
border: 1px solid #dce7ff;
|
||||
.cx-skins-refresh-button {
|
||||
margin-left: 8px;
|
||||
color: var(--cx-skins-muted);
|
||||
}
|
||||
|
||||
.cx-skins-refresh-status {
|
||||
min-height: 34px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0 11px;
|
||||
border: 1px solid #cdddfd;
|
||||
border-radius: 8px;
|
||||
color: #2556b7;
|
||||
background: var(--cx-skins-blue-soft);
|
||||
color: #2454ad;
|
||||
background: #f3f7ff;
|
||||
font-size: 12px;
|
||||
font-weight: 620;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.cx-skins-category-toolbar {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.cx-skins-grid {
|
||||
width: 100%;
|
||||
max-width: 280px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
grid-template-columns: repeat(auto-fit, minmax(230px, 1fr));
|
||||
align-items: start;
|
||||
justify-content: start;
|
||||
gap: 14px;
|
||||
padding-bottom: 8px;
|
||||
padding-bottom: 28px;
|
||||
}
|
||||
|
||||
/* Preserve compact cards for small categories, then use the available row
|
||||
once four or more themes can fit without making the cards too narrow. */
|
||||
.cx-skins-grid--2 {
|
||||
max-width: 574px;
|
||||
}
|
||||
|
||||
.cx-skins-grid--3 {
|
||||
max-width: 868px;
|
||||
}
|
||||
|
||||
.cx-skins-grid--4 {
|
||||
max-width: 1162px;
|
||||
}
|
||||
|
||||
.cx-skins-card {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--cx-skins-line);
|
||||
border-radius: 14px;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 5px 18px rgba(27, 35, 48, 0.06);
|
||||
box-shadow: 0 5px 18px rgba(27, 35, 48, 0.07);
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
transition: border-color 160ms ease, box-shadow 160ms ease, transform 160ms ease;
|
||||
}
|
||||
|
||||
.cx-skins-card:hover {
|
||||
border-color: #cfd5dd;
|
||||
box-shadow: 0 10px 26px rgba(27, 35, 48, 0.11);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.cx-skins-card--enabled {
|
||||
@@ -168,84 +224,230 @@
|
||||
box-shadow: 0 8px 24px color-mix(in srgb, var(--skin-accent) 18%, transparent);
|
||||
}
|
||||
|
||||
.cx-skins-card--selected {
|
||||
border-color: var(--cx-skins-blue);
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--cx-skins-blue) 22%, transparent),
|
||||
0 10px 26px rgba(27, 35, 48, 0.11);
|
||||
}
|
||||
|
||||
.cx-skins-card:focus-visible {
|
||||
outline: 2px solid color-mix(in srgb, var(--cx-skins-blue) 76%, transparent);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
.cx-skins-preview {
|
||||
height: 142px;
|
||||
position: relative;
|
||||
aspect-ratio: 16 / 9;
|
||||
overflow: hidden;
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--skin-accent) 18%, #e7e9ed);
|
||||
background: var(--skin-image) var(--skin-focus-x) var(--skin-focus-y) / cover no-repeat, var(--skin-bg);
|
||||
}
|
||||
|
||||
.cx-skins-preview::after {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
content: "";
|
||||
pointer-events: none;
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.05), rgba(20, 23, 28, 0.06));
|
||||
}
|
||||
|
||||
.cx-skins-preview--adaptive .cx-skins-preview-app {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.cx-skins-preview--adaptive::after {
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.03), rgba(20, 23, 28, 0.08));
|
||||
}
|
||||
|
||||
.cx-skins-preview-app {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
top: 9px;
|
||||
bottom: 9px;
|
||||
width: 64%;
|
||||
display: grid;
|
||||
grid-template-columns: 68px minmax(0, 1fr);
|
||||
padding: 12px;
|
||||
background:
|
||||
radial-gradient(circle at 78% 18%, color-mix(in srgb, var(--skin-secondary) 36%, transparent), transparent 34%),
|
||||
radial-gradient(circle at 12% 86%, color-mix(in srgb, var(--skin-highlight) 28%, transparent), transparent 30%),
|
||||
var(--skin-bg);
|
||||
grid-template-columns: 42px minmax(0, 1fr);
|
||||
overflow: hidden;
|
||||
border: 1px solid color-mix(in srgb, var(--skin-line, var(--skin-accent)) 44%, rgba(255, 255, 255, 0.55));
|
||||
border-radius: 7px;
|
||||
box-shadow: 0 10px 28px rgba(25, 22, 30, 0.18);
|
||||
backdrop-filter: blur(7px) saturate(1.08);
|
||||
}
|
||||
|
||||
.cx-skins-preview-app--left {
|
||||
left: 9px;
|
||||
}
|
||||
|
||||
.cx-skins-preview-app--right {
|
||||
right: 9px;
|
||||
}
|
||||
|
||||
.cx-skins-preview-app--center {
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.cx-skins-preview-sidebar {
|
||||
border-radius: 12px;
|
||||
background: color-mix(in srgb, var(--skin-panel) 88%, transparent);
|
||||
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--skin-accent) 18%, transparent);
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 6px 5px;
|
||||
border-right: 1px solid color-mix(in srgb, var(--skin-accent) 18%, transparent);
|
||||
background: color-mix(in srgb, var(--skin-panel) 82%, transparent);
|
||||
}
|
||||
|
||||
.cx-skins-preview-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 0 2px 4px;
|
||||
}
|
||||
|
||||
.cx-skins-preview-brand i {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
flex: 0 0 auto;
|
||||
border-radius: 3px;
|
||||
background: var(--skin-accent);
|
||||
box-shadow: 0 2px 7px color-mix(in srgb, var(--skin-accent) 38%, transparent);
|
||||
}
|
||||
|
||||
.cx-skins-preview-brand span {
|
||||
width: 19px;
|
||||
height: 3px;
|
||||
border-radius: 3px;
|
||||
background: color-mix(in srgb, var(--skin-text) 72%, transparent);
|
||||
}
|
||||
|
||||
.cx-skins-preview-nav {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.cx-skins-preview-nav span {
|
||||
height: 10px;
|
||||
border-radius: 3px;
|
||||
background: color-mix(in srgb, var(--skin-text) 9%, transparent);
|
||||
}
|
||||
|
||||
.cx-skins-preview-nav span.is-active {
|
||||
background: color-mix(in srgb, var(--skin-accent) 30%, transparent);
|
||||
box-shadow: inset 2px 0 var(--skin-accent);
|
||||
}
|
||||
|
||||
.cx-skins-preview-profile {
|
||||
width: 100%;
|
||||
height: 10px;
|
||||
margin-top: auto;
|
||||
border-radius: 3px;
|
||||
background: color-mix(in srgb, var(--skin-secondary) 28%, transparent);
|
||||
}
|
||||
|
||||
.cx-skins-preview-main {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
margin-left: 10px;
|
||||
padding: 16px;
|
||||
border-radius: 14px;
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-rows: 24px minmax(0, 1fr) 34px;
|
||||
color: var(--skin-text);
|
||||
background: color-mix(in srgb, var(--skin-panel) 72%, transparent);
|
||||
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--skin-accent) 18%, transparent);
|
||||
background: color-mix(in srgb, var(--skin-bg) 68%, transparent);
|
||||
}
|
||||
|
||||
.cx-skins-preview-main span {
|
||||
width: 52px;
|
||||
height: 7px;
|
||||
border-radius: 999px;
|
||||
.cx-skins-preview-topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 0 8px;
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--skin-accent) 15%, transparent);
|
||||
background: color-mix(in srgb, var(--skin-panel) 67%, transparent);
|
||||
}
|
||||
|
||||
.cx-skins-preview-topbar span {
|
||||
width: 42px;
|
||||
height: 4px;
|
||||
border-radius: 3px;
|
||||
background: color-mix(in srgb, var(--skin-text) 55%, transparent);
|
||||
}
|
||||
|
||||
.cx-skins-preview-topbar i {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
border-radius: 4px;
|
||||
background: var(--skin-accent);
|
||||
}
|
||||
|
||||
.cx-skins-preview-main strong {
|
||||
width: min(220px, 82%);
|
||||
height: 18px;
|
||||
border-radius: 5px;
|
||||
background: color-mix(in srgb, var(--skin-text) 86%, transparent);
|
||||
}
|
||||
|
||||
.cx-skins-preview-main p {
|
||||
width: min(260px, 92%);
|
||||
height: 9px;
|
||||
margin: 0;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--skin-muted) 74%, transparent);
|
||||
}
|
||||
|
||||
.cx-skins-preview-main div {
|
||||
.cx-skins-preview-chat {
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
gap: 7px;
|
||||
margin-top: 2px;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 7px 7px 5px;
|
||||
}
|
||||
|
||||
.cx-skins-preview-main i {
|
||||
width: 34px;
|
||||
height: 18px;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--skin-secondary) 58%, transparent);
|
||||
.cx-skins-preview-message {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.cx-skins-preview-main i:nth-child(2) {
|
||||
background: color-mix(in srgb, var(--skin-highlight) 55%, transparent);
|
||||
.cx-skins-preview-message i {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
flex: 0 0 auto;
|
||||
border-radius: 3px;
|
||||
background: var(--skin-secondary);
|
||||
}
|
||||
|
||||
.cx-skins-preview-main i:nth-child(3) {
|
||||
background: color-mix(in srgb, var(--skin-accent) 62%, transparent);
|
||||
.cx-skins-preview-message span {
|
||||
width: 68%;
|
||||
height: 17px;
|
||||
border-radius: 4px;
|
||||
background: color-mix(in srgb, var(--skin-panel) 78%, transparent);
|
||||
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--skin-accent) 12%, transparent);
|
||||
}
|
||||
|
||||
.cx-skins-preview-message--user {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.cx-skins-preview-message--user span {
|
||||
width: 54%;
|
||||
background: color-mix(in srgb, var(--skin-accent) 28%, var(--skin-panel));
|
||||
}
|
||||
|
||||
.cx-skins-preview-composer {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin: 0 8px 8px;
|
||||
padding: 0 5px 0 8px;
|
||||
border: 1px solid color-mix(in srgb, var(--skin-accent) 32%, transparent);
|
||||
border-radius: 5px;
|
||||
background: color-mix(in srgb, var(--skin-panel) 84%, transparent);
|
||||
box-shadow: 0 4px 12px rgba(31, 25, 36, 0.1);
|
||||
}
|
||||
|
||||
.cx-skins-preview-composer span {
|
||||
width: 54%;
|
||||
height: 4px;
|
||||
border-radius: 3px;
|
||||
background: color-mix(in srgb, var(--skin-muted) 56%, transparent);
|
||||
}
|
||||
|
||||
.cx-skins-preview-composer i {
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
margin-left: auto;
|
||||
border-radius: 4px;
|
||||
background: var(--skin-accent);
|
||||
}
|
||||
|
||||
.cx-skins-card-body {
|
||||
display: grid;
|
||||
gap: 9px;
|
||||
padding: 14px 15px 12px;
|
||||
gap: 6px;
|
||||
padding: 10px 11px 11px;
|
||||
}
|
||||
|
||||
.cx-skins-card-title {
|
||||
@@ -265,7 +467,7 @@
|
||||
.cx-skins-card-title strong {
|
||||
overflow: hidden;
|
||||
color: #1f232a;
|
||||
font-size: 15px;
|
||||
font-size: 14px;
|
||||
font-weight: 740;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
@@ -282,34 +484,108 @@
|
||||
|
||||
.cx-skins-card-body p {
|
||||
min-height: 18px;
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
margin: 0;
|
||||
color: #4d5562;
|
||||
font-size: 12px;
|
||||
font-weight: 620;
|
||||
line-height: 1.5;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 1;
|
||||
}
|
||||
|
||||
.cx-skins-swatches {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
padding: 0 15px 13px;
|
||||
}
|
||||
|
||||
.cx-skins-swatches span {
|
||||
width: 24px;
|
||||
height: 18px;
|
||||
border: 1px solid rgba(20, 23, 28, 0.08);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.cx-skins-card-actions {
|
||||
display: flex;
|
||||
.cx-skin-editor-title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding: 12px 15px;
|
||||
border-top: 1px solid #edf0f3;
|
||||
background: #fafbfc;
|
||||
}
|
||||
|
||||
.cx-skin-editor-body {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.cx-skin-editor-field {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.cx-skin-editor-field > span {
|
||||
color: var(--ui-text-muted);
|
||||
font-size: 12px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.cx-skin-editor-field input:not([type="range"]),
|
||||
.cx-skin-editor-field textarea {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
border: 1px solid var(--ui-border);
|
||||
border-radius: 7px;
|
||||
outline: none;
|
||||
color: var(--ui-text);
|
||||
background: var(--ui-surface-elevated);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
transition: border-color 140ms ease, box-shadow 140ms ease, background-color 140ms ease;
|
||||
}
|
||||
|
||||
.cx-skin-editor-field input:not([type="range"]) {
|
||||
height: 40px;
|
||||
padding: 0 11px;
|
||||
}
|
||||
|
||||
.cx-skin-editor-field textarea {
|
||||
min-height: 104px;
|
||||
padding: 10px 11px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.cx-skin-editor-field input:not([type="range"]):focus,
|
||||
.cx-skin-editor-field textarea:focus {
|
||||
border-color: color-mix(in srgb, var(--ui-accent) 58%, var(--ui-border));
|
||||
background: var(--ui-surface);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--ui-accent) 12%, transparent);
|
||||
}
|
||||
|
||||
.cx-skin-editor-field input:not([type="range"])::placeholder,
|
||||
.cx-skin-editor-field textarea::placeholder {
|
||||
color: var(--ui-text-subtle);
|
||||
}
|
||||
|
||||
.cx-skin-editor-range {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 54px;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
.cx-skin-editor-range input {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
accent-color: var(--ui-accent);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cx-skin-editor-range input:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.58;
|
||||
}
|
||||
|
||||
.cx-skin-editor-range output {
|
||||
min-width: 54px;
|
||||
padding: 5px 8px;
|
||||
border: 1px solid var(--ui-border);
|
||||
border-radius: 7px;
|
||||
color: var(--ui-text);
|
||||
background: var(--ui-surface-muted);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.cx-skins-empty {
|
||||
@@ -341,6 +617,32 @@
|
||||
}
|
||||
|
||||
.cx-skins-status {
|
||||
grid-template-columns: 1fr;
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
padding: 8px 14px;
|
||||
}
|
||||
|
||||
.cx-skins-status > div,
|
||||
.cx-skins-status > div:first-child {
|
||||
padding: 6px 0;
|
||||
}
|
||||
|
||||
.cx-skins-status > div + div {
|
||||
border-top: 1px solid var(--cx-skins-line);
|
||||
border-left: 0;
|
||||
}
|
||||
|
||||
.cx-skins-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(230px, 280px));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 440px) {
|
||||
.cx-skins-grid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.cx-skins-preview-app {
|
||||
width: 68%;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -243,22 +243,42 @@ export type SkinThemeSummary = {
|
||||
tagline: string;
|
||||
quote: string;
|
||||
image: string;
|
||||
imagePath: string;
|
||||
source: "builtin" | "imported" | string;
|
||||
enabled: boolean;
|
||||
directory: string;
|
||||
adaptive: boolean;
|
||||
surfaceOpacity: number;
|
||||
art: {
|
||||
focusX?: number | null;
|
||||
focusY?: number | null;
|
||||
safeArea?: "auto" | "left" | "right" | "center" | "none" | string | null;
|
||||
taskMode?: "auto" | "ambient" | "banner" | "off" | string | null;
|
||||
};
|
||||
colors: SkinThemeColors;
|
||||
};
|
||||
|
||||
export type SkinRuntimeStatus = {
|
||||
supported: boolean;
|
||||
active: boolean;
|
||||
phase: "unsupported" | "inactive" | "active" | "paused" | "stale" | "unavailable" | "error" | string;
|
||||
port?: number | null;
|
||||
themeId?: string | null;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type SkinCenterState = {
|
||||
skinsDir: string;
|
||||
currentThemeId?: string | null;
|
||||
currentThemePath?: string | null;
|
||||
themes: SkinThemeSummary[];
|
||||
runtime: SkinRuntimeStatus;
|
||||
};
|
||||
|
||||
export type SkinActionResult = {
|
||||
message: string;
|
||||
state: SkinCenterState;
|
||||
restartRequired: boolean;
|
||||
};
|
||||
|
||||
export type SkinExportResult = {
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "codex-x-workspace",
|
||||
"private": true,
|
||||
"version": "0.3.0",
|
||||
"version": "0.3.1",
|
||||
"scripts": {
|
||||
"dev": "pnpm --dir apps/desktop tauri dev",
|
||||
"build": "pnpm --dir apps/desktop tauri build",
|
||||
|
||||
Generated
+10
@@ -13,6 +13,9 @@ importers:
|
||||
'@tauri-apps/api':
|
||||
specifier: ^2.8.0
|
||||
version: 2.11.1
|
||||
'@tauri-apps/plugin-dialog':
|
||||
specifier: ^2.7.2
|
||||
version: 2.7.2
|
||||
'@tauri-apps/plugin-process':
|
||||
specifier: ^2.3.1
|
||||
version: 2.3.1
|
||||
@@ -525,6 +528,9 @@ packages:
|
||||
engines: {node: '>= 10'}
|
||||
hasBin: true
|
||||
|
||||
'@tauri-apps/plugin-dialog@2.7.2':
|
||||
resolution: {integrity: sha512-pX0IGm1I3I6wc+zeKYcq1GSqogK6okCNX5fOdaNU5ab1AjGS6l1E5wFNjEb7meg7ZFSp0JUs+0jQGQNyOvLrsg==}
|
||||
|
||||
'@tauri-apps/plugin-process@2.3.1':
|
||||
resolution: {integrity: sha512-nCa4fGVaDL/B9ai03VyPOjfAHRHSBz5v6F/ObsB73r/dA3MHHhZtldaDMIc0V/pnUw9ehzr2iEG+XkSEyC0JJA==}
|
||||
|
||||
@@ -1093,6 +1099,10 @@ snapshots:
|
||||
'@tauri-apps/cli-win32-ia32-msvc': 2.11.4
|
||||
'@tauri-apps/cli-win32-x64-msvc': 2.11.4
|
||||
|
||||
'@tauri-apps/plugin-dialog@2.7.2':
|
||||
dependencies:
|
||||
'@tauri-apps/api': 2.11.1
|
||||
|
||||
'@tauri-apps/plugin-process@2.3.1':
|
||||
dependencies:
|
||||
'@tauri-apps/api': 2.11.1
|
||||
|
||||
Reference in New Issue
Block a user