Compare commits

..
15 Commits
112 changed files with 25243 additions and 14451 deletions
+1 -30
View File
@@ -221,34 +221,9 @@ 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
run: cargo test --manifest-path apps/desktop/src-tauri/Cargo.toml --lib --locked -- --test-threads=1
- name: Import Apple signing certificate and notarization key
if: matrix.macos
@@ -380,9 +355,6 @@ 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
@@ -390,7 +362,6 @@ 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"
+107
View File
@@ -2,6 +2,113 @@
All notable changes to Codex-X will be documented here.
## [v0.3.16] - 2026-09-09
### 新增与调整
- 官方登录与第三方供应商统一为一键复制:副本立即加入列表,不进入编辑页,不弹出二次确认;自动添加不重复的副本名称。
- 官方与第三方配置的 TOML 编辑区新增“开启 1M 上下文窗口”复选框,保留自定义压缩阈值及其他配置。
- 设置新增“通用设置 / 用量统计”标签页,并复用统一的淡入淡出动画;用量统计展示本地 Codex Token 总量、输入与输出、缓存命中率、每日趋势、模型分布和最近 10 个会话,支持时间范围、模型筛选与手动刷新。最近会话显示聊天标题,无标题时使用项目名。
- 用量统计按主会话归并子代理及其后代的实际用量,子代理不再单独列入最近会话或增加活跃会话数;用户主动分叉的新聊天保持独立。
- 支持添加、复制和重命名多个官方 Codex 登录配置,可与第三方 API 供应商一起管理和切换;各配置独立保存认证与完整 TOML,切换前保留当前账号最新登录状态。
- 指令提示词页面顶部标题的强调色统一为蓝色,适配浅色与深色主题。
### 修复 Bug
- 修复 GitHub 模板同步期间查看、编辑或启用提示词可能拖住供应商操作的问题;模板内容优先读取本地修改、缓存和内置版本,模板详情加载不再占用其他页面的操作状态。
- 修复首次识别到的 CC Switch 当前供应商在切换后从列表消失的问题;切换前保存完整配置与认证信息,并处理同名记录、重复配置和失败回滚。
## [v0.3.15] - 2026-08-20
### 修复 Bug
- 修复 Windows 重启 Codex 时仅隐藏到托盘、后台未退出,以及再次重启报错的问题。
## [v0.3.14] - 2026-08-20
### 更新
- MCP 和 Skills 支持添加自定义备注。
- 供应商支持复制,同一 API 平台可保存多个模型或相同配置,复制项会添加到列表末尾。
- 供应商列表不再显示重复的模型信息。
- 设置页新增“重启 Codex”按钮,支持 macOS 和 Windows。
### 修复 Bug
- 修复相同供应商配置被覆盖,以及复制当前供应商后启用状态丢失的问题。
- 修复子代理会话被误判为“待同步”的问题。
## [v0.3.13] - 2026-08-18
### 修复 Bug
- 修复归档会话被错误标记为“待同步”并参与供应商同步的问题;归档会话现在会保持归档状态,不再作为同步候选。
## [v0.3.12] - 2026-08-11
### 更新
- 优化了软件的流畅度和部分 bug。
## [v0.3.11] - 2026-08-11
### 更新
- 优化了软件的流畅度和部分 bug。
## [v0.3.10] - 2026-08-11
### 更新
- 优化了软件的流畅度和部分 bug。
## [v0.3.9] - 2026-08-11
### 更新
- 优化了软件的流畅度和部分 bug。
## [v0.3.8] - 2026-08-03
### 修复 Bug
- 修复了某些问题。
## [v0.3.7] - 2026-08-02
### 修复 Bug
- 修复了一些 bug。
## [v0.3.6] - 2026-07-31
### 更新
- 供应商与 OpenAI Official 切换改为热更新;切换后新建会话即可生效,不再要求重启 Codex 客户端。
- macOS 关闭主窗口后进入顶部栏驻留模式,Windows 隐藏任务栏窗口;托盘菜单仍可恢复窗口或明确退出应用。
- 新增独立的项目维护日志,记录配置写入、供应商切换、会话同步和桌面生命周期的长期约束与验证依据。
### 修复 Bug
- 修复官方认证被第三方供应商覆盖或清除的问题;第三方密钥仅写入当前 provider,官方 ChatGPT 登录与官方 API Key 均使用独立快照保存并可往返恢复。
- 修复编辑活动供应商会新建副本、重复导入 cc-switch 供应商产生多条相同记录,以及旧版 cc-switch 数据库缺少 `category` 字段时无法读取官方认证的问题。
- 修复供应商、提示词及其他配置操作并发写入时可能覆盖或破坏 `config.toml` 的问题;写入现在使用跨进程锁、原始快照、条件原子替换和失败回滚。
- 修复会话同步显示成功但 Codex 未实际使用同步结果的问题;只处理活动 SQLite,严格校验 SQLite 指定的 rollout 路径、文件身份和线程 ID,并兼容 `codex-dev.db` 与显式 `sqlite_home` 下的自定义数据库名。
- 修复会话列表将旧数据库或孤立 JSONL 计入活动会话、把 JSONL 记录行数误当会话数,以及同步过程中修改非活动数据库或工作目录字段的问题。
## [v0.3.5] - 2026-07-28
### 更新
- GitHub 指令提示词现在可以直接查看和编辑本地内容;模板名称和文件名继续跟随在线目录,Markdown 内容由用户在现有提示词编辑页中维护。
- 模板内容实际修改后会标记为“本地已修改”,后续同步永久跳过该模板的远端内容,同时继续发现和下载 GitHub 新增模板;启用时优先使用用户的本地版本。
- 仅查看或保存未变化的内容不会退出 GitHub 同步;本地修改在下次启用该模板时生效。
### 修复 Bug
- 修复 jsDelivr `@main` 目录缓存旧版本时,“同步 GitHub 模板”只能看到 5 个模板、无法发现仓库中 11 个模板的问题;目录成员现在始终由 GitHub 源站确认,源站不可用时仍保留 CDN 和本地缓存回退。
- 修复当前使用中转供应商且 live `auth.json` 不存在时,概览页错误显示“认证文件未找到”的问题;已保存的官方认证快照现在会显示为“官方认证已保存”。
## [v0.3.4] - 2026-07-28
### 更新
+9 -16
View File
@@ -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, apply macOS / Windows skins, 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, 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" />
@@ -37,9 +37,10 @@ When you use Codex Desktop, the CLI, third-party APIs, and multiple prompts toge
You can use it to:
- Choose from the prompt library or import your own `.md`, then keep the existing prompt or replace it
- Save, test, and switch between OpenAI Official and third-party APIs, including Providers imported from cc-switch
- Manage multiple named official Codex login profiles and third-party APIs, duplicate or switch them with a click, and import Providers from cc-switch
- Search and organize local sessions by project, check / automatically repair problems, and precisely delete sessions you no longer need
- Manage Skills and MCP in one place, and inspect the current `config.toml`, `auth.json`, and operation backups
- Explore token usage by date and model in Settings, with subagent usage attributed to its main conversation
## Preview
@@ -117,7 +118,7 @@ You can use it to:
</tr>
<tr>
<td align="center">⚡ <b>Switch APIs / relays</b></td>
<td align="left">Save, test, and enable multiple third-party Providers, or import them from cc-switch; entries with the same endpoint and Key are merged automatically. Create or reopen a session after switching to apply the change without restarting the entire Codex client.</td>
<td align="left">Save multiple named official Codex login profiles alongside third-party Providers, with one-click duplication and switching. Third-party Providers support connection checks, model discovery / testing, and imports from cc-switch.</td>
</tr>
<tr>
<td align="center">💬 <b>Organize local sessions</b></td>
@@ -129,11 +130,11 @@ You can use it to:
</tr>
<tr>
<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>
<td align="left">Inspect <code>config.toml</code> and <code>auth.json</code> in one place. Both official and third-party profile editors offer an “Enable 1M context window” checkbox beside <code>config.toml>; it applies on save and requires model support. Important writes are backed up automatically.</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>
<td align="center">📊 <b>Track token usage</b></td>
<td align="left">Switch between General and Usage statistics in Settings. Filter local usage by date and model, explore daily trends, cache hit rate, and model distribution, and view the 10 most recent main conversations. Subagent usage is attributed to its main conversation and does not count as a separate session.</td>
</tr>
<tr>
<td align="center">📦 <b>Use it across platforms</b></td>
@@ -242,7 +243,8 @@ A backup is created automatically before every enable or disable action. In addi
> [!NOTE]
> After enabling a new third-party Provider, create or reopen a Codex session to use the new relay. You do not need to restart the entire Codex client.
- Save multiple third-party Providers and always see which one is currently active
- Save multiple named official Codex login profiles and third-party Providers, and always see which one is currently active
- Duplicate a profile immediately with one click, without a confirmation dialog or opening the editor; rename it whenever needed
- Test an API endpoint before switching, and save or enable a configuration separately
- Edit the Base URL, API Key, Model, Wire API, and complete TOML on the same page
- cc-switch imports report added, updated, merged, and skipped entries; the same URL + Key is no longer shown more than once
@@ -405,15 +407,6 @@ pnpm --dir apps/desktop tauri build
## 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.
- Best option: sign and notarize with an Apple Developer ID
+9 -16
View File
@@ -10,7 +10,7 @@
**Codex 可视化提示词注入 · Provider · 会话 · Skills / MCP 管理工具**
一款面向 **OpenAI Codex 桌面端 / Codex CLI** 的跨平台桌面工具。把提示词模板、自定义 Prompt、第三方 API 供应商、会话同步、Skills / MCP、macOS / Windows 皮肤和 TOML 配置都放进可视化界面里,不用反复手改文件。
一款面向 **OpenAI Codex 桌面端 / Codex CLI** 的跨平台桌面工具。把提示词模板、自定义 Prompt、第三方 API 供应商、会话同步、Skills / MCP 和 TOML 配置都放进可视化界面里,不用反复手改文件。
<p>
<img src="https://img.shields.io/github/v/release/yynxxxxx/Codex-X?label=version&color=blue" alt="version" />
@@ -38,9 +38,10 @@
- 像管理插件一样管理提示词:分类、导入 Markdown、自定义编辑、一键启用 / 禁用
- 内置 5 套提示词模板,同时支持用户把自己的提示词变成可视化模板库
- 保存、测试并切换 OpenAI Official 与第三方 API,还能从 cc-switch 导入现有供应商
- 管理多个可命名的官方 Codex 登录与第三方 API,一键复制、切换,并从 cc-switch 导入现有供应商
- 同步、检查、搜索和删除本地会话,按项目路径整理 Codex 历史记录
- 集中管理 Skills 与 MCP,查看当前 `config.toml`、`auth.json` 和操作备份
- 在设置中按日期、模型查看 Token 用量趋势,子代理用量归入所属主会话
## 软件预览
@@ -97,7 +98,7 @@
</tr>
<tr>
<td align="center"><b>Provider / API</b></td>
<td align="left">添加、编辑、启用、删除第三方供应商;支持连接检测、模型获取 / 测试、从 cc-switch 导入,并可在 OpenAI Official 与中转 API 之间切换。</td>
<td align="left">保存多个可命名的官方 Codex 登录配置,与第三方供应商统一管理、一键复制和切换;第三方供应商支持连接检测、模型获取 / 测试及从 cc-switch 导入。</td>
</tr>
<tr>
<td align="center"><b>会话管理</b></td>
@@ -109,11 +110,11 @@
</tr>
<tr>
<td align="center"><b>配置与登录</b></td>
<td align="left">集中查看 Codex 当前使用的 <code>config.toml</code> 与 <code>auth.json</code>,区分官方登录态和第三方 API Key;重要写入前自动备份。</td>
<td align="left">集中查看 <code>config.toml</code> 与 <code>auth.json</code>;官方登录和第三方供应商编辑页均可在 <code>config.toml</code> 旁勾选“开启 1M 上下文窗口”,保存后生效,需模型支持;重要写入前自动备份。</td>
</tr>
<tr>
<td align="center"><b>macOS / Windows 皮肤中心</b></td>
<td align="left">内置“椎名真白·樱花画室”主题,以真实壁纸和完整界面缩略图展示主题效果,支持导入 / 导出、热切换和关闭皮肤;换肤运行时已经内置在 Codex-X 中,无需另装 Codex Dream Skin 引擎。</td>
<td align="center"><b>用量统计</b></td>
<td align="left">设置页提供“通用设置 / 用量统计”标签,按日期与模型筛选本地 Token 用量,查看每日趋势、缓存命中率、模型分布及最近 10 个主会话;子代理用量归入所属主会话,不单独计为会话。</td>
</tr>
<tr>
<td align="center"><b>跨平台使用</b></td>
@@ -234,7 +235,8 @@ Codex-X 现在不只是“几套内置 Prompt”的启动器,而是一个可
> [!NOTE]
> 启用新的第三方供应商后,新建或重新打开 Codex 会话即可使用新的中转,不需要重启整个 Codex 客户端。
- 保存多个第三方供应商,随时查看当前正在使用哪一个
- 保存多个可命名的官方 Codex 登录配置与第三方供应商,随时查看当前正在使用哪一个
- 点击“复制”直接新增独立副本,无需确认或进入编辑页;名称可稍后修改
- 切换前可检测连接,并可获取模型进行测试
- 在同一页面编辑 Base URL、API Key、Model、Wire API 和完整 TOML
- 从 cc-switch 导入时自动区分新增、更新、合并与跳过;相同 URL + Key 不再重复显示
@@ -394,15 +396,6 @@ pnpm --dir apps/desktop tauri build
## 桌面端安装说明
### 皮肤中心安全边界
- 实机换肤支持 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 的正常行为。
- 最佳方式:使用 Apple Developer ID 签名并 notarize
+2 -4
View File
@@ -1,6 +1,6 @@
{
"name": "codex-x",
"version": "0.3.4",
"version": "0.3.16",
"private": true,
"description": "Codex Switch & Instruct desktop manager",
"type": "module",
@@ -10,12 +10,10 @@
"tauri": "tauri",
"dev:renderer": "vite --host 127.0.0.1 --port 1420",
"build:renderer": "vite build",
"typecheck": "tsc --noEmit",
"test:skin-runtime": "node src-tauri/resources/skin-runtime/tests/run-tests.mjs"
"typecheck": "tsc --noEmit"
},
"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",
+412 -75
View File
@@ -68,6 +68,137 @@ dependencies = [
"derive_arbitrary",
]
[[package]]
name = "async-broadcast"
version = "0.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532"
dependencies = [
"event-listener",
"event-listener-strategy",
"futures-core",
"pin-project-lite",
]
[[package]]
name = "async-channel"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2"
dependencies = [
"concurrent-queue",
"event-listener-strategy",
"futures-core",
"pin-project-lite",
]
[[package]]
name = "async-executor"
version = "1.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a"
dependencies = [
"async-task",
"concurrent-queue",
"fastrand",
"futures-lite",
"pin-project-lite",
"slab",
]
[[package]]
name = "async-io"
version = "2.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc"
dependencies = [
"autocfg",
"cfg-if",
"concurrent-queue",
"futures-io",
"futures-lite",
"parking",
"polling",
"rustix",
"slab",
"windows-sys 0.61.2",
]
[[package]]
name = "async-lock"
version = "3.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311"
dependencies = [
"event-listener",
"event-listener-strategy",
"pin-project-lite",
]
[[package]]
name = "async-process"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75"
dependencies = [
"async-channel",
"async-io",
"async-lock",
"async-signal",
"async-task",
"blocking",
"cfg-if",
"event-listener",
"futures-lite",
"rustix",
]
[[package]]
name = "async-recursion"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.118",
]
[[package]]
name = "async-signal"
version = "0.2.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485"
dependencies = [
"async-io",
"async-lock",
"atomic-waker",
"cfg-if",
"futures-core",
"futures-io",
"rustix",
"signal-hook-registry",
"slab",
"windows-sys 0.61.2",
]
[[package]]
name = "async-task"
version = "4.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de"
[[package]]
name = "async-trait"
version = "0.1.89"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.118",
]
[[package]]
name = "atk"
version = "0.18.2"
@@ -163,6 +294,19 @@ dependencies = [
"objc2",
]
[[package]]
name = "blocking"
version = "1.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21"
dependencies = [
"async-channel",
"async-task",
"futures-io",
"futures-lite",
"piper",
]
[[package]]
name = "brotli"
version = "8.0.4"
@@ -346,7 +490,7 @@ dependencies = [
[[package]]
name = "codex-x"
version = "0.3.4"
version = "0.3.16"
dependencies = [
"chrono",
"dirs 5.0.1",
@@ -360,8 +504,8 @@ dependencies = [
"sha2",
"tauri",
"tauri-build",
"tauri-plugin-dialog",
"tauri-plugin-process",
"tauri-plugin-single-instance",
"tauri-plugin-updater",
"thiserror 2.0.18",
"toml_edit 0.22.27",
@@ -379,6 +523,15 @@ dependencies = [
"memchr",
]
[[package]]
name = "concurrent-queue"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "cookie"
version = "0.18.1"
@@ -791,6 +944,33 @@ version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7"
[[package]]
name = "endi"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"
[[package]]
name = "enumflags2"
version = "0.7.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef"
dependencies = [
"enumflags2_derive",
"serde",
]
[[package]]
name = "enumflags2_derive"
version = "0.7.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.118",
]
[[package]]
name = "equivalent"
version = "1.0.2"
@@ -818,6 +998,26 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "event-listener"
version = "5.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2"
dependencies = [
"parking",
"pin-project-lite",
]
[[package]]
name = "event-listener-strategy"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"
dependencies = [
"event-listener",
"pin-project-lite",
]
[[package]]
name = "fallible-iterator"
version = "0.3.0"
@@ -962,6 +1162,19 @@ version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
[[package]]
name = "futures-lite"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad"
dependencies = [
"fastrand",
"futures-core",
"futures-io",
"parking",
"pin-project-lite",
]
[[package]]
name = "futures-macro"
version = "0.3.32"
@@ -1334,6 +1547,12 @@ version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "hermit-abi"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
[[package]]
name = "hex"
version = "0.4.3"
@@ -1383,12 +1602,6 @@ 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"
@@ -2252,6 +2465,16 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
[[package]]
name = "ordered-stream"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50"
dependencies = [
"futures-core",
"pin-project-lite",
]
[[package]]
name = "osakit"
version = "0.3.1"
@@ -2291,6 +2514,12 @@ dependencies = [
"system-deps",
]
[[package]]
name = "parking"
version = "2.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
[[package]]
name = "parking_lot"
version = "0.12.5"
@@ -2379,6 +2608,17 @@ version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "piper"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1"
dependencies = [
"atomic-waker",
"fastrand",
"futures-io",
]
[[package]]
name = "pkg-config"
version = "0.3.33"
@@ -2424,6 +2664,20 @@ dependencies = [
"miniz_oxide",
]
[[package]]
name = "polling"
version = "3.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218"
dependencies = [
"cfg-if",
"concurrent-queue",
"hermit-abi",
"pin-project-lite",
"rustix",
"windows-sys 0.61.2",
]
[[package]]
name = "potential_utf"
version = "0.1.5"
@@ -2662,30 +2916,6 @@ 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"
@@ -3124,6 +3354,16 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
[[package]]
name = "signal-hook-registry"
version = "1.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
dependencies = [
"errno",
"libc",
]
[[package]]
name = "simd-adler32"
version = "0.3.9"
@@ -3435,7 +3675,6 @@ dependencies = [
"gtk",
"heck 0.5.0",
"http",
"http-range",
"jni 0.21.1",
"libc",
"log",
@@ -3548,48 +3787,6 @@ 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"
@@ -3600,6 +3797,22 @@ dependencies = [
"tauri-plugin",
]
[[package]]
name = "tauri-plugin-single-instance"
version = "2.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b3214becf9ef5783c0ae99a3bb25adf5353a7a16ebf53e74b909e29205735c6c"
dependencies = [
"serde",
"serde_json",
"tauri",
"thiserror 2.0.18",
"tokio",
"tracing",
"windows-sys 0.60.2",
"zbus",
]
[[package]]
name = "tauri-plugin-updater"
version = "2.10.1"
@@ -4078,9 +4291,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
dependencies = [
"pin-project-lite",
"tracing-attributes",
"tracing-core",
]
[[package]]
name = "tracing-attributes"
version = "0.1.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.118",
]
[[package]]
name = "tracing-core"
version = "0.1.36"
@@ -4130,6 +4355,17 @@ version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]]
name = "uds_windows"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
dependencies = [
"memoffset",
"tempfile",
"windows-sys 0.61.2",
]
[[package]]
name = "unic-char-property"
version = "0.9.0"
@@ -5181,6 +5417,67 @@ dependencies = [
"synstructure",
]
[[package]]
name = "zbus"
version = "5.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe18fb60dc696039e738717b76eaea21e7a4489bbb1885020b43c94236d7e98a"
dependencies = [
"async-broadcast",
"async-executor",
"async-io",
"async-lock",
"async-process",
"async-recursion",
"async-task",
"async-trait",
"blocking",
"enumflags2",
"event-listener",
"futures-core",
"futures-lite",
"hex",
"libc",
"ordered-stream",
"rustix",
"serde",
"serde_repr",
"tracing",
"uds_windows",
"uuid",
"windows-sys 0.61.2",
"winnow 1.0.3",
"zbus_macros",
"zbus_names",
"zvariant",
]
[[package]]
name = "zbus_macros"
version = "5.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe96480bed92df2b442a1a30df364e12d08eed03aeb061f2b8dc6afb2be91119"
dependencies = [
"proc-macro-crate 3.5.0",
"proc-macro2",
"quote",
"syn 2.0.118",
"zbus_names",
"zvariant",
"zvariant_utils",
]
[[package]]
name = "zbus_names"
version = "4.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e"
dependencies = [
"serde",
"winnow 1.0.3",
"zvariant",
]
[[package]]
name = "zerocopy"
version = "0.8.52"
@@ -5307,3 +5604,43 @@ dependencies = [
"log",
"simd-adler32",
]
[[package]]
name = "zvariant"
version = "5.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bee2a0bcd2a907786a456fff45aaaaf54c9ba5f50b71ae9ec1a4edd200c94911"
dependencies = [
"endi",
"enumflags2",
"serde",
"winnow 1.0.3",
"zvariant_derive",
"zvariant_utils",
]
[[package]]
name = "zvariant_derive"
version = "5.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38a708216a18780796770bfe3f4739c7c83a3e8f789b755534bbbc06e4e23e12"
dependencies = [
"proc-macro-crate 3.5.0",
"proc-macro2",
"quote",
"syn 2.0.118",
"zvariant_utils",
]
[[package]]
name = "zvariant_utils"
version = "3.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90cb9383f9b45290407a1258b202d3f8f01db719eb60b4e4055c6375af4fc7c7"
dependencies = [
"proc-macro2",
"quote",
"serde",
"syn 2.0.118",
"winnow 1.0.3",
]
+3 -6
View File
@@ -1,6 +1,6 @@
[package]
name = "codex-x"
version = "0.3.4"
version = "0.3.16"
description = "Codex Switch & Instruct desktop manager"
authors = ["yynxxxxx"]
license = "MIT"
@@ -11,14 +11,11 @@ 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 = ["protocol-asset"] }
tauri = { version = "2.8.2", features = ["tray-icon"] }
tauri-plugin-process = "2.3.1"
tauri-plugin-updater = "2.10.1"
serde = { version = "1.0", features = ["derive"] }
@@ -40,4 +37,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"
tauri-plugin-single-instance = "=2.4.3"
@@ -6,7 +6,6 @@
"permissions": [
"core:default",
"core:window:allow-start-dragging",
"dialog:allow-save",
"updater:allow-check",
"updater:allow-download",
"updater:allow-install",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

@@ -1,32 +0,0 @@
{
"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)"
}
}
@@ -1,21 +0,0 @@
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.
@@ -1,22 +0,0 @@
# 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
@@ -1,796 +0,0 @@
((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__)
@@ -1,131 +0,0 @@
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;
}
@@ -1,942 +0,0 @@
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.4";
const LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "[::1]"]);
const CDP_ID_PATTERN = /^[A-Za-z0-9._-]{1,200}$/;
const MAX_ART_BYTES = 16 * 1024 * 1024;
const CODEX_SURFACE_SELECTORS = Object.freeze({
shell: "main.main-surface",
sidebar: "aside.app-shell-left-panel",
composer: ".composer-surface-chrome",
home: '[role="main"]:has([data-testid="home-icon"])',
appearance: 'input[name="appearance-theme"]',
themePreview: '[data-testid="theme-preview"]',
});
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);
}
}
export async function probeSession(session) {
return session.evaluate(`(() => {
const markers = {
shell: Boolean(document.querySelector(${JSON.stringify(CODEX_SURFACE_SELECTORS.shell)})),
sidebar: Boolean(document.querySelector(${JSON.stringify(CODEX_SURFACE_SELECTORS.sidebar)})),
composer: Boolean(document.querySelector(${JSON.stringify(CODEX_SURFACE_SELECTORS.composer)})),
main: Boolean(document.querySelector(${JSON.stringify(CODEX_SURFACE_SELECTORS.home)})),
settings: Boolean(document.querySelector(${JSON.stringify(CODEX_SURFACE_SELECTORS.appearance)})) ||
Boolean(document.querySelector(${JSON.stringify(CODEX_SURFACE_SELECTORS.themePreview)})),
};
return {
title: document.title,
href: location.href,
markers,
codex: location.protocol === 'app:' &&
((markers.shell && markers.sidebar) || markers.main || markers.settings),
};
})()`);
}
export async function waitForCodexProbe(session, timeoutMs = 1800) {
const deadline = Date.now() + timeoutMs;
let probe = null;
while (Date.now() < deadline) {
try {
probe = await probeSession(session);
if (probe?.codex) return probe;
} catch {
// Chromium can briefly replace the execution context while Codex boots.
}
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 remainingMs = Math.max(50, deadline - Date.now());
const probe = await waitForCodexProbe(session, Math.min(1800, remainingMs));
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 surface 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__
)()`);
}
export 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 settingsAnchor = document.querySelector(${JSON.stringify(CODEX_SURFACE_SELECTORS.appearance)}) ||
document.querySelector(${JSON.stringify(CODEX_SURFACE_SELECTORS.themePreview)});
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),
homeRouteBox: box(homeRoute),
homePresent: Boolean(home),
settingsRoute: Boolean(settingsAnchor),
settingsAnchor: box(settingsAnchor),
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 standardSurface = Boolean(result.shell?.visible && result.sidebar?.visible);
const routeSurface = Boolean(result.homeRouteBox?.visible || result.settingsAnchor?.visible);
const chromePass = result.settingsRoute ||
(result.chromePresent && result.chromePointerEvents === 'none');
const basePass = result.installed && result.version === ${JSON.stringify(SKIN_VERSION)} &&
result.stylePresent && chromePass && (standardSurface || routeSurface) &&
!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;
if (location.protocol !== "app:") return false;
const shell = document.querySelector(${JSON.stringify(CODEX_SURFACE_SELECTORS.shell)});
const sidebar = document.querySelector(${JSON.stringify(CODEX_SURFACE_SELECTORS.sidebar)});
const home = document.querySelector(${JSON.stringify(CODEX_SURFACE_SELECTORS.home)});
const settings = document.querySelector(${JSON.stringify(CODEX_SURFACE_SELECTORS.appearance)}) ||
document.querySelector(${JSON.stringify(CODEX_SURFACE_SELECTORS.themePreview)});
if (!(shell && sidebar) && !home && !settings) 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);
}
}
}
@@ -1,116 +0,0 @@
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.

Before

Width:  |  Height:  |  Size: 117 KiB

@@ -1,92 +0,0 @@
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.");
@@ -1,228 +0,0 @@
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,
probeSession,
verifySession,
waitForCodexProbe,
} 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(protocol = "app:") {
const observers = [];
const timers = new Map();
let nextTimer = 1;
const markers = { shell: false, sidebar: false, home: false, settings: false, themePreview: false };
const context = {
window: { installs: [] },
location: { protocol },
document: {
documentElement: {},
querySelector(selector) {
if (selector === "main.main-surface") return markers.shell ? {} : null;
if (selector === "aside.app-shell-left-panel") return markers.sidebar ? {} : null;
if (selector === '[role="main"]:has([data-testid="home-icon"])') return markers.home ? {} : null;
if (selector === 'input[name="appearance-theme"]') return markers.settings ? {} : null;
if (selector === '[data-testid="theme-preview"]') return markers.themePreview ? {} : 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 home = createFixture();
home.markers.home = true;
vm.runInNewContext(earlyPayloadFor('window.installs.push("home")', "home"), home.context);
assert.deepEqual(home.context.window.installs, ["home"], "The explicit Codex home route must support early injection.");
const settings = createFixture();
settings.markers.settings = true;
vm.runInNewContext(earlyPayloadFor('window.installs.push("settings")', "settings"), settings.context);
assert.deepEqual(
settings.context.window.installs,
["settings"],
"The Codex appearance route must support early injection.",
);
const web = createFixture("https:");
web.markers.shell = true;
web.markers.sidebar = true;
vm.runInNewContext(earlyPayloadFor('window.installs.push("web")', "web"), web.context);
assert.deepEqual(web.context.window.installs, [], "Web pages must remain untouched even with spoofed shell markers.");
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");
function createProbeSession(frames) {
let callCount = 0;
return {
get callCount() { return callCount; },
async evaluate(expression) {
const frame = frames[Math.min(callCount, frames.length - 1)];
callCount += 1;
if (frame instanceof Error) throw frame;
const marker = (key) => frame[key] ? {} : null;
return vm.runInNewContext(expression, {
location: { protocol: frame.protocol ?? "app:", href: `${frame.protocol ?? "app:"}//codex` },
document: {
title: "Codex",
querySelector(selector) {
if (selector === "main.main-surface") return marker("shell");
if (selector === "aside.app-shell-left-panel") return marker("sidebar");
if (selector === ".composer-surface-chrome") return marker("composer");
if (selector === '[role="main"]:has([data-testid="home-icon"])') return marker("home");
if (selector === 'input[name="appearance-theme"]') return marker("settings");
if (selector === '[data-testid="theme-preview"]') return marker("themePreview");
return null;
},
},
});
},
};
}
assert.equal((await probeSession(createProbeSession([{ shell: true, sidebar: true }]))).codex, true);
assert.equal((await probeSession(createProbeSession([{ home: true }]))).codex, true);
assert.equal((await probeSession(createProbeSession([{ settings: true }]))).codex, true);
assert.equal((await probeSession(createProbeSession([{}]))).codex, false, "An unmarked app page must be rejected.");
assert.equal(
(await probeSession(createProbeSession([{ protocol: "https:", shell: true, sidebar: true }]))).codex,
false,
"A non-app page must be rejected even when it spoofs the standard shell.",
);
const delayed = createProbeSession([
new Error("Execution context was destroyed"),
{},
{ home: true },
]);
assert.equal((await waitForCodexProbe(delayed, 300))?.codex, true);
assert.equal(delayed.callCount, 3, "Probe waiting must survive document replacement and delayed DOM markers.");
function createVerificationSession(route) {
const node = (width = 640, height = 480) => ({
getBoundingClientRect: () => ({ x: 0, y: 0, width, height }),
querySelector: () => null,
});
const body = node();
const homeHero = node(560, 240);
const home = node();
home.firstElementChild = { firstElementChild: { firstElementChild: homeHero } };
const homeIndicator = { closest: () => home };
const settingsAnchor = node(320, 48);
const chrome = node();
return {
async evaluate(expression) {
return vm.runInNewContext(expression, {
innerWidth: 1280,
innerHeight: 800,
window: { __CODEX_DREAM_SKIN_STATE__: { version: "1.2.4" } },
getComputedStyle(target) {
return { display: "block", visibility: "visible", pointerEvents: target === chrome ? "none" : "auto" };
},
document: {
body,
documentElement: {
classList: { contains: () => true },
scrollWidth: 1280,
clientWidth: 1280,
scrollHeight: 800,
clientHeight: 800,
},
getElementById(id) {
if (id === "codex-dream-skin-style") return {};
if (id === "codex-dream-skin-chrome" && route === "home") return chrome;
return null;
},
querySelector(selector) {
if (selector === '[data-testid="home-icon"]' && route === "home") return homeIndicator;
if (selector === '[role="main"].dream-skin-home' && route === "home") return home;
if (selector === 'input[name="appearance-theme"]' && route === "settings") return settingsAnchor;
return null;
},
},
});
},
};
}
assert.equal(
(await verifySession(createVerificationSession("home"))).pass,
true,
"A verified Codex home route must not require the legacy shell/sidebar pair.",
);
assert.equal(
(await verifySession(createVerificationSession("settings"))).pass,
true,
"A verified appearance route must accept the installed root theme before the main shell mounts.",
);
assert.equal(
(await verifySession(createVerificationSession("auxiliary"))).pass,
false,
"An auxiliary app page must not pass post-install verification.",
);
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,
/connectCodexTargets[\s\S]*const probe = await waitForCodexProbe/,
"One-shot discovery must wait for a progressively loaded Codex renderer.",
);
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: renderer probing is route-aware, delayed-load safe, generation-safe, and guarded.");
@@ -1,613 +0,0 @@
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.");
@@ -1,4 +0,0 @@
await import("./image-metadata.test.mjs");
await import("./injector-bootstrap.test.mjs");
await import("./renderer-inject.test.mjs");
await import("./theme-stage.test.mjs");
@@ -1,85 +0,0 @@
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 });
}
@@ -1,186 +0,0 @@
[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
@@ -1,602 +0,0 @@
$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
}
@@ -1,8 +0,0 @@
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.
+406 -9
View File
@@ -3,7 +3,110 @@ use crate::file_io::ensure_directory;
use crate::paths::app_home;
use crate::sqlite_utils::table_column_set;
use rusqlite::Connection;
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock};
use std::time::Duration;
const APP_DB_SCHEMA_VERSION: i64 = 4;
struct DatabaseInitializer {
migration_lock: Mutex<()>,
}
impl DatabaseInitializer {
fn new() -> Self {
Self {
migration_lock: Mutex::new(()),
}
}
fn open_at(&self, path: &Path) -> Result<Connection> {
self.open_at_with(path, initialize_schema)
}
fn open_at_with(
&self,
path: &Path,
initialize: impl FnOnce(&Connection) -> Result<()>,
) -> Result<Connection> {
if let Some(parent) = path.parent() {
ensure_directory(parent)?;
}
let conn = Connection::open(path).map_err(|e| CodexxError::Database(e.to_string()))?;
conn.busy_timeout(Duration::from_secs(5))
.map_err(|e| CodexxError::Database(e.to_string()))?;
if schema_is_current(&conn)? {
return Ok(conn);
}
// Serialize first-time migrations inside this process. The persistent
// schema version also prevents repeated migrations across launches and
// detects a database replaced at the same path.
let _migration_guard = self
.migration_lock
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
if !schema_is_current(&conn)? {
migrate_schema(&conn, initialize)?;
}
Ok(conn)
}
}
fn schema_version(conn: &Connection) -> Result<i64> {
conn.pragma_query_value(None, "user_version", |row| row.get(0))
.map_err(|error| CodexxError::Database(error.to_string()))
}
fn schema_is_current(conn: &Connection) -> Result<bool> {
Ok(schema_version(conn)? >= APP_DB_SCHEMA_VERSION)
}
fn migrate_schema(
conn: &Connection,
initialize: impl FnOnce(&Connection) -> Result<()>,
) -> Result<()> {
conn.execute_batch("BEGIN IMMEDIATE")
.map_err(|error| CodexxError::Database(error.to_string()))?;
let migration = (|| {
// Another Codex-X process may have completed the migration while this
// connection waited for SQLite's write lock.
if !schema_is_current(conn)? {
initialize(conn)?;
conn.pragma_update(None, "user_version", APP_DB_SCHEMA_VERSION)
.map_err(|error| CodexxError::Database(error.to_string()))?;
}
conn.execute_batch("COMMIT")
.map_err(|error| CodexxError::Database(error.to_string()))
})();
match migration {
Ok(()) => Ok(()),
Err(error) => match conn.execute_batch("ROLLBACK") {
Ok(()) => Err(error),
Err(rollback_error) => Err(CodexxError::Database(format!(
"{error}; app database migration rollback failed: {rollback_error}"
))),
},
}
}
fn database_initializer() -> &'static DatabaseInitializer {
static INITIALIZER: OnceLock<DatabaseInitializer> = OnceLock::new();
INITIALIZER.get_or_init(DatabaseInitializer::new)
}
#[cfg(test)]
pub(crate) fn test_db_guard() -> std::sync::MutexGuard<'static, ()> {
use std::sync::{Mutex, OnceLock};
static TEST_DB_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
TEST_DB_LOCK
.get_or_init(|| Mutex::new(()))
.lock()
.expect("test app database lock poisoned")
}
fn db_path() -> Result<PathBuf> {
Ok(app_home()?.join("codexx.db"))
@@ -34,12 +137,7 @@ fn ensure_sqlite_column(
}
}
pub(crate) fn open() -> Result<Connection> {
let path = db_path()?;
if let Some(parent) = path.parent() {
ensure_directory(parent)?;
}
let conn = Connection::open(&path).map_err(|e| CodexxError::Database(e.to_string()))?;
fn initialize_schema(conn: &Connection) -> Result<()> {
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS providers (
id TEXT PRIMARY KEY,
@@ -50,6 +148,8 @@ pub(crate) fn open() -> Result<Connection> {
toml_config TEXT,
wire_api TEXT NOT NULL DEFAULT 'responses',
requires_openai_auth INTEGER NOT NULL DEFAULT 1,
source TEXT NOT NULL DEFAULT 'manual',
source_id TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
@@ -71,6 +171,12 @@ pub(crate) fn open() -> Result<Connection> {
checked_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS builtin_prompt_overrides (
template_id TEXT PRIMARY KEY,
content TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS managed_mcp_servers (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
@@ -87,15 +193,59 @@ pub(crate) fn open() -> Result<Connection> {
content_hash TEXT,
enabled INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS skills_mcp_notes (
codex_dir TEXT NOT NULL,
item_kind TEXT NOT NULL CHECK(item_kind IN ('skill', 'mcp')),
item_id TEXT NOT NULL,
note TEXT NOT NULL,
updated_at TEXT NOT NULL,
PRIMARY KEY(codex_dir, item_kind, item_id)
);
CREATE TABLE IF NOT EXISTS active_provider_selections (
codex_dir TEXT PRIMARY KEY,
provider_id TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS official_profiles (
codex_dir TEXT NOT NULL,
id TEXT NOT NULL,
provider_name TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
PRIMARY KEY(codex_dir, id)
);
CREATE TABLE IF NOT EXISTS official_profile_selections (
codex_dir TEXT PRIMARY KEY,
profile_id TEXT NOT NULL,
updated_at TEXT NOT NULL
);",
)
.map_err(|e| CodexxError::Database(e.to_string()))?;
ensure_sqlite_column(
&conn,
conn,
"providers",
"toml_config",
"ALTER TABLE providers ADD COLUMN toml_config TEXT",
)?;
ensure_sqlite_column(
conn,
"providers",
"source",
"ALTER TABLE providers ADD COLUMN source TEXT NOT NULL DEFAULT 'manual'",
)?;
ensure_sqlite_column(
conn,
"providers",
"source_id",
"ALTER TABLE providers ADD COLUMN source_id TEXT",
)?;
conn.execute_batch(
"CREATE UNIQUE INDEX IF NOT EXISTS idx_providers_source_identity
ON providers(source, source_id)
WHERE source_id IS NOT NULL;",
)
.map_err(|e| CodexxError::Database(e.to_string()))?;
conn.execute(
"DELETE FROM prompts
WHERE id LIKE 'external-%'
@@ -131,5 +281,252 @@ pub(crate) fn open() -> Result<Connection> {
[],
)
.map_err(|e| CodexxError::Database(e.to_string()))?;
Ok(conn)
Ok(())
}
pub(crate) fn open() -> Result<Connection> {
database_initializer().open_at(&db_path()?)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
use std::sync::{Arc, Barrier};
use std::thread;
use std::time::Duration;
fn test_db_path(name: &str) -> PathBuf {
static COUNTER: AtomicU64 = AtomicU64::new(0);
let suffix = COUNTER.fetch_add(1, Ordering::Relaxed);
std::env::temp_dir()
.join(format!(
"codex-x-app-db-{name}-{}-{suffix}",
std::process::id()
))
.join("codexx.db")
}
fn remove_test_db(path: &Path) {
if let Some(parent) = path.parent() {
fs::remove_dir_all(parent).expect("remove app database test directory");
}
}
#[test]
fn repeated_opens_do_not_rerun_legacy_cleanup() {
let path = test_db_path("cleanup-once");
let initializer = DatabaseInitializer::new();
let conn = initializer.open_at(&path).expect("initialize database");
conn.execute_batch(
"INSERT INTO prompts (id, title, filename, content, created_at, updated_at)
VALUES
('kept', 'Kept', 'same.md', 'same', '1', '1'),
('external-duplicate', 'Duplicate', 'same.md', 'same', '2', '2');",
)
.expect("seed a post-migration duplicate");
drop(conn);
let reopened_initializer = DatabaseInitializer::new();
let conn = reopened_initializer
.open_at(&path)
.expect("reopen database in a new process lifecycle");
let count: i64 = conn
.query_row("SELECT COUNT(*) FROM prompts", [], |row| row.get(0))
.expect("count prompts after reopen");
assert_eq!(count, 2, "reopen must not rerun migration cleanup");
drop(conn);
remove_test_db(&path);
}
#[test]
fn database_replaced_at_the_same_path_is_initialized_again() {
let path = test_db_path("replace-database");
let initializer = DatabaseInitializer::new();
let conn = initializer.open_at(&path).expect("initialize database");
assert_eq!(
schema_version(&conn).expect("read schema version"),
APP_DB_SCHEMA_VERSION
);
drop(conn);
fs::remove_file(&path).expect("replace initialized database");
let conn = initializer
.open_at(&path)
.expect("initialize replacement database");
let provider_count: i64 = conn
.query_row("SELECT COUNT(*) FROM providers", [], |row| row.get(0))
.expect("query replacement database schema");
assert_eq!(provider_count, 0);
assert_eq!(
schema_version(&conn).expect("read replacement version"),
APP_DB_SCHEMA_VERSION
);
drop(conn);
remove_test_db(&path);
}
#[test]
fn version_one_database_migrates_new_metadata_tables_without_data_loss() {
let path = test_db_path("skills-mcp-notes-migration");
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).expect("create legacy database directory");
}
let legacy = Connection::open(&path).expect("create legacy database");
legacy
.execute_batch(
"CREATE TABLE managed_skills (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
directory TEXT NOT NULL,
source_path TEXT,
content_hash TEXT,
enabled INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL
);
INSERT INTO managed_skills
(id, name, directory, enabled, updated_at)
VALUES ('legacy-skill', 'Legacy skill', 'legacy-skill', 1, '2026-01-01T00:00:00Z');
PRAGMA user_version = 1;",
)
.expect("seed version one database");
drop(legacy);
let initializer = DatabaseInitializer::new();
let migrated = initializer.open_at(&path).expect("migrate database");
assert_eq!(
schema_version(&migrated).expect("read migrated schema version"),
APP_DB_SCHEMA_VERSION
);
let skill_count: i64 = migrated
.query_row(
"SELECT COUNT(*) FROM managed_skills WHERE id = 'legacy-skill'",
[],
|row| row.get(0),
)
.expect("count preserved skill");
assert_eq!(skill_count, 1);
migrated
.execute(
"INSERT INTO skills_mcp_notes
(codex_dir, item_kind, item_id, note, updated_at)
VALUES
('/tmp/legacy-codex', 'skill', 'legacy-skill', 'my note', '2026-01-02T00:00:00Z')",
[],
)
.expect("write note after migration");
migrated
.execute(
"INSERT INTO active_provider_selections
(codex_dir, provider_id, updated_at)
VALUES
('/tmp/legacy-codex', 'legacy-provider', '2026-01-02T00:00:00Z')",
[],
)
.expect("write active provider selection after migration");
drop(migrated);
remove_test_db(&path);
}
#[test]
fn concurrent_first_opens_initialize_once() {
let path = test_db_path("concurrent-init");
let attempts = Arc::new(AtomicUsize::new(0));
let barrier = Arc::new(Barrier::new(3));
let mut workers = Vec::new();
for _ in 0..2 {
let path = path.clone();
// Separate initializers model independent Codex-X processes; the
// SQLite transaction and persistent version still permit one run.
let initializer = DatabaseInitializer::new();
let attempts = Arc::clone(&attempts);
let barrier = Arc::clone(&barrier);
workers.push(thread::spawn(move || {
barrier.wait();
initializer.open_at_with(&path, |conn| {
attempts.fetch_add(1, Ordering::SeqCst);
thread::sleep(Duration::from_millis(25));
conn.execute_batch("CREATE TABLE initialized_once (id INTEGER PRIMARY KEY);")
.map_err(|error| CodexxError::Database(error.to_string()))
})
}));
}
barrier.wait();
for worker in workers {
drop(
worker
.join()
.expect("join concurrent database opener")
.expect("open database concurrently"),
);
}
assert_eq!(attempts.load(Ordering::SeqCst), 1);
remove_test_db(&path);
}
#[test]
fn failed_initialization_can_retry() {
let path = test_db_path("retry-init");
let initializer = DatabaseInitializer::new();
let attempts = AtomicUsize::new(0);
let error = initializer
.open_at_with(&path, |_| {
attempts.fetch_add(1, Ordering::SeqCst);
Err(CodexxError::Database(
"injected transient initialization failure".to_string(),
))
})
.expect_err("first initialization must fail");
assert!(error
.to_string()
.contains("transient initialization failure"));
let conn = initializer
.open_at_with(&path, |conn| {
attempts.fetch_add(1, Ordering::SeqCst);
conn.execute_batch("CREATE TABLE retry_succeeded (id INTEGER PRIMARY KEY);")
.map_err(|error| CodexxError::Database(error.to_string()))
})
.expect("retry initialization");
drop(conn);
let conn = initializer
.open_at_with(&path, |_| {
attempts.fetch_add(1, Ordering::SeqCst);
Ok(())
})
.expect("open initialized database");
assert_eq!(attempts.load(Ordering::SeqCst), 2);
drop(conn);
remove_test_db(&path);
}
#[test]
fn initialized_read_connection_does_not_need_a_write_lock() {
let path = test_db_path("read-with-writer");
let initializer = DatabaseInitializer::new();
let writer = initializer.open_at(&path).expect("initialize database");
writer
.execute_batch("BEGIN IMMEDIATE")
.expect("hold database write reservation");
let reader = initializer
.open_at(&path)
.expect("open reader while write reservation is held");
let count: i64 = reader
.query_row("SELECT COUNT(*) FROM providers", [], |row| row.get(0))
.expect("read while another connection holds write reservation");
assert_eq!(count, 0);
drop(reader);
writer
.execute_batch("ROLLBACK")
.expect("release write lock");
drop(writer);
remove_test_db(&path);
}
}
+80 -6
View File
@@ -1,5 +1,5 @@
use crate::constants::AGENTS_FILENAME;
use crate::error::Result;
use crate::error::{CodexxError, Result};
use crate::file_io::{ensure_directory, io_err, write_json};
use crate::paths::app_home;
use crate::prompts::agents_path;
@@ -7,7 +7,7 @@ use crate::{auth_path, config_path};
use chrono::Local;
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::{Path, PathBuf};
use std::path::{Component, Path, PathBuf};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -40,6 +40,84 @@ pub(crate) struct BackupEntry {
had_agents: bool,
}
fn lexical_absolute_path(path: &Path) -> Result<PathBuf> {
let absolute = if path.is_absolute() {
path.to_path_buf()
} else {
std::env::current_dir()
.map_err(|error| io_err(path, error))?
.join(path)
};
let mut normalized = PathBuf::new();
for component in absolute.components() {
match component {
Component::Prefix(prefix) => normalized.push(prefix.as_os_str()),
Component::RootDir => normalized.push(component.as_os_str()),
Component::CurDir => {}
Component::ParentDir => {
normalized.pop();
}
Component::Normal(part) => normalized.push(part),
}
}
Ok(normalized)
}
/// Resolves every existing ancestor while retaining a normalized suffix. This
/// compares missing CODEX_HOME targets without requiring the target to exist.
fn normalized_path_identity(path: &Path) -> Result<PathBuf> {
let absolute = if path.is_absolute() {
path.to_path_buf()
} else {
std::env::current_dir()
.map_err(|error| io_err(path, error))?
.join(path)
};
for ancestor in absolute.ancestors() {
match fs::canonicalize(ancestor) {
Ok(canonical) => {
let suffix = absolute
.strip_prefix(ancestor)
.expect("ancestor must be a path prefix");
return lexical_absolute_path(&canonical.join(suffix));
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => return Err(io_err(ancestor, error)),
}
}
lexical_absolute_path(&absolute)
}
fn same_path_identity(left: &Path, right: &Path) -> Result<bool> {
let left = normalized_path_identity(left)?;
let right = normalized_path_identity(right)?;
#[cfg(target_os = "windows")]
{
let left = left.to_string_lossy().replace('/', "\\");
let right = right.to_string_lossy().replace('/', "\\");
Ok(left.eq_ignore_ascii_case(&right))
}
#[cfg(not(target_os = "windows"))]
{
Ok(left == right)
}
}
pub(crate) fn validate_backup_codex_dir(meta: &BackupMeta, codex_dir: &Path) -> Result<()> {
let recorded = meta.codex_dir.trim();
if recorded.is_empty() {
return Err(CodexxError::Config("备份元数据缺少 CODEX_HOME".to_string()));
}
if same_path_identity(Path::new(recorded), codex_dir)? {
return Ok(());
}
Err(CodexxError::Config(format!(
"备份属于其他 CODEX_HOME,拒绝恢复:备份为 {},当前为 {}",
Path::new(recorded).display(),
codex_dir.display()
)))
}
fn backup_root() -> Result<PathBuf> {
Ok(app_home()?.join("backups"))
}
@@ -138,7 +216,3 @@ pub(crate) fn backups() -> Result<Vec<BackupEntry>> {
entries.sort_by(|a, b| b.created_at.cmp(&a.created_at));
Ok(entries)
}
pub(crate) fn latest_backup() -> Result<Option<BackupEntry>> {
Ok(backups()?.into_iter().next())
}
+26 -9
View File
@@ -1,7 +1,10 @@
use crate::backups::create_backup;
use crate::config_path;
use crate::error::Result;
use crate::file_io::{parse_toml_document, read_to_string_if_exists, write_text};
use crate::file_io::parse_toml_document;
#[cfg(test)]
use crate::live_config::acquire_live_config_lock;
use crate::live_config::{atomic_write_if_unchanged, read_file_snapshot, text_from_snapshot};
use std::path::Path;
use toml_edit::{DocumentMut, Item, Table};
@@ -28,14 +31,18 @@ fn remove_markdown_path(table: &mut Table, key: &str) -> Option<Item> {
/// Repairs prompt paths appended by older Codex-X versions after a `[tui]`
/// header. Only the exact legacy keys with Markdown path values are touched.
#[cfg(test)]
pub(crate) fn migrate_legacy_prompt_config(codex_dir: &Path) -> Result<bool> {
let cfg = config_path(codex_dir);
let text = read_to_string_if_exists(&cfg)?;
let _lock = acquire_live_config_lock(codex_dir)?;
migrate_legacy_prompt_config_locked(codex_dir)
}
pub(crate) fn migrated_legacy_prompt_config_text(cfg: &Path, text: &str) -> Result<Option<String>> {
if text.trim().is_empty() {
return Ok(false);
return Ok(None);
}
let mut doc = parse_toml_document(&cfg, &text)?;
let mut doc = parse_toml_document(cfg, text)?;
let nested_instruction = tui_table(&doc)
.and_then(|tui| tui.get(MODEL_AVAILABILITY_NUX_KEY))
.and_then(|item| item.as_table())
@@ -46,12 +53,10 @@ pub(crate) fn migrate_legacy_prompt_config(codex_dir: &Path) -> Result<bool> {
tui_table(&doc).is_some_and(|tui| markdown_path(tui.get(MODEL_AVAILABILITY_NUX_KEY)));
if !nested_instruction && !tui_instruction && !nux_as_instruction {
return Ok(false);
return Ok(None);
}
let root_instruction_exists = doc.as_table().contains_key(INSTRUCTION_KEY);
create_backup(codex_dir, "migrate-legacy-prompt-config")?;
let removed_nested = doc
.get_mut("tui")
.and_then(|item| item.as_table_mut())
@@ -75,7 +80,19 @@ pub(crate) fn migrate_legacy_prompt_config(codex_dir: &Path) -> Result<bool> {
doc.as_table_mut().insert(INSTRUCTION_KEY, instruction);
}
write_text(&cfg, &doc.to_string())?;
Ok(Some(doc.to_string()))
}
pub(crate) fn migrate_legacy_prompt_config_locked(codex_dir: &Path) -> Result<bool> {
let cfg = config_path(codex_dir);
let original = read_file_snapshot(&cfg)?;
let text = text_from_snapshot(&cfg, original.as_deref())?;
let Some(migrated) = migrated_legacy_prompt_config_text(&cfg, &text)? else {
return Ok(false);
};
create_backup(codex_dir, "migrate-legacy-prompt-config")?;
atomic_write_if_unchanged(&cfg, original.as_deref(), migrated.as_bytes())?;
Ok(true)
}
@@ -0,0 +1,235 @@
use crate::error::{CodexxError, Result};
use serde::{Deserialize, Serialize};
use toml_edit::{value, DocumentMut};
const CONTEXT_WINDOW: &str = "model_context_window";
const COMPACT_TOKEN_LIMIT: &str = "model_auto_compact_token_limit";
const ONE_MILLION: i64 = 1_000_000;
const DEFAULT_COMPACT_LIMIT: i64 = 900_000;
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ContextWindowValues {
pub(crate) context_window: Option<i64>,
pub(crate) compact_token_limit: Option<i64>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ContextWindowConfig {
pub(crate) config_text: String,
pub(crate) enabled: bool,
#[serde(flatten)]
pub(crate) values: ContextWindowValues,
}
fn read_integer(doc: &DocumentMut, key: &str) -> Result<Option<i64>> {
doc.get(key)
.map(|item| {
item.as_integer()
.ok_or_else(|| CodexxError::Config(format!("{key} 必须为整数")))
})
.transpose()
}
fn write_integer(doc: &mut DocumentMut, key: &str, number: i64) {
// Keep existing inline comments and spacing when replacing a setting.
let decor = doc
.get(key)
.and_then(|item| item.as_value())
.map(|value| value.decor().clone());
doc[key] = value(number);
if let Some(decor) = decor {
*doc[key].as_value_mut().expect("integer value").decor_mut() = decor;
}
}
fn restore_integer(doc: &mut DocumentMut, key: &str, number: Option<i64>) {
if let Some(number) = number {
write_integer(doc, key, number);
} else {
doc.as_table_mut().remove(key);
}
}
/// Edits a supplied draft only; this never reads or writes the live configuration.
/// `enabled = None` parses the checkbox state after a manual TOML edit.
pub(crate) fn update_codex_context_window_inner(
config_text: String,
enabled: Option<bool>,
previous_values: Option<ContextWindowValues>,
) -> Result<ContextWindowConfig> {
let mut doc = config_text
.parse::<DocumentMut>()
.map_err(|error| CodexxError::Toml {
path: "config.toml".to_string(),
message: error.to_string(),
})?;
let context_window = read_integer(&doc, CONTEXT_WINDOW)?;
let compact_token_limit = read_integer(&doc, COMPACT_TOKEN_LIMIT)?;
match enabled {
Some(true) => {
write_integer(&mut doc, CONTEXT_WINDOW, ONE_MILLION);
if compact_token_limit.is_none() {
write_integer(&mut doc, COMPACT_TOKEN_LIMIT, DEFAULT_COMPACT_LIMIT);
}
}
Some(false) => {
// Only undo the 1M preset. A manual edit to either value wins.
if context_window == Some(ONE_MILLION) {
restore_integer(
&mut doc,
CONTEXT_WINDOW,
previous_values
.as_ref()
.and_then(|values| values.context_window),
);
}
let expected_compact_limit = previous_values
.as_ref()
.and_then(|values| values.compact_token_limit)
.unwrap_or(DEFAULT_COMPACT_LIMIT);
if compact_token_limit == Some(expected_compact_limit) {
restore_integer(
&mut doc,
COMPACT_TOKEN_LIMIT,
previous_values
.as_ref()
.and_then(|values| values.compact_token_limit),
);
}
}
None => {}
}
let context_window = read_integer(&doc, CONTEXT_WINDOW)?;
let compact_token_limit = read_integer(&doc, COMPACT_TOKEN_LIMIT)?;
Ok(ContextWindowConfig {
config_text: if enabled.is_none() {
config_text
} else {
doc.to_string()
},
enabled: context_window == Some(ONE_MILLION),
values: ContextWindowValues {
context_window,
compact_token_limit,
},
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn enables_at_top_level_and_preserves_unrelated_tables_and_comments() {
let original = "# My Codex settings\nmodel = \"gpt-5.5\" # keep model\n\n[mcp_servers.notes]\ncommand = \"notes\"\nmodel_context_window = 120000\n\n[model_providers.custom]\nbase_url = \"https://api.example.test/v1\"\n";
let result = update_codex_context_window_inner(original.into(), Some(true), None).unwrap();
let doc = result.config_text.parse::<DocumentMut>().unwrap();
assert_eq!(doc[CONTEXT_WINDOW].as_integer(), Some(ONE_MILLION));
assert_eq!(
doc[COMPACT_TOKEN_LIMIT].as_integer(),
Some(DEFAULT_COMPACT_LIMIT)
);
assert_eq!(
doc["mcp_servers"]["notes"][CONTEXT_WINDOW].as_integer(),
Some(120000)
);
assert!(result.config_text.contains("# My Codex settings"));
assert!(result
.config_text
.contains("model = \"gpt-5.5\" # keep model"));
assert!(result
.config_text
.contains("base_url = \"https://api.example.test/v1\""));
}
#[test]
fn parses_underscores_and_quoted_keys_without_reformatting_the_draft() {
let original = "\"model_context_window\" = 1_000_000 # large\nmodel_auto_compact_token_limit = 850_000\n";
let result = update_codex_context_window_inner(original.into(), None, None).unwrap();
assert!(result.enabled);
assert_eq!(result.values.compact_token_limit, Some(850000));
assert_eq!(result.config_text, original);
}
#[test]
fn ignores_context_values_in_tables_or_multiline_strings() {
let original = "instructions = '''\nmodel_context_window = 1000000\n'''\n[profiles.large]\nmodel_context_window = 1000000\n";
let result = update_codex_context_window_inner(original.into(), None, None).unwrap();
assert!(!result.enabled);
assert_eq!(result.values.context_window, None);
}
#[test]
fn preserves_custom_compaction_when_enabling_and_disabling() {
let original = "model_auto_compact_token_limit = 850000 # custom\n";
let enabled = update_codex_context_window_inner(original.into(), Some(true), None).unwrap();
assert_eq!(enabled.values.compact_token_limit, Some(850000));
let disabled =
update_codex_context_window_inner(enabled.config_text, Some(false), None).unwrap();
assert_eq!(disabled.values.context_window, None);
assert_eq!(disabled.config_text, original);
}
#[test]
fn restores_custom_context_and_existing_default_compaction_in_same_edit() {
let original = "model_context_window = 256000 # original\nmodel_auto_compact_token_limit = 900000 # intentional\n";
let parsed = update_codex_context_window_inner(original.into(), None, None).unwrap();
let enabled = update_codex_context_window_inner(original.into(), Some(true), None).unwrap();
let disabled = update_codex_context_window_inner(
enabled.config_text,
Some(false),
Some(parsed.values),
)
.unwrap();
assert_eq!(disabled.config_text, original);
}
#[test]
fn disabling_a_saved_preset_removes_both_default_values() {
let original = "model = \"gpt-5.5\"\n";
let enabled = update_codex_context_window_inner(original.into(), Some(true), None).unwrap();
let disabled =
update_codex_context_window_inner(enabled.config_text, Some(false), None).unwrap();
assert_eq!(disabled.config_text, original);
assert!(!disabled.enabled);
}
#[test]
fn disabling_does_not_undo_new_manual_values() {
let manual = "model_context_window = 500000\nmodel_auto_compact_token_limit = 420000\n";
let result = update_codex_context_window_inner(
manual.into(),
Some(false),
Some(ContextWindowValues {
context_window: Some(256000),
compact_token_limit: Some(200000),
}),
)
.unwrap();
assert_eq!(result.config_text, manual);
}
#[test]
fn rejects_invalid_toml_and_invalid_top_level_field_types() {
for original in [
"[unfinished",
"model_context_window = '1000000'",
"model_auto_compact_token_limit = 'automatic'",
] {
assert!(update_codex_context_window_inner(original.into(), Some(true), None).is_err());
}
}
#[test]
fn a_blank_draft_can_be_enabled_and_returned_to_blank() {
let enabled = update_codex_context_window_inner(String::new(), Some(true), None).unwrap();
assert!(enabled.enabled);
let disabled =
update_codex_context_window_inner(enabled.config_text, Some(false), None).unwrap();
assert!(disabled.config_text.is_empty());
}
}
@@ -0,0 +1,134 @@
use tauri::{
menu::{Menu, MenuItem},
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
Manager, WindowEvent,
};
const MAIN_WINDOW_LABEL: &str = "main";
const TRAY_ID: &str = "codex-x-tray";
const SHOW_WINDOW_MENU_ID: &str = "show-main-window";
const QUIT_APP_MENU_ID: &str = "quit-codex-x";
const SHOW_TRAY_MENU_ON_LEFT_CLICK: bool = cfg!(target_os = "macos");
#[cfg(target_os = "macos")]
fn set_macos_tray_mode(app: &tauri::AppHandle, dock_visible: bool) -> tauri::Result<()> {
let policy = if dock_visible {
tauri::ActivationPolicy::Regular
} else {
tauri::ActivationPolicy::Accessory
};
// Attempt both operations: either one alone can leave a stale Dock entry on some macOS versions.
let dock_result = app.set_dock_visibility(dock_visible);
let policy_result = app.set_activation_policy(policy);
dock_result?;
policy_result
}
fn retain_first_error(first_error: &mut Option<tauri::Error>, result: tauri::Result<()>) {
if let Err(error) = result {
if first_error.is_none() {
*first_error = Some(error);
}
}
}
fn show_main_window(app: &tauri::AppHandle) -> tauri::Result<()> {
let Some(window) = app.get_webview_window(MAIN_WINDOW_LABEL) else {
return Ok(());
};
let mut first_error = None;
#[cfg(target_os = "windows")]
retain_first_error(&mut first_error, window.set_skip_taskbar(false));
#[cfg(target_os = "macos")]
retain_first_error(&mut first_error, app.show());
retain_first_error(&mut first_error, window.unminimize());
retain_first_error(&mut first_error, window.show());
retain_first_error(&mut first_error, window.set_focus());
#[cfg(target_os = "macos")]
retain_first_error(&mut first_error, set_macos_tray_mode(app, true));
first_error.map_or(Ok(()), Err)
}
pub(crate) fn restore_main_window(app: &tauri::AppHandle) {
if let Err(error) = show_main_window(app) {
eprintln!("failed to restore the Codex-X window: {error}");
}
}
pub(crate) fn setup_system_tray(app: &tauri::App) -> tauri::Result<()> {
let show_window =
MenuItem::with_id(app, SHOW_WINDOW_MENU_ID, "显示 Codex-X", true, None::<&str>)?;
let quit_app = MenuItem::with_id(app, QUIT_APP_MENU_ID, "退出 Codex-X", true, None::<&str>)?;
let menu = Menu::with_items(app, &[&show_window, &quit_app])?;
let mut tray = TrayIconBuilder::with_id(TRAY_ID)
.menu(&menu)
.tooltip("Codex-X")
.show_menu_on_left_click(SHOW_TRAY_MENU_ON_LEFT_CLICK)
.on_menu_event(|app, event| match event.id().as_ref() {
SHOW_WINDOW_MENU_ID => restore_main_window(app),
QUIT_APP_MENU_ID => app.exit(0),
_ => {}
})
.on_tray_icon_event(|tray, event| {
if !SHOW_TRAY_MENU_ON_LEFT_CLICK
&& matches!(
event,
TrayIconEvent::Click {
button: MouseButton::Left,
button_state: MouseButtonState::Up,
..
}
)
{
restore_main_window(tray.app_handle());
}
});
if let Some(icon) = app.default_window_icon().cloned() {
tray = tray.icon(icon);
}
tray.build(app)?;
Ok(())
}
pub(crate) fn handle_window_event(window: &tauri::Window, event: &WindowEvent) {
if window.label() != MAIN_WINDOW_LABEL {
return;
}
if let WindowEvent::CloseRequested { api, .. } = event {
api.prevent_close();
if let Err(error) = window.hide() {
eprintln!("failed to hide the Codex-X window: {error}");
return;
}
#[cfg(target_os = "windows")]
if let Err(error) = window.set_skip_taskbar(true) {
eprintln!("failed to remove Codex-X from the taskbar: {error}");
}
#[cfg(target_os = "macos")]
if let Err(error) = set_macos_tray_mode(window.app_handle(), false) {
eprintln!("failed to move Codex-X to the menu bar: {error}");
}
}
}
pub(crate) fn handle_run_event(app: &tauri::AppHandle, event: tauri::RunEvent) {
#[cfg(target_os = "macos")]
if let tauri::RunEvent::Reopen { .. } = event {
restore_main_window(app);
}
#[cfg(not(target_os = "macos"))]
let _ = (app, event);
}
+184 -5
View File
@@ -2,6 +2,7 @@ use crate::error::{CodexxError, Result};
use chrono::Local;
use serde_json::Value;
use std::fs;
use std::fs::OpenOptions;
use std::io::Write;
use std::path::Path;
use toml_edit::DocumentMut;
@@ -107,6 +108,30 @@ pub(crate) fn read_to_string_if_exists(path: &Path) -> Result<String> {
fs::read_to_string(path).map_err(|e| io_err(path, e))
}
#[cfg(test)]
pub(crate) fn harden_sensitive_file_permissions(path: &Path) -> Result<()> {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let metadata = match fs::metadata(path) {
Ok(metadata) => metadata,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(error) => return Err(io_err(path, error)),
};
if !metadata.is_file() || metadata.permissions().mode() & 0o777 == 0o600 {
return Ok(());
}
fs::set_permissions(path, fs::Permissions::from_mode(0o600))
.map_err(|error| io_err(path, error))?;
}
#[cfg(not(unix))]
let _ = path;
Ok(())
}
pub(crate) fn parse_toml_document(path: &Path, text: &str) -> Result<DocumentMut> {
if text.trim().is_empty() {
return Ok(DocumentMut::new());
@@ -117,7 +142,55 @@ pub(crate) fn parse_toml_document(path: &Path, text: &str) -> Result<DocumentMut
})
}
pub(crate) fn atomic_write(path: &Path, bytes: &[u8]) -> Result<()> {
#[cfg(unix)]
fn atomic_write_mode(path: &Path) -> Option<u32> {
use std::os::unix::fs::PermissionsExt;
let sensitive = path
.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| matches!(name, "config.toml" | "auth.json"));
if sensitive {
return Some(0o600);
}
fs::metadata(path)
.ok()
.map(|metadata| metadata.permissions().mode() & 0o777)
}
fn create_atomic_temp(path: &Path, tmp: &Path, private: bool) -> Result<fs::File> {
let mut options = OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
let mode = private.then_some(0o600).or_else(|| atomic_write_mode(path));
if let Some(mode) = mode {
options.mode(mode);
let file = options.open(tmp).map_err(|error| io_err(tmp, error))?;
fs::set_permissions(tmp, fs::Permissions::from_mode(mode))
.map_err(|error| io_err(tmp, error))?;
return Ok(file);
}
}
#[cfg(not(unix))]
let _ = private;
options.open(tmp).map_err(|error| io_err(tmp, error))
}
fn atomic_write_with_privacy_and_check<F>(
path: &Path,
bytes: &[u8],
private: bool,
pre_commit: F,
) -> Result<()>
where
F: FnOnce() -> Result<()>,
{
use std::sync::atomic::{AtomicU64, Ordering};
static WRITE_COUNTER: AtomicU64 = AtomicU64::new(0);
if let Some(parent) = path.parent() {
@@ -129,13 +202,29 @@ pub(crate) fn atomic_write(path: &Path, bytes: &[u8]) -> Result<()> {
Local::now().timestamp_nanos_opt().unwrap_or_default(),
WRITE_COUNTER.fetch_add(1, Ordering::Relaxed),
));
{
let mut file = fs::File::create(&tmp).map_err(|e| io_err(&tmp, e))?;
let result = (|| {
let mut file = create_atomic_temp(path, &tmp, private)?;
file.write_all(bytes).map_err(|e| io_err(&tmp, e))?;
file.sync_all().map_err(|e| io_err(&tmp, e))?;
drop(file);
pre_commit()?;
fs::rename(&tmp, path).map_err(|e| io_err(path, e))
})();
if result.is_err() {
let _ = fs::remove_file(&tmp);
}
fs::rename(&tmp, path).map_err(|e| io_err(path, e))?;
Ok(())
result
}
pub(crate) fn atomic_write(path: &Path, bytes: &[u8]) -> Result<()> {
atomic_write_with_privacy_and_check(path, bytes, false, || Ok(()))
}
pub(crate) fn atomic_write_checked<F>(path: &Path, bytes: &[u8], pre_commit: F) -> Result<()>
where
F: FnOnce() -> Result<()>,
{
atomic_write_with_privacy_and_check(path, bytes, false, pre_commit)
}
pub(crate) fn write_text(path: &Path, text: &str) -> Result<()> {
@@ -147,6 +236,11 @@ pub(crate) fn write_json(path: &Path, value: &Value) -> Result<()> {
write_text(path, &(text + "\n"))
}
pub(crate) fn write_private_json(path: &Path, value: &Value) -> Result<()> {
let text = serde_json::to_string_pretty(value).map_err(|e| json_err(path, e))?;
atomic_write_with_privacy_and_check(path, (text + "\n").as_bytes(), true, || Ok(()))
}
#[cfg(test)]
mod tests {
use super::*;
@@ -182,6 +276,91 @@ mod tests {
fs::remove_dir_all(dir).expect("remove test directory");
}
#[test]
fn checked_atomic_write_rejects_a_change_immediately_before_replace() {
let dir = temp_dir("checked-race");
let path = dir.join("config.toml");
fs::write(&path, b"old").expect("write original file");
let error = atomic_write_checked(&path, b"codex-x", || {
fs::write(&path, b"external").expect("simulate external writer");
Err(CodexxError::Config("stale snapshot".to_string()))
})
.expect_err("stale checked write must fail");
assert!(error.to_string().contains("stale snapshot"));
assert_eq!(fs::read(&path).expect("read external value"), b"external");
assert_eq!(fs::read_dir(&dir).expect("read directory").count(), 1);
fs::remove_dir_all(dir).expect("remove test directory");
}
#[cfg(unix)]
#[test]
fn atomic_write_restricts_sensitive_codex_files_and_preserves_other_modes() {
use std::os::unix::fs::PermissionsExt;
let dir = temp_dir("permissions");
let config = dir.join("config.toml");
let auth = dir.join("auth.json");
let script = dir.join("tool.sh");
for path in [&config, &auth, &script] {
fs::write(path, b"old").expect("seed file");
}
fs::set_permissions(&config, fs::Permissions::from_mode(0o644)).expect("set config mode");
fs::set_permissions(&auth, fs::Permissions::from_mode(0o644)).expect("set auth mode");
fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).expect("set script mode");
atomic_write(&config, b"new config").expect("replace config");
atomic_write(&auth, b"new auth").expect("replace auth");
atomic_write(&script, b"new script").expect("replace script");
let mode = |path: &Path| fs::metadata(path).unwrap().permissions().mode() & 0o777;
assert_eq!(mode(&config), 0o600);
assert_eq!(mode(&auth), 0o600);
assert_eq!(mode(&script), 0o755);
fs::remove_dir_all(dir).expect("remove test directory");
}
#[cfg(unix)]
#[test]
fn harden_sensitive_permissions_updates_existing_files_without_creating_missing_files() {
use std::os::unix::fs::PermissionsExt;
let dir = temp_dir("harden-permissions");
let config = dir.join("config.toml");
let missing = dir.join("auth.json");
fs::write(&config, b"model = \"gpt\"\n").expect("seed config");
fs::set_permissions(&config, fs::Permissions::from_mode(0o644)).expect("set open mode");
harden_sensitive_file_permissions(&config).expect("harden config");
harden_sensitive_file_permissions(&missing).expect("ignore missing auth");
assert_eq!(
fs::metadata(&config).unwrap().permissions().mode() & 0o777,
0o600
);
assert!(!missing.exists());
fs::remove_dir_all(dir).expect("remove test directory");
}
#[cfg(unix)]
#[test]
fn private_json_is_private_from_its_first_atomic_write() {
use std::os::unix::fs::PermissionsExt;
let dir = temp_dir("private-json");
let path = dir.join("official-snapshot.json");
write_private_json(&path, &serde_json::json!({"token": "secret"}))
.expect("write private JSON");
assert_eq!(
fs::metadata(&path).unwrap().permissions().mode() & 0o777,
0o600
);
fs::remove_dir_all(dir).expect("remove test directory");
}
#[test]
fn ensure_directory_accepts_existing_and_missing_directories() {
let root = temp_dir("ensure-directory");
File diff suppressed because it is too large Load Diff
+213
View File
@@ -0,0 +1,213 @@
use crate::error::{CodexxError, Result};
use crate::file_io::{atomic_write_checked, ensure_directory, io_err};
use std::fs;
use std::io::Write;
use std::path::Path;
pub(crate) struct LiveConfigLock {
file: fs::File,
}
impl Drop for LiveConfigLock {
fn drop(&mut self) {
let _ = self.file.unlock();
}
}
pub(crate) fn acquire_live_config_lock(codex_dir: &Path) -> Result<LiveConfigLock> {
let tmp_dir = codex_dir.join("tmp");
ensure_directory(&tmp_dir)?;
let path = tmp_dir.join("codex-x-live-config.lock");
if path.is_dir() {
return Err(CodexxError::Config(format!(
"Codex live 配置锁被同名目录占用: {}",
path.display()
)));
}
let mut file = fs::OpenOptions::new()
.create(true)
.truncate(false)
.read(true)
.write(true)
.open(&path)
.map_err(|error| io_err(&path, error))?;
file.try_lock().map_err(|_| {
CodexxError::Config(format!(
"另一个 Codex-X 正在修改 Codex live 配置,请稍后重试: {}",
path.display()
))
})?;
file.set_len(0).map_err(|error| io_err(&path, error))?;
writeln!(file, "pid={}", std::process::id()).map_err(|error| io_err(&path, error))?;
file.sync_all().map_err(|error| io_err(&path, error))?;
Ok(LiveConfigLock { file })
}
pub(crate) fn read_file_snapshot(path: &Path) -> Result<Option<Vec<u8>>> {
match fs::read(path) {
Ok(bytes) => Ok(Some(bytes)),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(error) => Err(io_err(path, error)),
}
}
pub(crate) fn text_from_snapshot(path: &Path, snapshot: Option<&[u8]>) -> Result<String> {
String::from_utf8(snapshot.unwrap_or_default().to_vec())
.map_err(|_| CodexxError::Config(format!("{} 不是有效的 UTF-8 文本", path.display())))
}
pub(crate) fn ensure_file_snapshot_unchanged(path: &Path, expected: Option<&[u8]>) -> Result<()> {
if read_file_snapshot(path)?.as_deref() == expected {
return Ok(());
}
Err(CodexxError::Config(format!(
"{} 已被其他程序修改,本次写入已取消,请刷新后重试",
path.display()
)))
}
pub(crate) fn atomic_write_if_unchanged(
path: &Path,
expected: Option<&[u8]>,
replacement: &[u8],
) -> Result<()> {
atomic_write_checked(path, replacement, || {
ensure_file_snapshot_unchanged(path, expected)
})
}
pub(crate) fn remove_file_if_unchanged(path: &Path, expected: Option<&[u8]>) -> Result<()> {
ensure_file_snapshot_unchanged(path, expected)?;
match fs::remove_file(path) {
Ok(()) => Ok(()),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(error) => Err(io_err(path, error)),
}
}
pub(crate) fn restore_file_snapshot_if_unchanged(
path: &Path,
expected_current: Option<&[u8]>,
snapshot: Option<&[u8]>,
) -> Result<()> {
match snapshot {
Some(bytes) => atomic_write_if_unchanged(path, expected_current, bytes),
None => remove_file_if_unchanged(path, expected_current),
}
}
pub(crate) struct AppliedFileChange {
path: std::path::PathBuf,
before: Option<Vec<u8>>,
after: Option<Vec<u8>>,
changed: bool,
}
impl AppliedFileChange {
pub(crate) fn rollback(&self) -> Result<()> {
if !self.changed {
return Ok(());
}
restore_file_snapshot_if_unchanged(
&self.path,
self.after.as_deref(),
self.before.as_deref(),
)
}
}
pub(crate) fn apply_file_change(
path: &Path,
before: Option<Vec<u8>>,
after: Option<Vec<u8>>,
) -> Result<AppliedFileChange> {
let changed = before != after;
if changed {
match after.as_deref() {
Some(bytes) => atomic_write_if_unchanged(path, before.as_deref(), bytes)?,
None => remove_file_if_unchanged(path, before.as_deref())?,
}
}
Ok(AppliedFileChange {
path: path.to_path_buf(),
before,
after,
changed,
})
}
pub(crate) fn rollback_file_changes(changes: &[AppliedFileChange]) -> Result<()> {
for change in changes.iter().filter(|change| change.changed) {
ensure_file_snapshot_unchanged(&change.path, change.after.as_deref())?;
}
let mut failures = Vec::new();
for change in changes.iter().rev() {
if let Err(error) = change.rollback() {
failures.push(error.to_string());
}
}
if failures.is_empty() {
Ok(())
} else {
Err(CodexxError::Config(failures.join(";")))
}
}
pub(crate) fn fail_with_file_rollback<T>(
error: CodexxError,
changes: &[AppliedFileChange],
) -> Result<T> {
match rollback_file_changes(changes) {
Ok(()) => Err(error),
Err(rollback_error) => Err(CodexxError::Config(format!(
"{error};文件回滚失败:{rollback_error}"
))),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicU64, Ordering};
fn temp_dir(name: &str) -> std::path::PathBuf {
static COUNTER: AtomicU64 = AtomicU64::new(0);
let path = std::env::temp_dir().join(format!(
"codex-x-live-config-{name}-{}-{}",
std::process::id(),
COUNTER.fetch_add(1, Ordering::Relaxed),
));
let _ = fs::remove_dir_all(&path);
fs::create_dir_all(&path).expect("create test directory");
path
}
#[test]
fn lock_rejects_a_second_writer_and_releases_on_drop() {
let dir = temp_dir("lock");
let first = acquire_live_config_lock(&dir).expect("acquire first lock");
let error = acquire_live_config_lock(&dir)
.err()
.expect("second lock must fail");
assert!(error.to_string().contains("另一个 Codex-X"));
drop(first);
acquire_live_config_lock(&dir).expect("lock is released on drop");
fs::remove_dir_all(dir).expect("remove test directory");
}
#[test]
fn stale_checked_write_preserves_the_external_value() {
let dir = temp_dir("stale");
let path = dir.join("config.toml");
fs::write(&path, b"old").expect("seed file");
let old = read_file_snapshot(&path).expect("capture file");
fs::write(&path, b"external").expect("simulate external writer");
atomic_write_if_unchanged(&path, old.as_deref(), b"codex-x")
.expect_err("stale write must fail");
assert_eq!(fs::read(&path).expect("read file"), b"external");
fs::remove_dir_all(dir).expect("remove test directory");
}
}
+11 -1
View File
@@ -1,7 +1,7 @@
use crate::error::{CodexxError, Result};
#[cfg(test)]
use chrono::Local;
use std::path::PathBuf;
use std::path::{Path, PathBuf};
pub(crate) fn home_dir() -> Result<PathBuf> {
dirs::home_dir().ok_or(CodexxError::NoHomeDir)
@@ -33,3 +33,13 @@ pub(crate) fn app_home() -> Result<PathBuf> {
Ok(home_dir()?.join(".codexx"))
}
}
pub(crate) fn normalized_path_scope(path: &Path) -> String {
let resolved = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
let normalized = resolved.to_string_lossy().replace('\\', "/");
if cfg!(target_os = "windows") {
normalized.to_ascii_lowercase()
} else {
normalized
}
}
File diff suppressed because it is too large Load Diff
+405 -52
View File
@@ -1,6 +1,9 @@
use super::store::normalize_prompt_filename;
use super::store::{
builtin_prompt_override_ids_inner, builtin_prompt_override_inner, normalize_prompt_filename,
};
use super::types::{
BuiltinPromptStatus, BundledPromptMeta, CachedBuiltinPrompt, GithubContentEntry,
BuiltinPromptDetail, BuiltinPromptStatus, BundledPromptMeta, CachedBuiltinPrompt,
GithubContentEntry,
};
use crate::constants::{
GITHUB_EXAMPLES_API, GITHUB_EXAMPLES_BASE, INSTRUCTION_54_CONTENT, INSTRUCTION_54_FILENAME,
@@ -20,7 +23,8 @@ use serde::Deserialize;
use std::collections::{HashMap, HashSet};
use std::sync::Mutex;
static BUILTIN_PROMPT_CACHE_LOCK: Mutex<()> = Mutex::new(());
// Only serialize remote syncs. Local reads must stay available during downloads.
static BUILTIN_PROMPT_SYNC_LOCK: Mutex<()> = Mutex::new(());
const CATALOG_CDN_KEY: &str = "模板 CDN";
const CATALOG_GITHUB_KEY: &str = "GitHub 模板目录";
const PROMPT_CDN_KEY: &str = "模板 CDN";
@@ -302,6 +306,13 @@ pub(crate) fn delete_cached_prompt_ids(
fn prune_builtin_prompt_cache(active_ids: &HashSet<String>) -> Result<usize> {
let mut conn = open_db()?;
prune_builtin_prompt_cache_on_connection(&mut conn, active_ids)
}
fn prune_builtin_prompt_cache_on_connection(
conn: &mut Connection,
active_ids: &HashSet<String>,
) -> Result<usize> {
let transaction = conn
.transaction_with_behavior(TransactionBehavior::Immediate)
.map_err(|e| CodexxError::Database(e.to_string()))?;
@@ -312,7 +323,14 @@ fn prune_builtin_prompt_cache(active_ids: &HashSet<String>) -> Result<usize> {
let mut deleted = 0;
for id in stale_ids {
deleted += transaction
.execute("DELETE FROM builtin_prompt_cache WHERE id = ?1", [id])
.execute(
"DELETE FROM builtin_prompt_cache
WHERE id = ?1
AND NOT EXISTS (
SELECT 1 FROM builtin_prompt_overrides WHERE template_id = ?1
)",
[id],
)
.map_err(|e| CodexxError::Database(e.to_string()))?;
}
transaction
@@ -491,6 +509,22 @@ fn fetch_github_prompt_catalog() -> Result<RemotePromptCatalog> {
fetch_first_valid(&sources, parse_prompt_catalog)
}
fn confirm_prompt_catalog_with<Fetch>(
catalog: RemotePromptCatalog,
fetch_authoritative: Fetch,
) -> (RemotePromptCatalog, bool)
where
Fetch: FnOnce() -> Result<RemotePromptCatalog>,
{
if catalog.authoritative {
return (catalog, false);
}
match fetch_authoritative() {
Ok(authoritative) => (authoritative, false),
Err(_) => (catalog, true),
}
}
fn prompt_status_from_cache(cache: CachedBuiltinPrompt, message: &str) -> BuiltinPromptStatus {
let (title, subtitle, badge) = prompt_display_meta(&cache.filename);
BuiltinPromptStatus {
@@ -506,6 +540,7 @@ fn prompt_status_from_cache(cache: CachedBuiltinPrompt, message: &str) -> Builti
sync_issue: None,
checked_at: Some(cache.checked_at),
message: message.to_string(),
customized: false,
}
}
@@ -562,6 +597,7 @@ fn refresh_builtin_prompt_from_source(
"已是最新在线模板"
}
.to_string(),
customized: false,
})
}
Err(_) => {
@@ -593,6 +629,7 @@ fn refresh_builtin_prompt_from_source(
"在线模板暂时不可用,且没有本地副本"
}
.to_string(),
customized: false,
})
}
}
@@ -612,9 +649,72 @@ fn bundled_prompt_status(meta: BundledPromptMeta, message: &str) -> BuiltinPromp
sync_issue: None,
checked_at: None,
message: message.to_string(),
customized: false,
}
}
fn customized_prompt_status(id: &str, filename: &str) -> Result<BuiltinPromptStatus> {
let mut status = if let Some(cache) = cached_builtin_prompt(id)? {
prompt_status_from_cache(cache, "本地已修改,GitHub 同步已跳过")
} else if let Some(meta) = bundled_prompt_meta(id) {
bundled_prompt_status(meta, "本地已修改,GitHub 同步已跳过")
} else {
let (title, subtitle, badge) = prompt_display_meta(filename);
BuiltinPromptStatus {
id: id.to_string(),
filename: filename.to_string(),
title,
subtitle,
badge,
source_url: builtin_prompt_source_url(filename),
cached: false,
updated: false,
content_source: "local".to_string(),
sync_issue: None,
checked_at: None,
message: "本地已修改,GitHub 同步已跳过".to_string(),
customized: true,
}
};
status.customized = true;
status.message = "本地已修改,GitHub 同步已跳过".to_string();
Ok(status)
}
fn mark_customized_prompt_statuses(
statuses: &mut [BuiltinPromptStatus],
customized_ids: &HashSet<String>,
) {
for status in statuses {
if customized_ids.contains(&status.id) {
status.customized = true;
status.message = "本地已修改,GitHub 同步已跳过".to_string();
}
}
}
fn include_customized_prompt_statuses(
statuses: &mut Vec<BuiltinPromptStatus>,
customized_ids: &HashSet<String>,
caches: Vec<CachedBuiltinPrompt>,
) {
for cache in caches {
if customized_ids.contains(&cache.id)
&& !statuses.iter().any(|status| status.id == cache.id)
{
statuses.push(prompt_status_from_cache(
cache,
"本地已修改,GitHub 同步已跳过",
));
}
}
mark_customized_prompt_statuses(statuses, customized_ids);
}
fn should_sync_prompt_content(id: &str, customized_ids: &HashSet<String>) -> bool {
!customized_ids.contains(id)
}
pub(crate) fn cached_prompt_fallback_statuses(
caches: Vec<CachedBuiltinPrompt>,
) -> Vec<BuiltinPromptStatus> {
@@ -657,47 +757,38 @@ pub(crate) fn cached_prompt_fallback_statuses(
}
pub(crate) fn builtin_prompt_status_inner() -> Result<Vec<BuiltinPromptStatus>> {
Ok(cached_prompt_fallback_statuses(cached_builtin_prompts()?))
let customized_ids = builtin_prompt_override_ids_inner()?;
let mut statuses = cached_prompt_fallback_statuses(cached_builtin_prompts()?);
mark_customized_prompt_statuses(&mut statuses, &customized_ids);
Ok(statuses)
}
pub(crate) fn refresh_builtin_prompts_with_active(
active_remote_builtin_prompt_id: impl FnOnce() -> Option<String>,
) -> Result<Vec<BuiltinPromptStatus>> {
let _cache_guard = BUILTIN_PROMPT_CACHE_LOCK
let _sync_guard = BUILTIN_PROMPT_SYNC_LOCK
.lock()
.map_err(|_| CodexxError::Database("提示词缓存锁已损坏".to_string()))?;
let mut catalog = match fetch_prompt_catalog() {
let customized_ids = builtin_prompt_override_ids_inner()?;
let catalog = match fetch_prompt_catalog() {
Ok(catalog) => catalog,
Err(_) => {
let mut statuses = builtin_prompt_status_inner()?;
for status in &mut statuses {
status.sync_issue = Some("catalog".to_string());
status.message = "在线模板目录暂时不可用,已保留本地内容".to_string();
if !status.customized {
status.message = "在线模板目录暂时不可用,已保留本地内容".to_string();
}
}
return Ok(statuses);
}
};
let cached_before = cached_builtin_prompts()?;
let mut catalog_confirmation_failed = false;
if !catalog.authoritative {
let cdn_ids = catalog
.prompts
.iter()
.map(|(id, _)| id.clone())
.collect::<HashSet<_>>();
let cache_may_be_newer = cached_before
.iter()
.any(|cache| !cdn_ids.contains(&cache.id));
let bundled_missing = bundled_prompt_metas()
.into_iter()
.any(|meta| !cdn_ids.contains(meta.id));
if cache_may_be_newer || bundled_missing {
match fetch_github_prompt_catalog() {
Ok(authoritative) => catalog = authoritative,
Err(_) => catalog_confirmation_failed = true,
}
}
}
// A successful CDN response can still contain an old @main directory. Always
// confirm directory membership with GitHub so newly added and removed files
// become visible immediately; keep the CDN result as the offline fallback.
let (catalog, catalog_confirmation_failed) =
confirm_prompt_catalog_with(catalog, fetch_github_prompt_catalog);
let remote_ids = catalog
.prompts
.iter()
@@ -710,29 +801,46 @@ pub(crate) fn refresh_builtin_prompts_with_active(
.collect::<HashSet<_>>();
let mut statuses = Vec::new();
for (id, filename) in catalog.prompts {
let bundled = bundled_prompt_meta(&id).map(|meta| meta.content);
statuses.push(refresh_builtin_prompt_from_source(&id, &filename, bundled)?);
if !should_sync_prompt_content(&id, &customized_ids) {
statuses.push(customized_prompt_status(&id, &filename)?);
} else {
let bundled = bundled_prompt_meta(&id).map(|meta| meta.content);
statuses.push(refresh_builtin_prompt_from_source(&id, &filename, bundled)?);
}
}
for meta in bundled_prompt_metas() {
if !remote_ids.contains(meta.id) {
statuses.push(bundled_prompt_status(
meta,
"在线目录暂未提供该模板,使用软件内置版本",
));
statuses.push(if customized_ids.contains(meta.id) {
customized_prompt_status(meta.id, meta.filename)?
} else {
bundled_prompt_status(meta, "在线目录暂未提供该模板,使用软件内置版本")
});
}
}
if catalog.authoritative {
let mut retained_ids = remote_ids.clone();
for customized_id in &customized_ids {
retained_ids.insert(customized_id.clone());
if !remote_ids.contains(customized_id)
&& !statuses.iter().any(|status| status.id == *customized_id)
{
if let Some(cache) = cached_builtin_prompt(customized_id)? {
statuses.push(customized_prompt_status(customized_id, &cache.filename)?);
}
}
}
if let Some(active_id) = active_remote_builtin_prompt_id() {
if !remote_ids.contains(&active_id) {
if let Some(cache) = cached_builtin_prompt(&active_id)? {
let mut status = prompt_status_from_cache(
cache,
"该在线模板已下架,当前配置继续使用本地副本",
);
status.content_source = "removed".to_string();
statuses.push(status);
if !statuses.iter().any(|status| status.id == active_id) {
let mut status = prompt_status_from_cache(
cache,
"该在线模板已下架,当前配置继续使用本地副本",
);
status.content_source = "removed".to_string();
statuses.push(status);
}
}
retained_ids.insert(active_id);
}
@@ -753,6 +861,10 @@ pub(crate) fn refresh_builtin_prompts_with_active(
if catalog_confirmation_failed {
mark_catalog_confirmation_failed(&mut statuses);
}
// Local edits can finish while remote requests are pending. Use their
// latest IDs and retained cache entries when returning the catalog.
let customized_ids = builtin_prompt_override_ids_inner()?;
include_customized_prompt_statuses(&mut statuses, &customized_ids, cached_builtin_prompts()?);
let order = bundled_prompt_metas()
.into_iter()
.enumerate()
@@ -773,9 +885,8 @@ pub(crate) fn refresh_builtin_prompts_with_active(
pub(crate) fn builtin_prompt_content(
template_id: &str,
) -> Result<(String, String, String, String)> {
let _cache_guard = BUILTIN_PROMPT_CACHE_LOCK
.lock()
.map_err(|_| CodexxError::Database("提示词缓存锁已损坏".to_string()))?;
// Editing and enabling a template are local operations. Network refreshes
// update SQLite separately, whose reads see the last committed cache entry.
let id = if template_id.trim().is_empty() {
"gpt5.5-unrestricted"
} else {
@@ -788,17 +899,12 @@ pub(crate) fn builtin_prompt_content(
.map(|item| item.filename.clone())
.or_else(|| bundled.map(|item| item.filename.to_string()))
.ok_or_else(|| CodexxError::Config(format!("提示词模板不存在或尚未同步: {id}")))?;
let trust = PromptContentTrust {
cached: cached.as_ref().map(|item| item.content.as_str()),
bundled: bundled.map(|item| item.content),
};
if let Ok((remote, source_url)) = fetch_remote_prompt(&filename, trust) {
save_builtin_prompt_cache(id, &filename, &source_url, &remote)?;
if let Some(content) = builtin_prompt_override_inner(id)? {
return Ok((
filename.clone(),
format!("./{filename}"),
remote,
"在线最新".to_string(),
content,
"本地已修改".to_string(),
));
}
if let Some(cache) = cached {
@@ -810,7 +916,9 @@ pub(crate) fn builtin_prompt_content(
));
}
let bundled = bundled.ok_or_else(|| {
CodexxError::Config(format!("无法下载提示词且没有可用缓存: {template_id}"))
CodexxError::Config(format!(
"提示词尚无本地内容,请先同步 GitHub 模板: {template_id}"
))
})?;
Ok((
bundled.filename.to_string(),
@@ -820,10 +928,112 @@ pub(crate) fn builtin_prompt_content(
))
}
pub(crate) fn builtin_prompt_detail_inner(template_id: &str) -> Result<BuiltinPromptDetail> {
let id = if template_id.trim().is_empty() {
"gpt5.5-unrestricted"
} else {
template_id.trim()
};
let (filename, _, content, _) = builtin_prompt_content(id)?;
let (title, _, _) = prompt_display_meta(&filename);
Ok(BuiltinPromptDetail {
id: id.to_string(),
filename,
title,
content,
customized: builtin_prompt_override_inner(id)?.is_some(),
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn local_prompt_detail_does_not_wait_for_background_sync() {
let _db_guard = crate::app_db::test_db_guard();
let id = "github-detail-during-sync";
save_builtin_prompt_cache(
id,
"detail-during-sync.md",
"https://example.test/prompt.md",
"cached content",
)
.expect("cache template");
super::super::store::save_builtin_prompt_override_inner(id, "local edit")
.expect("save local edit");
let sync_guard = BUILTIN_PROMPT_SYNC_LOCK
.lock()
.expect("simulate pending sync");
let (sender, receiver) = std::sync::mpsc::channel();
let reader = std::thread::spawn(move || {
sender.send(builtin_prompt_detail_inner(id)).unwrap();
});
let detail = receiver.recv_timeout(std::time::Duration::from_secs(1));
// Release before asserting so a regression cannot leave a blocked worker.
drop(sync_guard);
reader.join().expect("finish detail reader");
let conn = open_db().expect("open test store");
conn.execute(
"DELETE FROM builtin_prompt_overrides WHERE template_id = ?1",
[id],
)
.unwrap();
conn.execute("DELETE FROM builtin_prompt_cache WHERE id = ?1", [id])
.unwrap();
let detail = detail
.expect("local detail must finish while sync is pending")
.unwrap();
assert_eq!(detail.content, "local edit");
assert!(detail.customized);
}
#[test]
fn cached_and_bundled_prompt_content_stays_available_during_sync() {
let _db_guard = crate::app_db::test_db_guard();
let id = "github-cached-content-during-sync";
save_builtin_prompt_cache(
id,
"cached-during-sync.md",
"https://example.test/prompt.md",
"last downloaded content",
)
.expect("cache online template");
let before = cached_builtin_prompt(id).unwrap().unwrap().checked_at;
let sync_guard = BUILTIN_PROMPT_SYNC_LOCK
.lock()
.expect("simulate pending sync");
let (sender, receiver) = std::sync::mpsc::channel();
let reader = std::thread::spawn(move || {
let result = (|| -> Result<_> {
Ok((
builtin_prompt_content(id)?,
builtin_prompt_content("gpt5.5-unrestricted")?,
))
})();
sender.send(result).unwrap();
});
let result = receiver.recv_timeout(std::time::Duration::from_secs(1));
drop(sync_guard);
reader.join().expect("finish content reader");
let after = cached_builtin_prompt(id).unwrap().unwrap().checked_at;
open_db()
.unwrap()
.execute("DELETE FROM builtin_prompt_cache WHERE id = ?1", [id])
.unwrap();
let (cached, bundled) = result
.expect("local content must not wait for sync")
.unwrap();
assert_eq!(cached.2, "last downloaded content");
assert_eq!(cached.3, "本地缓存");
assert_eq!(bundled.2, INSTRUCTION_CONTENT);
assert_eq!(bundled.3, "打包内置");
assert_eq!(before, after, "reading must not refresh the cache");
}
const TEST_SOURCES: [RemoteSource<'static>; 2] = [
RemoteSource::new(PROMPT_CDN_KEY, "https://cdn.test/prompt.md", None),
RemoteSource::new(PROMPT_GITHUB_KEY, "https://github.test/prompt.md", None),
@@ -838,6 +1048,10 @@ mod tests {
content TEXT NOT NULL,
checked_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE builtin_prompt_overrides (
template_id TEXT PRIMARY KEY,
content TEXT NOT NULL
);",
)
.expect("create prompt cache table");
@@ -849,6 +1063,65 @@ mod tests {
conn
}
#[test]
fn edit_saved_during_sync_survives_stale_catalog_pruning_and_returned_statuses() {
let mut conn = prompt_cache_connection();
for id in [
"removed-but-edited",
"removed-unedited",
"remote-still-present",
] {
save_builtin_prompt_cache_on_connection(
&conn,
id,
&format!("{id}.md"),
"https://example.test/prompt.md",
"downloaded content",
)
.unwrap();
}
// Sync started before this edit, so its original retained IDs omit it.
let retained_at_sync_start = HashSet::from(["remote-still-present".to_string()]);
conn.execute(
"INSERT INTO builtin_prompt_overrides (template_id, content) VALUES (?1, ?2)",
["removed-but-edited", "new local content"],
)
.unwrap();
let removed = prune_builtin_prompt_cache_on_connection(&mut conn, &retained_at_sync_start)
.expect("prune the old catalog after the local save");
assert_eq!(removed, 1);
let cache = cached_builtin_prompt_from_connection(&conn, "removed-but-edited")
.unwrap()
.expect("keep the filename needed to reopen the saved edit");
assert_eq!(cache.filename, "removed-but-edited.md");
assert!(
cached_builtin_prompt_from_connection(&conn, "removed-unedited")
.unwrap()
.is_none()
);
let mut statuses = vec![prompt_status_from_cache(
cached_builtin_prompt_from_connection(&conn, "remote-still-present")
.unwrap()
.unwrap(),
"synced",
)];
let customized_ids = HashSet::from(["removed-but-edited".to_string()]);
include_customized_prompt_statuses(
&mut statuses,
&customized_ids,
cached_builtin_prompts_from_connection(&conn).unwrap(),
);
assert_eq!(statuses.len(), 2);
let edited = statuses
.iter()
.find(|status| status.id == "removed-but-edited")
.expect("include the newly edited template in the sync response");
assert!(edited.customized);
assert!(edited.message.contains("本地已修改"));
}
#[test]
fn synced_remote_prompt_is_available_from_sqlite_after_restart() {
let database_path = std::env::temp_dir().join(format!(
@@ -913,6 +1186,86 @@ mod tests {
.starts_with("github-writing-clarity-editor-"));
}
#[test]
fn locally_modified_prompt_is_excluded_from_content_sync() {
let customized_ids = HashSet::from(["edited-template".to_string()]);
assert!(!should_sync_prompt_content(
"edited-template",
&customized_ids
));
assert!(should_sync_prompt_content(
"new-github-template",
&customized_ids
));
}
#[test]
fn locally_modified_prompt_status_is_visible_after_restart() {
let cache = CachedBuiltinPrompt {
id: "edited-template".to_string(),
filename: "edited-template.md".to_string(),
source_url: "https://example.test/edited-template.md".to_string(),
content: "upstream cache".to_string(),
checked_at: "2026-07-28T00:00:00Z".to_string(),
};
let mut statuses = cached_prompt_fallback_statuses(vec![cache]);
let customized_ids = HashSet::from(["edited-template".to_string()]);
mark_customized_prompt_statuses(&mut statuses, &customized_ids);
let status = statuses
.iter()
.find(|status| status.id == "edited-template")
.expect("customized cached prompt remains visible");
assert!(status.customized);
assert!(status.message.contains("同步已跳过"));
}
#[test]
fn stale_cdn_catalog_is_always_replaced_by_github_catalog() {
let cdn = RemotePromptCatalog {
prompts: vec![("bundled".to_string(), "bundled.md".to_string())],
authoritative: false,
};
let github = RemotePromptCatalog {
prompts: vec![
("bundled".to_string(), "bundled.md".to_string()),
(
"github-software-development-debugging-test".to_string(),
"software-development-debugging.md".to_string(),
),
],
authoritative: true,
};
let mut calls = 0;
let (confirmed, failed) = confirm_prompt_catalog_with(cdn, || {
calls += 1;
Ok(github)
});
assert_eq!(calls, 1);
assert!(!failed);
assert!(confirmed.authoritative);
assert_eq!(confirmed.prompts.len(), 2);
}
#[test]
fn unavailable_github_catalog_keeps_cdn_fallback_for_retry() {
let cdn = RemotePromptCatalog {
prompts: vec![("bundled".to_string(), "bundled.md".to_string())],
authoritative: false,
};
let (fallback, failed) =
confirm_prompt_catalog_with(cdn, || Err(CodexxError::Config("offline".to_string())));
assert!(failed);
assert!(!fallback.authoritative);
assert_eq!(fallback.prompts.len(), 1);
}
#[test]
fn syncing_remote_prompt_updates_the_existing_sqlite_row() {
let conn = prompt_cache_connection();
@@ -2,8 +2,9 @@ use crate::constants::{
AGENTS_FILENAME, AGENTS_MANAGED_BEGIN, AGENTS_MANAGED_END, AGENTS_TEMPLATE_PREFIX,
};
use crate::error::{CodexxError, Result};
use crate::file_io::{io_err, read_to_string_if_exists, write_text};
use std::fs;
use crate::file_io::read_to_string_if_exists;
#[cfg(test)]
use crate::live_config::{apply_file_change, read_file_snapshot, text_from_snapshot};
use std::path::{Path, PathBuf};
pub(crate) fn agents_path(codex_dir: &Path) -> PathBuf {
@@ -31,7 +32,7 @@ pub(crate) fn managed_agents_bounds(content: &str) -> Result<Option<(usize, usiz
Ok(Some((begins[0], ends[0] + AGENTS_MANAGED_END.len())))
}
fn remove_managed_agents_block(content: &str) -> Result<(String, bool)> {
pub(crate) fn remove_managed_agents_block_from_content(content: &str) -> Result<(String, bool)> {
let Some((start, end)) = managed_agents_bounds(content)? else {
return Ok((content.to_string(), false));
};
@@ -46,6 +47,23 @@ fn remove_managed_agents_block(content: &str) -> Result<(String, bool)> {
Ok((merged, true))
}
pub(crate) fn install_managed_agents_block_in_content(
existing: &str,
template_key: &str,
content: &str,
) -> Result<String> {
let (base, _) = remove_managed_agents_block_from_content(existing)?;
let managed = format!(
"{AGENTS_MANAGED_BEGIN}\n{AGENTS_TEMPLATE_PREFIX} {template_key} -->\n{}\n{AGENTS_MANAGED_END}",
content.trim()
);
Ok(if base.trim().is_empty() {
format!("{managed}\n")
} else {
format!("{}\n\n{managed}\n", base.trim_end())
})
}
pub(crate) fn managed_agents_template_key_from_content(content: &str) -> Option<String> {
let (start, end) = managed_agents_bounds(content).ok().flatten()?;
content[start..end].lines().find_map(|line| {
@@ -64,40 +82,33 @@ pub(crate) fn managed_agents_template_key(codex_dir: &Path) -> Result<Option<Str
Ok(managed_agents_template_key_from_content(&content))
}
#[cfg(test)]
pub(crate) fn install_managed_agents_block(
codex_dir: &Path,
template_key: &str,
content: &str,
) -> Result<()> {
let path = agents_path(codex_dir);
let existing = read_to_string_if_exists(&path)?;
let (base, _) = remove_managed_agents_block(&existing)?;
let managed = format!(
"{AGENTS_MANAGED_BEGIN}\n{AGENTS_TEMPLATE_PREFIX} {template_key} -->\n{}\n{AGENTS_MANAGED_END}",
content.trim()
);
let next = if base.trim().is_empty() {
format!("{managed}\n")
} else {
format!("{}\n\n{managed}\n", base.trim_end())
};
write_text(&path, &next)
let before = read_file_snapshot(&path)?;
let existing = text_from_snapshot(&path, before.as_deref())?;
let next = install_managed_agents_block_in_content(&existing, template_key, content)?;
apply_file_change(&path, before, Some(next.into_bytes()))?;
Ok(())
}
#[cfg(test)]
pub(crate) fn uninstall_managed_agents_block(codex_dir: &Path) -> Result<bool> {
let path = agents_path(codex_dir);
if !path.exists() {
let before = read_file_snapshot(&path)?;
if before.is_none() {
return Ok(false);
}
let existing = read_to_string_if_exists(&path)?;
let (next, removed) = remove_managed_agents_block(&existing)?;
let existing = text_from_snapshot(&path, before.as_deref())?;
let (next, removed) = remove_managed_agents_block_from_content(&existing)?;
if !removed {
return Ok(false);
}
if next.trim().is_empty() {
fs::remove_file(&path).map_err(|e| io_err(&path, e))?;
} else {
write_text(&path, &next)?;
}
let after = (!next.trim().is_empty()).then(|| next.into_bytes());
apply_file_change(&path, before, after)?;
Ok(true)
}
+8 -6
View File
@@ -4,18 +4,20 @@ mod store;
mod types;
pub(crate) use catalog::{
builtin_prompt_content, builtin_prompt_status_inner, bundled_prompt_meta, bundled_prompt_metas,
refresh_builtin_prompts_with_active,
builtin_prompt_content, builtin_prompt_detail_inner, builtin_prompt_status_inner,
bundled_prompt_meta, bundled_prompt_metas, refresh_builtin_prompts_with_active,
};
pub(crate) use managed_agents::{
agents_path, install_managed_agents_block, managed_agents_bounds, managed_agents_template_key,
uninstall_managed_agents_block,
agents_path, install_managed_agents_block_in_content, managed_agents_bounds,
managed_agents_template_key, remove_managed_agents_block_from_content,
};
#[cfg(test)]
pub(crate) use managed_agents::{install_managed_agents_block, uninstall_managed_agents_block};
pub(crate) use store::{
delete_prompt_inner, get_saved_prompt_inner, list_saved_prompts_inner,
normalize_prompt_filename, save_prompt_inner,
normalize_prompt_filename, save_builtin_prompt_override_inner, save_prompt_inner,
};
pub(crate) use types::{BuiltinPromptStatus, SavedPrompt};
pub(crate) use types::{BuiltinPromptDetail, BuiltinPromptStatus, SavedPrompt};
#[cfg(test)]
pub(crate) use catalog::{
+143
View File
@@ -2,6 +2,7 @@ use super::types::SavedPrompt;
use crate::error::{CodexxError, Result};
use crate::{now_rfc3339, open_db};
use rusqlite::params;
use std::collections::HashSet;
pub(crate) fn normalize_prompt_filename(input: &str, fallback: &str) -> String {
let raw = input.trim().trim_end_matches(".md");
@@ -107,6 +108,68 @@ pub(crate) fn delete_prompt_inner(id: &str) -> Result<()> {
Ok(())
}
fn builtin_prompt_override_from_connection(
conn: &rusqlite::Connection,
template_id: &str,
) -> Result<Option<String>> {
match conn.query_row(
"SELECT content FROM builtin_prompt_overrides WHERE template_id = ?1",
[template_id],
|row| row.get(0),
) {
Ok(content) => Ok(Some(content)),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(CodexxError::Database(e.to_string())),
}
}
pub(crate) fn builtin_prompt_override_inner(template_id: &str) -> Result<Option<String>> {
let conn = open_db()?;
builtin_prompt_override_from_connection(&conn, template_id.trim())
}
pub(crate) fn builtin_prompt_override_ids_inner() -> Result<HashSet<String>> {
let conn = open_db()?;
let mut stmt = conn
.prepare("SELECT template_id FROM builtin_prompt_overrides")
.map_err(|e| CodexxError::Database(e.to_string()))?;
let rows = stmt
.query_map([], |row| row.get(0))
.map_err(|e| CodexxError::Database(e.to_string()))?;
rows.map(|row| row.map_err(|e| CodexxError::Database(e.to_string())))
.collect()
}
fn save_builtin_prompt_override_on_connection(
conn: &rusqlite::Connection,
template_id: &str,
content: &str,
) -> Result<()> {
let id = template_id.trim();
if id.is_empty() {
return Err(CodexxError::Config("提示词模板标识不能为空".to_string()));
}
if content.trim().is_empty() {
return Err(CodexxError::Config("提示词内容不能为空".to_string()));
}
let now = now_rfc3339();
conn.execute(
"INSERT INTO builtin_prompt_overrides (template_id, content, created_at, updated_at)
VALUES (?1, ?2, ?3, ?3)
ON CONFLICT(template_id) DO UPDATE SET
content = excluded.content,
updated_at = excluded.updated_at",
params![id, content, now],
)
.map_err(|e| CodexxError::Database(e.to_string()))?;
Ok(())
}
pub(crate) fn save_builtin_prompt_override_inner(template_id: &str, content: &str) -> Result<()> {
let conn = open_db()?;
save_builtin_prompt_override_on_connection(&conn, template_id, content)
}
fn find_saved_prompt_by_content(content: &str) -> Result<Option<SavedPrompt>> {
let conn = open_db()?;
let mut stmt = conn
@@ -167,3 +230,83 @@ pub(super) fn find_saved_prompt_by_current_file(
Err(e) => Err(CodexxError::Database(e.to_string())),
}
}
#[cfg(test)]
mod tests {
use super::*;
use rusqlite::Connection;
fn override_connection() -> Connection {
let conn = Connection::open_in_memory().expect("open override database");
conn.execute_batch(
"CREATE TABLE builtin_prompt_overrides (
template_id TEXT PRIMARY KEY,
content TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);",
)
.expect("create override table");
conn
}
#[test]
fn builtin_prompt_override_can_be_edited_repeatedly() {
let conn = override_connection();
save_builtin_prompt_override_on_connection(&conn, "template", "first")
.expect("save initial override");
save_builtin_prompt_override_on_connection(&conn, "template", "second")
.expect("update override");
assert_eq!(
builtin_prompt_override_from_connection(&conn, "template")
.expect("read override")
.as_deref(),
Some("second")
);
}
#[test]
fn builtin_prompt_override_rejects_empty_content() {
let conn = override_connection();
let error = save_builtin_prompt_override_on_connection(&conn, "template", " \n")
.expect_err("reject empty override");
assert!(error.to_string().contains("内容不能为空"));
}
#[test]
fn builtin_prompt_override_survives_database_reopen() {
let database_path = std::env::temp_dir().join(format!(
"codexx-prompt-override-{}-{}.sqlite",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("system time")
.as_nanos()
));
{
let conn = Connection::open(&database_path).expect("open override database");
conn.execute_batch(
"CREATE TABLE builtin_prompt_overrides (
template_id TEXT PRIMARY KEY,
content TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);",
)
.expect("create override table");
save_builtin_prompt_override_on_connection(&conn, "template", "local content")
.expect("save override");
}
let reopened = Connection::open(&database_path).expect("reopen override database");
assert_eq!(
builtin_prompt_override_from_connection(&reopened, "template")
.expect("read persisted override")
.as_deref(),
Some("local content")
);
drop(reopened);
std::fs::remove_file(database_path).expect("remove override database");
}
}
@@ -24,6 +24,17 @@ pub(crate) struct BuiltinPromptStatus {
pub(crate) sync_issue: Option<String>,
pub(crate) checked_at: Option<String>,
pub(crate) message: String,
pub(crate) customized: bool,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct BuiltinPromptDetail {
pub(crate) id: String,
pub(crate) filename: String,
pub(crate) title: String,
pub(crate) content: String,
pub(crate) customized: bool,
}
#[derive(Debug, Clone, Copy)]
+336 -76
View File
@@ -1,18 +1,20 @@
use super::{
custom_provider_id, experimental_bearer_token_from_doc, list_saved_providers_on_connection,
normalize_saved_provider, open_store, upsert_provider_on_connection, ProviderUpsertKind,
ProviderUpsertMode, SavedProvider,
consolidate_legacy_provider_duplicates_on_connection, custom_provider_id,
experimental_bearer_token_from_doc, list_saved_providers_on_connection,
normalize_saved_provider, open_store, strip_provider_bearer_tokens,
upsert_ccswitch_provider_on_connection, ProviderUpsertKind, SavedProvider,
};
use crate::ccswitch::{ccswitch_db_candidates, default_ccswitch_db_path};
use crate::error::{CodexxError, Result};
use crate::sqlite_utils::table_column_set;
use crate::string_value;
use crate::toml_utils::ensure_table;
use rusqlite::{Connection, OpenFlags, TransactionBehavior};
use serde::Serialize;
use serde_json::Value;
use std::collections::HashMap;
use std::path::PathBuf;
use toml_edit::{DocumentMut, Table};
use toml_edit::{value, DocumentMut, Item, Table};
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
@@ -30,6 +32,7 @@ pub(crate) struct ImportResult {
#[serde(rename_all = "camelCase")]
pub(crate) struct OfficialAuthCandidate {
auth_json: String,
config_text: Option<String>,
model: Option<String>,
source: String,
}
@@ -91,6 +94,7 @@ pub(crate) struct CcSwitchCodexSection {
pub(crate) wire_api: String,
pub(crate) requires_openai_auth: bool,
pub(crate) experimental_bearer_token: Option<String>,
pub(crate) provider_table: Table,
}
fn table_string(table: &Table, key: &str) -> Option<String> {
@@ -134,6 +138,7 @@ pub(super) fn codex_section_from_table(
.and_then(|item| item.as_bool())
.unwrap_or(false),
experimental_bearer_token: table_string(table, "experimental_bearer_token"),
provider_table: table.clone(),
})
}
@@ -160,79 +165,130 @@ fn select_ccswitch_section_for_row(
global_sections: &HashMap<String, CcSwitchCodexSection>,
) -> Option<CcSwitchCodexSection> {
let provider_id = custom_provider_id(&row.id);
if let Some(section) = global_sections.get(&provider_id) {
return Some(section.clone());
}
if let Some(section) = global_sections.get(row.id.trim()) {
return Some(section.clone());
}
let config_text = settings.get("config").and_then(Value::as_str).unwrap_or("");
let doc = config_text.parse::<DocumentMut>().ok()?;
let model = string_value(&doc, "model");
let active_provider = string_value(&doc, "model_provider");
let providers = doc.get("model_providers").and_then(|item| item.as_table());
let doc = config_text.parse::<DocumentMut>().ok();
if let Some(providers) = providers {
for exact_id in [provider_id.as_str(), row.id.trim()] {
if let Some(section) = providers
.get(exact_id)
.and_then(|item| item.as_table())
.and_then(|table| codex_section_from_table(exact_id, table, model.clone()))
{
return Some(section);
}
}
if let Some(doc) = doc.as_ref() {
let model = string_value(doc, "model");
let active_provider = string_value(doc, "model_provider");
let providers = doc.get("model_providers").and_then(|item| item.as_table());
if active_provider.as_deref() == Some(row.id.trim())
|| active_provider.as_deref() == Some(provider_id.as_str())
{
if let Some(active) = active_provider.as_deref() {
if let Some(providers) = providers {
for exact_id in [provider_id.as_str(), row.id.trim()] {
if let Some(section) = providers
.get(active)
.get(exact_id)
.and_then(|item| item.as_table())
.and_then(|table| codex_section_from_table(active, table, model.clone()))
.and_then(|table| codex_section_from_table(exact_id, table, model.clone()))
{
return Some(section);
}
}
if active_provider.as_deref() == Some(row.id.trim())
|| active_provider.as_deref() == Some(provider_id.as_str())
{
if let Some(active) = active_provider.as_deref() {
if let Some(section) = providers
.get(active)
.and_then(|item| item.as_table())
.and_then(|table| codex_section_from_table(active, table, model.clone()))
{
return Some(section);
}
}
}
// Legacy cc-switch templates store each third-party provider under
// `[model_providers.custom]` in that row's own complete config.
if active_provider
.as_deref()
.is_none_or(|active| active == "custom")
{
if let Some(section) = providers
.get("custom")
.and_then(|item| item.as_table())
.and_then(|table| codex_section_from_table("custom", table, model.clone()))
{
return Some(section);
}
}
}
}
// Legacy cc-switch/custom templates often store every third-party provider
// under `[model_providers.custom]`. Only use it when the row's own config
// explicitly activates custom or contains no other provider identity.
if active_provider
.as_deref()
.is_none_or(|active| active == "custom")
{
if let Some(section) = providers
.get("custom")
.and_then(|item| item.as_table())
.and_then(|table| codex_section_from_table("custom", table, model.clone()))
{
return Some(section);
}
for exact_id in [provider_id.as_str(), row.id.trim()] {
if let Some(section) = global_sections.get(exact_id) {
return Some(section.clone());
}
}
let doc = doc?;
let active_provider = string_value(&doc, "model_provider");
doc.get("base_url")
.and_then(|item| item.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
.map(|base_url| CcSwitchCodexSection {
id: provider_id,
name: None,
base_url: base_url.trim_end_matches('/').to_string(),
model,
wire_api: "responses".to_string(),
requires_openai_auth: false,
experimental_bearer_token: experimental_bearer_token_from_doc(
&doc,
active_provider.as_deref(),
),
.map(|base_url| {
let base_url = base_url.trim_end_matches('/').to_string();
let token = experimental_bearer_token_from_doc(&doc, active_provider.as_deref());
let mut provider_table = Table::new();
provider_table["base_url"] = value(base_url.clone());
provider_table["wire_api"] = value("responses");
provider_table["requires_openai_auth"] = value(false);
if let Some(token) = token.as_deref() {
provider_table["experimental_bearer_token"] = value(token);
}
CcSwitchCodexSection {
id: provider_id,
name: None,
base_url,
model: string_value(&doc, "model"),
wire_api: "responses".to_string(),
requires_openai_auth: false,
experimental_bearer_token: token,
provider_table,
}
})
}
fn ccswitch_provider_template(
settings: &Value,
section: &CcSwitchCodexSection,
provider_name: &str,
model: &str,
) -> Option<String> {
let config_text = settings.get("config").and_then(Value::as_str).unwrap_or("");
let mut doc = if config_text.trim().is_empty() {
DocumentMut::new()
} else {
config_text.parse::<DocumentMut>().ok()?
};
let provider_id = section.id.trim();
if provider_id.is_empty() {
return None;
}
if string_value(&doc, "model_provider").as_deref() != Some(provider_id) {
doc["model_provider"] = value(provider_id);
}
if string_value(&doc, "model").as_deref() != Some(model) {
doc["model"] = value(model);
}
let providers = ensure_table(doc.as_table_mut(), "model_providers").ok()?;
if providers
.get(provider_id)
.and_then(|item| item.as_table())
.is_none()
{
let mut table = section.provider_table.clone();
if table_string(&table, "name").is_none() && !provider_name.trim().is_empty() {
table["name"] = value(provider_name.trim());
}
providers.insert(provider_id, Item::Table(table));
}
strip_provider_bearer_tokens(&mut doc);
Some(doc.to_string().trim_end().to_string())
}
pub(crate) fn build_ccswitch_codex_provider(
row: &CcSwitchCodexRow,
global_sections: &HashMap<String, CcSwitchCodexSection>,
@@ -240,17 +296,23 @@ pub(crate) fn build_ccswitch_codex_provider(
let settings: Value = serde_json::from_str(&row.settings_config).ok()?;
let section = select_ccswitch_section_for_row(row, &settings, global_sections)?;
let api_key = ccswitch_auth_api_key(&settings).or(section.experimental_bearer_token.clone());
let provider_name = section
.name
.clone()
.or_else(|| {
let name = row.name.trim();
(!name.is_empty()).then(|| name.to_string())
})
.unwrap_or_else(|| row.id.clone());
let model = section.model.clone()?;
let toml_config = ccswitch_provider_template(&settings, &section, &provider_name, &model)?;
Some(SavedProvider {
id: custom_provider_id(&row.id),
provider_name: if row.name.trim().is_empty() {
section.name.unwrap_or_else(|| row.id.clone())
} else {
row.name.trim().to_string()
},
provider_name,
base_url: section.base_url,
model: section.model.unwrap_or_else(|| "gpt-5.5".to_string()),
model,
api_key,
toml_config: None,
toml_config: Some(toml_config),
wire_api: section.wire_api,
requires_openai_auth: section.requires_openai_auth,
})
@@ -314,6 +376,7 @@ pub(crate) fn import_ccswitch_codex_providers_inner(path: Option<String>) -> Res
let transaction = local_conn
.transaction_with_behavior(TransactionBehavior::Immediate)
.map_err(|e| CodexxError::Database(e.to_string()))?;
merged += consolidate_legacy_provider_duplicates_on_connection(&transaction)?;
for row in rows_vec {
if is_official_ccswitch_row(&row) {
@@ -327,15 +390,11 @@ pub(crate) fn import_ccswitch_codex_providers_inner(path: Option<String>) -> Res
match build_ccswitch_codex_provider(&row, &global_sections) {
Some(provider) => {
let provider = normalize_saved_provider(provider)?;
let result = upsert_provider_on_connection(
&transaction,
provider,
ProviderUpsertMode::Imported,
)?;
let result =
upsert_ccswitch_provider_on_connection(&transaction, provider, row.id.trim())?;
match result.kind {
ProviderUpsertKind::Added => added += 1,
ProviderUpsertKind::Updated => updated += 1,
ProviderUpsertKind::Merged => merged += 1,
}
imported += 1;
}
@@ -348,6 +407,7 @@ pub(crate) fn import_ccswitch_codex_providers_inner(path: Option<String>) -> Res
}
}
}
merged += consolidate_legacy_provider_duplicates_on_connection(&transaction)?;
transaction
.commit()
.map_err(|e| CodexxError::Database(e.to_string()))?;
@@ -385,13 +445,22 @@ pub(crate) fn read_ccswitch_official_auth_inner(
CodexxError::Database(format!("打开 cc-switch 数据库失败 {}: {e}", db.display()))
})?;
let provider_columns = table_column_set(&conn, "providers")?;
let official_filter = if provider_columns.contains("category") {
"id = 'codex-official' OR category = 'official'"
} else {
// Older cc-switch databases predate the category column. The stable
// codex-official id is still enough to identify the official row.
"id = 'codex-official'"
};
let query = format!(
"SELECT id, name, settings_config FROM providers
WHERE app_type = 'codex' AND ({official_filter})
ORDER BY CASE WHEN id = 'codex-official' THEN 0 ELSE 1 END
LIMIT 1"
);
let mut stmt = conn
.prepare(
"SELECT id, name, settings_config FROM providers
WHERE app_type = 'codex' AND (id = 'codex-official' OR category = 'official')
ORDER BY CASE WHEN id = 'codex-official' THEN 0 ELSE 1 END
LIMIT 1",
)
.prepare(&query)
.map_err(|e| CodexxError::Database(e.to_string()))?;
let mut rows = stmt
@@ -426,9 +495,12 @@ pub(crate) fn read_ccswitch_official_auth_inner(
CodexxError::Database("cc-switch official provider 缺少 auth object".to_string())
})?;
let model = settings
let config_text = settings
.get("config")
.and_then(Value::as_str)
.map(ToString::to_string);
let model = config_text
.as_deref()
.and_then(|text| text.parse::<DocumentMut>().ok())
.and_then(|doc| string_value(&doc, "model"));
@@ -437,7 +509,195 @@ pub(crate) fn read_ccswitch_official_auth_inner(
Ok(Some(OfficialAuthCandidate {
auth_json,
config_text,
model,
source: format!("cc-switch:{name}:{id}"),
}))
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn provider_import_round_trips_complete_config_without_bearer_tokens() {
let settings_config = json!({
"auth": {"OPENAI_API_KEY": "sk-from-auth"},
"config": r#"# keep-imported-comment
model_provider = "custom"
model = "gpt-5.6-sol"
model_reasoning_effort = "xhigh"
service_tier = "priority"
experimental_bearer_token = "sk-top-level"
notify = ["C:\\Users\\Thy\\codex-computer-use.exe", "turn-ended"]
[model_providers.custom]
name = "Sky2api"
base_url = "https://proxy.example.com/v1/"
wire_api = "responses"
requires_openai_auth = false
experimental_bearer_token = "sk-from-config"
request_max_retries = 7
[model_providers.other]
name = "Other provider"
base_url = "https://other.example.com/v1"
experimental_bearer_token = "sk-other"
[projects."/work/project"]
trust_level = "trusted"
[desktop]
followUpQueueMode = "queue"
localeOverride = "zh-CN"
[windows]
sandbox = "elevated"
shell_path = 'D:\Program Files\PowerShell\7\pwsh.exe'
[plugins."browser@openai-bundled"]
enabled = true
[features]
js_repl = false
[shell_environment_policy.set]
CODEX_HOME = 'C:\Users\Thy\.codex'
[mcp_servers.docs]
command = "docs-server"
"#,
})
.to_string();
let row = CcSwitchCodexRow {
id: "magicai-123".to_string(),
name: " Sky2_free ".to_string(),
settings_config,
category: None,
};
let provider =
build_ccswitch_codex_provider(&row, &HashMap::new()).expect("build cc-switch provider");
assert_eq!(provider.id, "magicai-123");
assert_eq!(provider.provider_name, "Sky2api");
assert_eq!(provider.base_url, "https://proxy.example.com/v1");
assert_eq!(provider.model, "gpt-5.6-sol");
assert_eq!(provider.api_key.as_deref(), Some("sk-from-auth"));
assert_eq!(provider.wire_api, "responses");
assert!(!provider.requires_openai_auth);
let text = provider.toml_config.expect("complete provider TOML");
let doc = text.parse::<DocumentMut>().expect("parse provider TOML");
assert!(text.contains("# keep-imported-comment"));
assert_eq!(doc["model_provider"].as_str(), Some("custom"));
assert_eq!(doc["model_reasoning_effort"].as_str(), Some("xhigh"));
assert_eq!(doc["service_tier"].as_str(), Some("priority"));
assert_eq!(doc["notify"].as_array().map(|values| values.len()), Some(2));
assert_eq!(
doc["model_providers"]["custom"]["name"].as_str(),
Some("Sky2api")
);
assert_eq!(
doc["model_providers"]["custom"]["base_url"].as_str(),
Some("https://proxy.example.com/v1/")
);
assert_eq!(
doc["model_providers"]["custom"]["request_max_retries"].as_integer(),
Some(7)
);
assert_eq!(
doc["model_providers"]["other"]["base_url"].as_str(),
Some("https://other.example.com/v1")
);
assert_eq!(
doc["projects"]["/work/project"]["trust_level"].as_str(),
Some("trusted")
);
assert_eq!(doc["desktop"]["followUpQueueMode"].as_str(), Some("queue"));
assert_eq!(doc["desktop"]["localeOverride"].as_str(), Some("zh-CN"));
assert_eq!(doc["windows"]["sandbox"].as_str(), Some("elevated"));
assert_eq!(
doc["windows"]["shell_path"].as_str(),
Some(r"D:\Program Files\PowerShell\7\pwsh.exe")
);
assert_eq!(
doc["plugins"]["browser@openai-bundled"]["enabled"].as_bool(),
Some(true)
);
assert_eq!(doc["features"]["js_repl"].as_bool(), Some(false));
assert_eq!(
doc["shell_environment_policy"]["set"]["CODEX_HOME"].as_str(),
Some(r"C:\Users\Thy\.codex")
);
assert_eq!(
doc["mcp_servers"]["docs"]["command"].as_str(),
Some("docs-server")
);
assert!(doc.get("experimental_bearer_token").is_none());
assert!(doc["model_providers"]
.as_table()
.expect("model providers table")
.iter()
.all(|(_, item)| item
.as_table()
.is_none_or(|table| table.get("experimental_bearer_token").is_none())));
assert!(!text.contains("sk-from-auth"));
assert!(!text.contains("experimental_bearer_token"));
}
#[test]
fn complete_row_without_a_model_is_not_silently_downgraded() {
let row = CcSwitchCodexRow {
id: "missing-model".to_string(),
name: "Database label".to_string(),
settings_config: json!({
"auth": {"OPENAI_API_KEY": "sk-test"},
"config": r#"model_provider = "custom"
[model_providers.custom]
name = "TOML label"
base_url = "https://proxy.example.com/v1"
wire_api = "responses"
requires_openai_auth = false
"#,
})
.to_string(),
category: None,
};
assert!(build_ccswitch_codex_provider(&row, &HashMap::new()).is_none());
}
#[test]
fn malformed_row_config_does_not_create_a_sparse_provider() {
let section = codex_sections_from_config(
r#"model_provider = "broken-row"
model = "gpt-5.5"
[model_providers.broken-row]
name = "Recovered only from another row"
base_url = "https://proxy.example.com/v1"
wire_api = "responses"
requires_openai_auth = false
"#,
)
.into_iter()
.next()
.expect("global provider section");
let mut global_sections = HashMap::new();
global_sections.insert(section.id.clone(), section);
let row = CcSwitchCodexRow {
id: "broken-row".to_string(),
name: "Broken row".to_string(),
settings_config: json!({
"auth": {"OPENAI_API_KEY": "sk-must-not-import"},
"config": "model = ["
})
.to_string(),
category: None,
};
assert!(build_ccswitch_codex_provider(&row, &global_sections).is_none());
}
}
File diff suppressed because it is too large Load Diff
+31 -18
View File
@@ -2,6 +2,8 @@ mod ccswitch;
mod connection;
mod live;
mod official_auth;
pub(crate) mod official_profiles;
mod selection;
mod store;
use crate::error::Result;
@@ -22,35 +24,46 @@ pub(crate) use connection::{
fetch_provider_models_inner, test_provider_connection_inner, ProviderConnectionResult,
ProviderModelsResult,
};
#[cfg(test)]
pub(crate) use live::detected_live_custom_provider;
pub(crate) use live::{
detected_live_custom_provider, save_provider_toml_config_with_pre_persist,
switch_official_provider_with_pre_persist, switch_provider_with_pre_persist,
};
pub(crate) use live::{
reset_official_provider_inner, restore_official_provider_inner, save_official_config_inner,
save_provider_toml_config_inner, switch_official_provider_inner, switch_provider_inner,
build_provider_toml_draft_inner, delete_saved_provider_inner, save_active_provider_inner,
save_official_config_inner, save_provider_toml_config_inner, switch_provider_inner,
OfficialConfigInput, ProviderInput, ProviderTomlInput,
};
#[cfg(test)]
pub(crate) use official_auth::official_snapshot_path_for_test;
pub(crate) use live::{
reset_official_provider_inner, restore_official_provider_inner,
save_provider_toml_config_with_pre_persist, switch_official_provider_inner,
switch_official_provider_with_pre_persist, switch_provider_with_pre_persist,
};
#[cfg(test)]
pub(crate) use official_auth::{
capture_live_chatgpt_config, get_official_config_draft_inner, OfficialConfigDraft,
capture_live_chatgpt_config, get_official_config_draft_inner, official_snapshot_path_for_test,
};
pub(crate) use official_auth::{
document_is_official, official_auth_available, OfficialConfigDraft,
};
pub(crate) use selection::{
clear_active_provider_on_connection, clear_provider_selections_on_connection,
reconcile_active_provider_on_connection, remember_active_provider_on_connection,
};
#[cfg(test)]
pub(crate) use store::{
canonical_provider_base_url, merge_duplicate_provider_identities, provider_by_id_on_connection,
provider_identity, save_manual_provider_on_connection,
canonical_provider_base_url, provider_by_id_on_connection, provider_identity,
save_manual_provider_on_connection, upsert_provider_on_connection, ProviderUpsertMode,
};
pub(crate) use store::{
custom_provider_id, delete_provider_inner, experimental_bearer_token_from_doc,
list_saved_providers_inner, list_saved_providers_on_connection, normalize_saved_provider,
reserved_codex_provider_id, save_detected_provider_inner, save_provider_inner,
upsert_provider_on_connection, ProviderUpsertKind, ProviderUpsertMode, SavedProvider,
consolidate_legacy_provider_duplicates_on_connection, custom_provider_id,
delete_provider_inner, duplicate_provider_inner, experimental_bearer_token_from_doc,
is_placeholder_provider, list_saved_providers_inner, list_saved_providers_on_connection,
matching_saved_provider_ids_for_live, normalize_saved_provider,
normalize_saved_provider_for_save, provider_template_from_document, reserved_codex_provider_id,
rollback_provider_store_inner, save_detected_provider_with_rollback_inner, save_provider_inner,
save_provider_with_rollback_inner, strip_provider_bearer_tokens,
upsert_ccswitch_provider_on_connection, DuplicateProviderResult, ProviderStoreRollback,
ProviderUpsertKind, SavedProvider,
};
pub(crate) fn open_store() -> Result<Connection> {
let mut conn = crate::app_db::open()?;
store::merge_duplicate_provider_identities(&mut conn)?;
Ok(conn)
crate::app_db::open()
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,204 @@
use crate::error::{CodexxError, Result};
use crate::now_rfc3339;
use crate::paths::normalized_path_scope;
use rusqlite::{params, Connection, OptionalExtension};
use std::collections::HashSet;
use std::path::Path;
fn selected_provider_id_on_connection(
conn: &Connection,
codex_dir: &Path,
) -> Result<Option<String>> {
conn.query_row(
"SELECT provider_id
FROM active_provider_selections
WHERE codex_dir = ?1",
[normalized_path_scope(codex_dir)],
|row| row.get(0),
)
.optional()
.map_err(|error| CodexxError::Database(error.to_string()))
}
pub(crate) fn remember_active_provider_on_connection(
conn: &Connection,
codex_dir: &Path,
provider_id: &str,
) -> Result<()> {
let provider_id = provider_id.trim();
if provider_id.is_empty() {
return Err(CodexxError::Config("当前供应商 ID 不能为空".to_string()));
}
let exists = conn
.query_row(
"SELECT EXISTS(SELECT 1 FROM providers WHERE id = ?1)",
[provider_id],
|row| row.get::<_, bool>(0),
)
.map_err(|error| CodexxError::Database(error.to_string()))?;
if !exists {
return Err(CodexxError::Config(format!(
"无法记录当前供应商,未找到 ID {provider_id}"
)));
}
conn.execute(
"INSERT INTO active_provider_selections (codex_dir, provider_id, updated_at)
VALUES (?1, ?2, ?3)
ON CONFLICT(codex_dir) DO UPDATE SET
provider_id = excluded.provider_id,
updated_at = excluded.updated_at",
params![normalized_path_scope(codex_dir), provider_id, now_rfc3339()],
)
.map_err(|error| CodexxError::Database(error.to_string()))?;
Ok(())
}
pub(crate) fn clear_active_provider_on_connection(
conn: &Connection,
codex_dir: &Path,
) -> Result<()> {
conn.execute(
"DELETE FROM active_provider_selections WHERE codex_dir = ?1",
[normalized_path_scope(codex_dir)],
)
.map_err(|error| CodexxError::Database(error.to_string()))?;
Ok(())
}
pub(crate) fn clear_provider_selections_on_connection(
conn: &Connection,
provider_id: &str,
) -> Result<()> {
conn.execute(
"DELETE FROM active_provider_selections WHERE provider_id = ?1",
[provider_id],
)
.map_err(|error| CodexxError::Database(error.to_string()))?;
Ok(())
}
pub(crate) fn reconcile_active_provider_on_connection(
conn: &Connection,
codex_dir: &Path,
candidate_ids: &[String],
) -> Result<Option<String>> {
let candidates = candidate_ids
.iter()
.map(|id| id.trim())
.filter(|id| !id.is_empty())
.collect::<HashSet<_>>();
let selected = selected_provider_id_on_connection(conn, codex_dir)?;
if let Some(selected) = selected.as_deref() {
if candidates.contains(selected) {
return Ok(Some(selected.to_string()));
}
clear_active_provider_on_connection(conn, codex_dir)?;
}
if candidates.len() != 1 {
return Ok(None);
}
let provider_id = candidates
.into_iter()
.next()
.expect("one candidate must be present");
remember_active_provider_on_connection(conn, codex_dir, provider_id)?;
Ok(Some(provider_id.to_string()))
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
fn test_connection() -> Connection {
let conn = Connection::open_in_memory().expect("open selection test database");
conn.execute_batch(
"CREATE TABLE providers (id TEXT PRIMARY KEY);
CREATE TABLE active_provider_selections (
codex_dir TEXT PRIMARY KEY,
provider_id TEXT NOT NULL,
updated_at TEXT NOT NULL
);
INSERT INTO providers (id) VALUES ('original'), ('copy'), ('other');",
)
.expect("create selection test schema");
conn
}
fn scope(name: &str) -> PathBuf {
std::env::temp_dir().join(format!("codex-x-selection-{name}"))
}
#[test]
fn exact_duplicate_does_not_replace_the_remembered_current_provider() {
let conn = test_connection();
let codex_dir = scope("duplicate");
remember_active_provider_on_connection(&conn, &codex_dir, "original")
.expect("remember original provider");
let active = reconcile_active_provider_on_connection(
&conn,
&codex_dir,
&["original".to_string(), "copy".to_string()],
)
.expect("reconcile duplicate profiles");
assert_eq!(active.as_deref(), Some("original"));
}
#[test]
fn selections_are_independent_for_each_codex_directory() {
let conn = test_connection();
let first = scope("first");
let second = scope("second");
remember_active_provider_on_connection(&conn, &first, "original")
.expect("remember first selection");
remember_active_provider_on_connection(&conn, &second, "copy")
.expect("remember second selection");
let candidates = ["original".to_string(), "copy".to_string()];
assert_eq!(
reconcile_active_provider_on_connection(&conn, &first, &candidates)
.expect("reconcile first selection")
.as_deref(),
Some("original")
);
assert_eq!(
reconcile_active_provider_on_connection(&conn, &second, &candidates)
.expect("reconcile second selection")
.as_deref(),
Some("copy")
);
}
#[test]
fn stale_selection_falls_back_only_when_the_live_match_is_unique() {
let conn = test_connection();
let codex_dir = scope("stale");
remember_active_provider_on_connection(&conn, &codex_dir, "original")
.expect("remember stale selection");
assert_eq!(
reconcile_active_provider_on_connection(&conn, &codex_dir, &["other".to_string()])
.expect("reconcile unique replacement")
.as_deref(),
Some("other")
);
assert_eq!(
selected_provider_id_on_connection(&conn, &codex_dir)
.expect("read repaired selection")
.as_deref(),
Some("other")
);
assert!(
reconcile_active_provider_on_connection(&conn, &codex_dir, &[])
.expect("clear unmatched selection")
.is_none()
);
assert!(selected_provider_id_on_connection(&conn, &codex_dir)
.expect("read cleared selection")
.is_none());
}
}
File diff suppressed because it is too large Load Diff
@@ -200,17 +200,6 @@ pub(super) fn create_provider_sync_backup(
existed: path.exists(),
});
}
for name in [
"config.toml",
".codex-global-state.json",
".codex-global-state.json.bak",
] {
snapshots.push(copy_file_to_backup(
codex_dir,
&backup_dir,
&codex_dir.join(name),
)?);
}
for path in changed_rollouts {
snapshots.push(copy_file_to_backup(codex_dir, &backup_dir, path)?);
}
File diff suppressed because it is too large Load Diff
+18 -29
View File
@@ -1,8 +1,9 @@
use super::app_server::delete_sessions_via_codex_app_server;
use super::storage::{
current_model_provider, discover_sqlite_databases, ensure_sqlite_discovery_writable,
scan_rollouts, split_line_ending, sqlite_subagent_thread_ids, sqlite_thread_needs_alignment,
SqliteDiscovery, SqliteThreadIndexState,
is_canonical_rollout_storage_path, rollout_filename_matches_id, scan_rollouts,
split_line_ending, sqlite_subagent_thread_ids, sqlite_thread_needs_alignment, SqliteDiscovery,
SqliteThreadIndexState,
};
use super::sync::{acquire_session_maintenance_lock, session_sync_status_with_discovery};
use super::types::SessionSyncStatus;
@@ -313,7 +314,9 @@ fn active_session_storage_snapshot(
if cols.contains("model_provider") {
let cwd_col = sql_select_column(&cols, "cwd", "NULL");
let query = format!("SELECT id, model_provider, {cwd_col} FROM threads");
let archived_col = sql_select_column(&cols, "archived", "0");
let query =
format!("SELECT id, model_provider, {cwd_col}, {archived_col} FROM threads");
let mut mismatch_stmt = conn
.prepare(&query)
.map_err(|error| CodexxError::Database(error.to_string()))?;
@@ -323,11 +326,12 @@ fn active_session_storage_snapshot(
row.get::<_, String>(0)?,
row.get::<_, Option<String>>(1)?,
row.get::<_, Option<String>>(2)?,
row.get::<_, i64>(3)?,
))
})
.map_err(|error| CodexxError::Database(error.to_string()))?;
for row in mismatches {
let (id, provider, cwd) =
let (id, provider, cwd, archived) =
row.map_err(|error| CodexxError::Database(error.to_string()))?;
if sqlite_thread_needs_alignment(
&rollouts,
@@ -337,6 +341,7 @@ fn active_session_storage_snapshot(
provider: provider.as_deref(),
cwd: cwd.as_deref(),
cwd_column: cols.contains("cwd"),
archived: archived != 0,
},
) {
snapshot.mismatched_ids.insert(id);
@@ -387,30 +392,6 @@ fn collect_rollout_storage_paths(root: &Path, out: &mut Vec<PathBuf>) {
}
}
fn rollout_filename_matches_id(path: &Path, id: &str) -> bool {
path.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| {
name.ends_with(&format!("-{id}.jsonl")) || name.ends_with(&format!("-{id}.jsonl.zst"))
})
}
fn canonical_rollout_storage_roots(codex_dir: &Path) -> Vec<PathBuf> {
[
codex_dir.join("sessions"),
codex_dir.join("archived_sessions"),
]
.into_iter()
.filter_map(|root| root.canonicalize().ok())
.collect()
}
fn is_canonical_rollout_storage_path(codex_dir: &Path, path: &Path) -> bool {
canonical_rollout_storage_roots(codex_dir)
.iter()
.any(|root| path.starts_with(root))
}
fn canonical_rollout_path(codex_dir: &Path, value: &str, id: &str) -> Result<Option<PathBuf>> {
let raw = PathBuf::from(value.trim());
let path = if raw.is_absolute() {
@@ -932,6 +913,11 @@ pub(crate) fn delete_codex_sessions_inner(
.intersection(&storage_before.subagent_ids)
.count();
let deleted_top_level = deleted_active_ids.len().saturating_sub(deleted_subagents);
let deleted_mismatched_sessions = fallback
.sessions
.iter()
.filter(|item| item.needs_sync && deleted_active_ids.contains(&item.id))
.count();
fallback
.sessions
.retain(|item| !counts.deleted_ids.contains(&item.id));
@@ -944,7 +930,10 @@ pub(crate) fn delete_codex_sessions_inner(
fallback.mismatched_threads = fallback
.mismatched_threads
.saturating_sub(deleted_mismatched);
fallback.needs_sync = fallback.mismatched_threads > 0;
fallback.mismatched_sessions = fallback
.mismatched_sessions
.saturating_sub(deleted_mismatched_sessions);
fallback.needs_sync = fallback.mismatched_sessions > 0;
fallback.warnings.push(message);
fallback
}
@@ -1,17 +1,3 @@
use crate::error::{CodexxError, Result};
use crate::file_io::{atomic_write, io_err, json_err, write_text};
use serde_json::{json, Map, Value};
use std::collections::HashSet;
use std::fs;
use std::path::Path;
#[derive(Debug, Clone)]
pub(super) struct GlobalStateWrite {
pub(super) path: std::path::PathBuf,
pub(super) original_bytes: Option<Vec<u8>>,
pub(super) written_bytes: Vec<u8>,
}
pub(super) fn normalize_workspace_path(value: &str) -> Option<String> {
let trimmed = value.trim();
if trimmed.is_empty() {
@@ -26,313 +12,3 @@ pub(super) fn normalize_workspace_path(value: &str) -> Option<String> {
}
Some(trimmed.to_string())
}
fn load_global_state(path: &Path) -> Result<Map<String, Value>> {
if !path.exists() {
return Ok(Map::new());
}
let text = fs::read_to_string(path).map_err(|error| io_err(path, error))?;
let value = serde_json::from_str::<Value>(&text).map_err(|error| json_err(path, error))?;
Ok(value.as_object().cloned().unwrap_or_default())
}
pub(super) fn projectless_thread_ids(path: &Path) -> Result<HashSet<String>> {
let state = load_global_state(path)?;
Ok(state
.get("projectless-thread-ids")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(Value::as_str)
.map(str::trim)
.filter(|id| !id.is_empty())
.map(ToString::to_string)
.collect())
}
fn normalized_path_array(value: &Value) -> Vec<String> {
if let Some(items) = value.as_array() {
items
.iter()
.filter_map(Value::as_str)
.filter_map(normalize_workspace_path)
.collect()
} else {
value
.as_str()
.and_then(normalize_workspace_path)
.into_iter()
.collect()
}
}
fn dedupe_workspace_paths(paths: Vec<String>) -> Vec<String> {
let mut seen = HashSet::new();
paths
.into_iter()
.filter(|path| {
seen.insert(
path.replace('/', r"\")
.trim_end_matches('\\')
.to_ascii_lowercase(),
)
})
.collect()
}
fn normalized_global_state(state: &Map<String, Value>) -> Map<String, Value> {
let mut next = Map::new();
for key in ["electron-saved-workspace-roots", "project-order"] {
if let Some(value) = state.get(key) {
next.insert(
key.to_string(),
json!(dedupe_workspace_paths(normalized_path_array(value))),
);
}
}
if let Some(value) = state.get("active-workspace-roots") {
let normalized = dedupe_workspace_paths(normalized_path_array(value));
let next_value = if value.is_array() {
json!(normalized)
} else if let Some(first) = normalized.first() {
json!(first)
} else {
value.clone()
};
next.insert("active-workspace-roots".to_string(), next_value);
}
if let Some(labels) = state
.get("electron-workspace-root-labels")
.and_then(Value::as_object)
{
let mut normalized = Map::new();
for (path, value) in labels {
normalized.insert(
normalize_workspace_path(path).unwrap_or_else(|| path.clone()),
value.clone(),
);
}
next.insert(
"electron-workspace-root-labels".to_string(),
Value::Object(normalized),
);
}
if let Some(open_targets) = state
.get("open-in-target-preferences")
.and_then(Value::as_object)
{
let mut normalized = open_targets.clone();
if let Some(per_path) = open_targets.get("perPath").and_then(Value::as_object) {
let mut normalized_per_path = Map::new();
for (path, value) in per_path {
normalized_per_path.insert(
normalize_workspace_path(path).unwrap_or_else(|| path.clone()),
value.clone(),
);
}
normalized.insert("perPath".to_string(), Value::Object(normalized_per_path));
}
next.insert(
"open-in-target-preferences".to_string(),
Value::Object(normalized),
);
}
next
}
pub(super) fn count_global_state_updates(path: &Path) -> Result<usize> {
let state = load_global_state(path)?;
let next = normalized_global_state(&state);
Ok(next
.iter()
.filter(|(key, value)| state.get(*key) != Some(*value))
.count())
}
#[cfg(test)]
fn apply_global_state_update(path: &Path) -> Result<usize> {
apply_global_state_update_with_journal(path, &mut Vec::new(), &mut || Ok(()))
}
fn read_optional_bytes(path: &Path) -> Result<Option<Vec<u8>>> {
match fs::read(path) {
Ok(bytes) => Ok(Some(bytes)),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(error) => Err(io_err(path, error)),
}
}
pub(super) fn apply_global_state_update_with_journal<F>(
path: &Path,
writes: &mut Vec<GlobalStateWrite>,
after_main_write: &mut F,
) -> Result<usize>
where
F: FnMut() -> Result<()>,
{
let original_bytes = read_optional_bytes(path)?;
let mut state = match &original_bytes {
Some(bytes) => serde_json::from_slice::<Value>(bytes)
.map_err(|error| json_err(path, error))?
.as_object()
.cloned()
.unwrap_or_default(),
None => Map::new(),
};
let next = normalized_global_state(&state);
let updated = next
.iter()
.filter(|(key, value)| state.get(*key) != Some(*value))
.count();
if updated > 0 {
for (key, value) in next {
state.insert(key, value);
}
let text = serde_json::to_string_pretty(&Value::Object(state))
.map_err(|error| json_err(path, error))?;
let written_bytes = text.as_bytes().to_vec();
if read_optional_bytes(path)? != original_bytes {
return Err(CodexxError::Config(
"全局状态已发生变化,请重试。".to_string(),
));
}
write_text(path, &text)?;
writes.push(GlobalStateWrite {
path: path.to_path_buf(),
original_bytes,
written_bytes: written_bytes.clone(),
});
after_main_write()?;
if let Some(parent) = path.parent() {
let backup = parent.join(".codex-global-state.json.bak");
let original_bytes = read_optional_bytes(&backup)?;
if read_optional_bytes(&backup)? != original_bytes {
return Err(CodexxError::Config(
"全局状态已发生变化,请重试。".to_string(),
));
}
write_text(&backup, &text)?;
writes.push(GlobalStateWrite {
path: backup,
original_bytes,
written_bytes,
});
}
}
Ok(updated)
}
pub(super) fn restore_global_write(write: &GlobalStateWrite) -> Result<()> {
match fs::read(&write.path) {
Ok(current) if current == write.written_bytes => {}
Ok(_) => {
return Err(CodexxError::Config(format!(
"全局状态已发生变化,无法安全恢复: {}",
write.path.display()
)));
}
Err(error)
if error.kind() == std::io::ErrorKind::NotFound && write.original_bytes.is_none() =>
{
return Ok(());
}
Err(error) => return Err(io_err(&write.path, error)),
}
if let Some(original) = &write.original_bytes {
atomic_write(&write.path, original)
} else {
fs::remove_file(&write.path).map_err(|error| io_err(&write.path, error))
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
use std::sync::atomic::{AtomicU64, Ordering};
fn temp_dir(name: &str) -> PathBuf {
static COUNTER: AtomicU64 = AtomicU64::new(0);
let path = std::env::temp_dir().join(format!(
"codex-x-global-state-{name}-{}-{}",
std::process::id(),
COUNTER.fetch_add(1, Ordering::Relaxed),
));
let _ = fs::remove_dir_all(&path);
fs::create_dir_all(&path).expect("create test directory");
path
}
#[test]
fn global_state_update_keeps_unknown_fields_and_matches_backup() {
let dir = temp_dir("update");
let path = dir.join(".codex-global-state.json");
fs::write(
&path,
r#"{
"electron-saved-workspace-roots": "/tmp/project",
"unrelated-setting": { "enabled": true }
}"#,
)
.expect("write original global state");
assert_eq!(
apply_global_state_update(&path).expect("update global state"),
1
);
let main_text = fs::read_to_string(&path).expect("read global state");
let backup_path = dir.join(".codex-global-state.json.bak");
let backup_text = fs::read_to_string(&backup_path).expect("read global state backup");
assert_eq!(main_text, backup_text);
let state: Value = serde_json::from_str(&main_text).expect("parse global state");
assert_eq!(
state.get("electron-saved-workspace-roots"),
Some(&json!(["/tmp/project"]))
);
assert_eq!(
state.get("unrelated-setting"),
Some(&json!({ "enabled": true }))
);
fs::remove_dir_all(dir).expect("remove test directory");
}
#[test]
fn restores_distinct_existing_global_main_and_backup() {
let dir = temp_dir("distinct-snapshots");
let main = dir.join(".codex-global-state.json");
let backup = dir.join(".codex-global-state.json.bak");
let original_main = br#"{"source":"main","value":1}"#;
let original_backup = br#"{"source":"backup","value":2}"#;
let written_text = r#"{"source":"mutated"}"#.to_string();
fs::write(&main, original_main).expect("write original main");
fs::write(&backup, original_backup).expect("write original backup");
write_text(&main, &written_text).expect("mutate global main");
write_text(&backup, &written_text).expect("mutate global backup");
let writes = [
GlobalStateWrite {
path: main.clone(),
original_bytes: Some(original_main.to_vec()),
written_bytes: written_text.as_bytes().to_vec(),
},
GlobalStateWrite {
path: backup.clone(),
original_bytes: Some(original_backup.to_vec()),
written_bytes: written_text.into_bytes(),
},
];
for write in writes.iter().rev() {
restore_global_write(write).expect("restore global write");
}
assert_eq!(fs::read(&main).expect("read restored main"), original_main);
assert_eq!(
fs::read(&backup).expect("read restored backup"),
original_backup
);
fs::remove_dir_all(dir).expect("remove test directory");
}
}
+5 -1
View File
@@ -1,5 +1,6 @@
mod app_server;
mod backup;
mod catalog;
mod delete;
mod global_state;
mod storage;
@@ -14,11 +15,14 @@ pub(crate) use backup::{
#[cfg(test)]
pub(crate) use delete::{active_session_ids_present, hard_delete_sessions_locally};
pub(crate) use delete::{delete_codex_sessions_inner, SessionDeleteInput, SessionDeleteResult};
pub(crate) use storage::sqlite_candidate_paths;
#[cfg(test)]
pub(crate) use storage::{
apply_session_changes, list_session_previews, restore_session_changes, scan_rollouts,
scan_sqlite, sqlite_session_db_paths,
};
pub(crate) use storage::{
session_project_title, session_titles_by_id, source_value_is_subagent, sqlite_candidate_paths,
usage_thread_identities, UsageThreadIdentity,
};
pub(crate) use sync::{session_sync_status_inner, sync_sessions_provider_inner};
pub(crate) use types::{SessionSyncResult, SessionSyncStatus};
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,5 +1,6 @@
use super::global_state::{
apply_global_state_update_with_journal, restore_global_write, GlobalStateWrite,
use super::catalog::{
apply_catalog_updates, catalog_columns, create_catalog_rollback_tables,
restore_catalog_updates, CatalogRepairThread,
};
use super::storage::{apply_session_changes, restore_session_changes};
use super::types::{RolloutScan, SessionFileChange};
@@ -7,7 +8,7 @@ use crate::error::{CodexxError, Result};
use crate::file_io::io_err;
use crate::sqlite_utils::{sqlite_has_table, table_column_set};
use rusqlite::{Connection, OpenFlags};
use std::collections::HashSet;
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::time::Duration;
@@ -15,16 +16,18 @@ use std::time::Duration;
pub(super) struct SqliteUpdateCounts {
provider_rows: usize,
cwd_rows: usize,
catalog_insert_rows: usize,
}
impl SqliteUpdateCounts {
pub(super) fn total(&self) -> usize {
self.provider_rows + self.cwd_rows
self.provider_rows + self.cwd_rows + self.catalog_insert_rows
}
fn add(&mut self, other: Self) {
self.provider_rows += other.provider_rows;
self.cwd_rows += other.cwd_rows;
self.catalog_insert_rows += other.catalog_insert_rows;
}
}
@@ -32,7 +35,6 @@ impl SqliteUpdateCounts {
pub(super) struct MutationJournal {
applied_rollouts: Vec<SessionFileChange>,
sqlite_restore_attempts: Vec<SqliteRestoreAttempt>,
global_writes: Vec<GlobalStateWrite>,
}
#[derive(Debug, Clone)]
@@ -45,7 +47,8 @@ pub(super) struct PendingSqliteUpdate {
path: PathBuf,
conn: Connection,
observer: Connection,
columns: HashSet<String>,
thread_columns: HashSet<String>,
catalog_columns: HashSet<String>,
counts: SqliteUpdateCounts,
transaction_open: bool,
}
@@ -80,7 +83,8 @@ fn create_sqlite_rollback_table(conn: &Connection) -> Result<()> {
cwd_changed INTEGER NOT NULL DEFAULT 0
);",
)
.map_err(|error| CodexxError::Database(error.to_string()))
.map_err(|error| CodexxError::Database(error.to_string()))?;
create_catalog_rollback_tables(conn)
}
pub(super) fn prepare_sqlite_updates(sqlite_paths: &[PathBuf]) -> Result<Vec<PendingSqliteUpdate>> {
@@ -107,11 +111,17 @@ pub(super) fn prepare_sqlite_updates(sqlite_paths: &[PathBuf]) -> Result<Vec<Pen
})?;
conn.busy_timeout(Duration::from_secs(5))
.map_err(|error| CodexxError::Database(error.to_string()))?;
if !sqlite_has_table(&conn, "threads")? {
continue;
}
let columns = table_column_set(&conn, "threads")?;
if !columns.contains("id") || !columns.contains("model_provider") {
let thread_columns = if sqlite_has_table(&conn, "threads")? {
table_column_set(&conn, "threads")?
} else {
HashSet::new()
};
let catalog_columns = catalog_columns(&conn)?;
let supports_thread_updates =
thread_columns.contains("id") && thread_columns.contains("model_provider");
let supports_catalog_updates =
catalog_columns.contains("thread_id") && catalog_columns.contains("model_provider");
if !supports_thread_updates && !supports_catalog_updates {
continue;
}
conn.execute_batch("BEGIN IMMEDIATE")
@@ -147,7 +157,8 @@ pub(super) fn prepare_sqlite_updates(sqlite_paths: &[PathBuf]) -> Result<Vec<Pen
path: identity,
conn,
observer,
columns,
thread_columns,
catalog_columns,
counts: SqliteUpdateCounts::default(),
transaction_open: true,
});
@@ -165,53 +176,86 @@ fn apply_sqlite_updates(
pending: &mut [PendingSqliteUpdate],
rollouts: &RolloutScan,
target_provider: &str,
catalog_sources: &HashMap<String, CatalogRepairThread>,
syncable_thread_ids: &HashSet<String>,
) -> Result<()> {
let mut sorted_thread_ids = syncable_thread_ids.iter().collect::<Vec<_>>();
sorted_thread_ids.sort();
for update in pending.iter_mut() {
update
.conn
.execute(
if update.thread_columns.contains("id") && update.thread_columns.contains("model_provider")
{
let archived_filter = if update.thread_columns.contains("archived") {
" AND COALESCE(archived, 0) = 0"
} else {
""
};
let snapshot_sql = format!(
"INSERT INTO temp.codexx_session_rollback
(id, model_provider, provider_changed)
SELECT id, model_provider, 1 FROM threads
WHERE COALESCE(model_provider, '') <> ?1
ON CONFLICT(id) DO UPDATE SET
model_provider = excluded.model_provider,
provider_changed = 1",
[target_provider],
)
.map_err(|error| CodexxError::Database(error.to_string()))?;
update.counts.provider_rows = update
.conn
.execute(
"UPDATE threads SET model_provider = ?1 WHERE COALESCE(model_provider, '') <> ?1",
[target_provider],
)
.map_err(|error| CodexxError::Database(error.to_string()))?;
if update.columns.contains("id") && update.columns.contains("cwd") {
for (thread_id, cwd) in &rollouts.cwd_by_thread_id {
(id, model_provider, provider_changed)
SELECT id, model_provider, 1 FROM threads
WHERE id = ?2 AND COALESCE(model_provider, '') <> ?1{archived_filter}
ON CONFLICT(id) DO UPDATE SET
model_provider = excluded.model_provider,
provider_changed = 1"
);
let update_sql = format!(
"UPDATE threads SET model_provider = ?1 \
WHERE id = ?2 AND COALESCE(model_provider, '') <> ?1{archived_filter}"
);
for thread_id in &sorted_thread_ids {
update
.conn
.execute(
"INSERT INTO temp.codexx_session_rollback
(id, cwd, cwd_changed)
SELECT id, cwd, 1 FROM threads
WHERE id = ?2 AND COALESCE(cwd, '') <> ?1
ON CONFLICT(id) DO UPDATE SET
cwd = excluded.cwd,
cwd_changed = 1",
(cwd, thread_id),
)
.execute(&snapshot_sql, (target_provider, thread_id))
.map_err(|error| CodexxError::Database(error.to_string()))?;
update.counts.cwd_rows += update
update.counts.provider_rows += update
.conn
.execute(
"UPDATE threads SET cwd = ?1 WHERE id = ?2 AND COALESCE(cwd, '') <> ?1",
(cwd, thread_id),
)
.execute(&update_sql, (target_provider, thread_id))
.map_err(|error| CodexxError::Database(error.to_string()))?;
}
}
if update.thread_columns.contains("id") && update.thread_columns.contains("cwd") {
let archived_filter = if update.thread_columns.contains("archived") {
" AND COALESCE(archived, 0) = 0"
} else {
""
};
let snapshot_sql = format!(
"INSERT INTO temp.codexx_session_rollback
(id, cwd, cwd_changed)
SELECT id, cwd, 1 FROM threads
WHERE id = ?2 AND COALESCE(cwd, '') <> ?1{archived_filter}
ON CONFLICT(id) DO UPDATE SET
cwd = excluded.cwd,
cwd_changed = 1"
);
let update_sql = format!(
"UPDATE threads SET cwd = ?1 \
WHERE id = ?2 AND COALESCE(cwd, '') <> ?1{archived_filter}"
);
for (thread_id, cwd) in &rollouts.cwd_by_thread_id {
if !syncable_thread_ids.contains(thread_id) {
continue;
}
update
.conn
.execute(&snapshot_sql, (cwd, thread_id))
.map_err(|error| CodexxError::Database(error.to_string()))?;
update.counts.cwd_rows += update
.conn
.execute(&update_sql, (cwd, thread_id))
.map_err(|error| CodexxError::Database(error.to_string()))?;
}
}
let catalog_counts = apply_catalog_updates(
&update.conn,
&update.catalog_columns,
target_provider,
catalog_sources,
syncable_thread_ids,
)?;
update.counts.provider_rows += catalog_counts.provider_rows;
update.counts.catalog_insert_rows += catalog_counts.inserted_rows;
}
Ok(())
}
@@ -273,18 +317,23 @@ fn restore_sqlite_update(
)));
}
let mut statements = vec![
"UPDATE threads
SET model_provider = (
SELECT rollback.model_provider
FROM temp.codexx_session_rollback AS rollback
WHERE rollback.id = threads.id
)
WHERE id IN (
SELECT id FROM temp.codexx_session_rollback WHERE provider_changed = 1
)",
];
if update.columns.contains("cwd") {
restore_catalog_updates(&update.conn, &update.catalog_columns)?;
let mut statements = Vec::new();
if update.thread_columns.contains("id") && update.thread_columns.contains("model_provider")
{
statements.push(
"UPDATE threads
SET model_provider = (
SELECT rollback.model_provider
FROM temp.codexx_session_rollback AS rollback
WHERE rollback.id = threads.id
)
WHERE id IN (
SELECT id FROM temp.codexx_session_rollback WHERE provider_changed = 1
)",
);
}
if update.thread_columns.contains("id") && update.thread_columns.contains("cwd") {
statements.push(
"UPDATE threads
SET cwd = (
@@ -321,11 +370,6 @@ pub(super) fn rollback_mutation(
pending_sqlite: &mut [PendingSqliteUpdate],
) -> Vec<String> {
let mut errors = Vec::new();
for write in journal.global_writes.iter().rev() {
if let Err(error) = restore_global_write(write) {
errors.push(error.to_string());
}
}
for attempt in journal.sqlite_restore_attempts.iter().rev() {
let Some(update) = pending_sqlite
.iter_mut()
@@ -357,8 +401,8 @@ pub(super) fn mutation_error(original: CodexxError, recovery_errors: Vec<String>
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum MutationPoint {
BeforeSqliteLock,
AfterSqliteCommit(usize),
AfterGlobalMainWrite,
}
pub(super) struct MutationResult {
@@ -371,7 +415,8 @@ pub(super) fn execute_provider_sync_mutation<F>(
rollouts: &RolloutScan,
pending_sqlite: &mut [PendingSqliteUpdate],
target_provider: &str,
global_state_path: &Path,
catalog_sources: &HashMap<String, CatalogRepairThread>,
syncable_thread_ids: &HashSet<String>,
journal: &mut MutationJournal,
hook: &mut F,
) -> Result<MutationResult>
@@ -381,11 +426,12 @@ where
let result = (|| -> Result<MutationResult> {
let (applied_rollouts, skipped_rollouts) = apply_session_changes(&rollouts.changes)?;
journal.applied_rollouts = applied_rollouts;
apply_sqlite_updates(pending_sqlite, rollouts, target_provider)?;
apply_global_state_update_with_journal(
global_state_path,
&mut journal.global_writes,
&mut || hook(MutationPoint::AfterGlobalMainWrite),
apply_sqlite_updates(
pending_sqlite,
rollouts,
target_provider,
catalog_sources,
syncable_thread_ids,
)?;
let sqlite_updates = commit_sqlite_updates(pending_sqlite, journal, hook)?;
Ok(MutationResult {
@@ -18,6 +18,8 @@ fn temp_dir(name: &str) -> PathBuf {
));
let _ = fs::remove_dir_all(&path);
fs::create_dir_all(&path).expect("create test directory");
fs::write(path.join("config.toml"), "model_provider = \"custom\"\n")
.expect("write shared provider config");
path
}
@@ -176,7 +178,8 @@ fn failed_sqlite_commit_rolls_back_without_snapshot_restore() {
path: database.clone(),
conn,
observer,
columns: HashSet::new(),
thread_columns: HashSet::new(),
catalog_columns: HashSet::new(),
counts: SqliteUpdateCounts::default(),
transaction_open: true,
}];
@@ -332,53 +335,7 @@ fn sqlite_prepare_deduplicates_symlink_aliases() {
}
#[test]
fn global_main_failpoint_restores_prior_mutations_without_touching_backup() {
let codex_dir = temp_dir("global-main-failpoint");
let id = "019f6000-0000-7000-8000-000000000408";
let rollout = codex_dir.join(format!("sessions/rollout-test-{id}.jsonl"));
let original_rollout = write_rollout(&rollout, id);
let database = codex_dir.join("state_10.sqlite");
create_thread_database(&database, id, &rollout);
let global = codex_dir.join(".codex-global-state.json");
let global_backup = codex_dir.join(".codex-global-state.json.bak");
let original_global = br#"{"electron-saved-workspace-roots":"/tmp/project"}"#;
fs::write(&global, original_global).expect("write original global state");
assert!(!global_backup.exists());
let error = sync_sessions_provider_with_hook(
Some(codex_dir.display().to_string()),
Some("custom".to_string()),
|point| match point {
MutationPoint::AfterGlobalMainWrite => {
assert_eq!(thread_provider(&database, id), "openai");
assert!(fs::read_to_string(&rollout)
.expect("read mutated rollout")
.contains("\"model_provider\":\"custom\""));
assert!(!global_backup.exists());
Err(CodexxError::Config("主状态写入后注入失败".to_string()))
}
_ => Ok(()),
},
)
.expect_err("fail after global main write");
assert_eq!(error.to_string(), "配置错误: 主状态写入后注入失败");
assert_eq!(thread_provider(&database, id), "openai");
assert_eq!(
fs::read(&rollout).expect("read restored rollout"),
original_rollout
);
assert_eq!(
fs::read(&global).expect("read restored global"),
original_global
);
assert!(!global_backup.exists());
fs::remove_dir_all(codex_dir).expect("remove test directory");
}
#[test]
fn injected_failure_restores_sqlite_jsonl_and_global_state() {
fn injected_failure_restores_sqlite_and_jsonl_without_touching_global_state() {
let codex_dir = temp_dir("full-mutation-rollback");
let id = "019f6000-0000-7000-8000-000000000411";
let rollout = codex_dir.join(format!("sessions/rollout-test-{id}.jsonl"));
@@ -440,10 +397,11 @@ fn injected_failure_restores_sqlite_jsonl_and_global_state() {
assert!(fs::read_to_string(&rollout)
.expect("read mutated rollout")
.contains("\"model_provider\":\"custom\""));
let main = fs::read_to_string(&global).expect("read mutated global state");
let backup =
fs::read_to_string(&global_backup).expect("read mutated global backup");
assert_eq!(main, backup);
assert_eq!(
fs::read(&global).expect("read untouched global state"),
original_global
);
assert!(!global_backup.exists());
Err(CodexxError::Config("测试注入失败".to_string()))
}
_ => Ok(()),
@@ -478,10 +436,18 @@ fn injected_failure_restores_sqlite_jsonl_and_global_state() {
original_global
);
assert!(!global_backup.exists());
assert!(fs::read_dir(provider_sync_backup_root(&codex_dir))
let retained_backup = fs::read_dir(provider_sync_backup_root(&codex_dir))
.expect("read retained provider sync backup")
.next()
.is_some());
.expect("retained provider sync backup")
.expect("read retained provider sync backup entry")
.path();
let metadata = fs::read_to_string(retained_backup.join("metadata.json"))
.expect("read retained provider sync metadata");
assert!(!metadata.contains("config.toml"));
assert!(!metadata.contains(".codex-global-state.json"));
assert!(!retained_backup.join("config.toml").exists());
assert!(!retained_backup.join(".codex-global-state.json").exists());
drop(wal_guard);
fs::remove_dir_all(codex_dir).expect("remove test directory");
+15 -1
View File
@@ -1,5 +1,5 @@
use serde::Serialize;
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
use std::time::SystemTime;
@@ -33,7 +33,10 @@ pub(crate) struct SessionSyncStatus {
pub(crate) top_level_threads: usize,
pub(crate) subagent_threads: usize,
pub(crate) mismatched_threads: usize,
pub(crate) mismatched_sessions: usize,
pub(crate) needs_sync: bool,
pub(crate) scan_complete: bool,
pub(crate) scan_failures: Vec<String>,
pub(crate) backup_dir: Option<String>,
pub(crate) warnings: Vec<String>,
pub(crate) sessions: Vec<SessionPreview>,
@@ -50,13 +53,17 @@ pub(crate) struct SessionSyncResult {
#[derive(Debug, Default)]
pub(crate) struct RolloutScan {
pub(crate) discovered_rollout_files: usize,
pub(crate) rollout_files: usize,
pub(crate) session_meta_count: usize,
pub(crate) mismatched_rollouts: usize,
pub(crate) mismatched_session_meta: usize,
pub(crate) changes: Vec<SessionFileChange>,
pub(crate) cwd_by_thread_id: HashMap<String, String>,
pub(crate) thread_ids: HashSet<String>,
pub(crate) mismatched_thread_ids: HashSet<String>,
pub(crate) warnings: Vec<String>,
pub(crate) scan_failures: Vec<String>,
}
#[derive(Debug, Clone)]
@@ -74,5 +81,12 @@ pub(crate) struct SqliteScan {
pub(crate) top_level_threads: usize,
pub(crate) subagent_threads: usize,
pub(crate) mismatched_threads: usize,
pub(crate) thread_ids: HashSet<String>,
pub(crate) syncable_thread_ids: HashSet<String>,
pub(crate) archived_thread_ids: HashSet<String>,
pub(crate) subagent_thread_ids: HashSet<String>,
pub(crate) rollout_paths_by_thread_id: HashMap<String, String>,
pub(crate) mismatched_thread_ids: HashSet<String>,
pub(crate) warnings: Vec<String>,
pub(crate) scan_failures: Vec<String>,
}
+428 -39
View File
@@ -1,16 +1,25 @@
use super::build_skills_mcp_state_inner;
use super::types::{ManagedMcpServer, SkillsMcpState};
use crate::backups::create_backup;
use crate::ccswitch::default_ccswitch_db_path;
use crate::error::{CodexxError, Result};
use crate::file_io::{ensure_directory, parse_toml_document, read_to_string_if_exists, write_text};
use crate::file_io::{ensure_directory, parse_toml_document, read_to_string_if_exists};
use crate::live_config::{
acquire_live_config_lock, apply_file_change, fail_with_file_rollback, read_file_snapshot,
text_from_snapshot,
};
use crate::toml_utils::ensure_table;
use crate::{config_path, now_rfc3339, open_db, resolve_codex_dir};
use rusqlite::{params, Connection, OpenFlags};
use rusqlite::{
params, Connection, OpenFlags, OptionalExtension, Transaction, TransactionBehavior,
};
use serde_json::{json, Value};
use std::collections::HashSet;
use std::path::Path;
use toml_edit::{value, Item, Table};
type CcSwitchMcpCandidate = (String, String, Value, bool);
fn toml_value_to_json(value: &toml_edit::Value) -> Value {
if let Some(s) = value.as_str() {
return json!(s);
@@ -134,8 +143,13 @@ pub(super) fn mcp_summary(config: &Value) -> (String, Option<String>, Option<Str
(transport, command, url, summary)
}
pub(super) fn save_managed_mcp(id: &str, name: &str, config: &Value, enabled: bool) -> Result<()> {
let conn = open_db()?;
fn save_managed_mcp_on_connection(
conn: &Connection,
id: &str,
name: &str,
config: &Value,
enabled: bool,
) -> Result<()> {
conn.execute(
"INSERT INTO managed_mcp_servers (id, name, server_config, enabled, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5)
@@ -156,6 +170,83 @@ pub(super) fn save_managed_mcp(id: &str, name: &str, config: &Value, enabled: bo
Ok(())
}
pub(super) fn save_managed_mcp(id: &str, name: &str, config: &Value, enabled: bool) -> Result<()> {
let conn = open_db()?;
save_managed_mcp_on_connection(&conn, id, name, config, enabled)
}
fn managed_mcp_on_connection(conn: &Connection, id: &str) -> Result<Option<(String, Value, bool)>> {
conn.query_row(
"SELECT name, server_config, enabled FROM managed_mcp_servers WHERE id = ?1 LIMIT 1",
[id],
|row| {
let config_text: String = row.get(1)?;
Ok((
row.get(0)?,
serde_json::from_str(&config_text).unwrap_or(Value::Object(Default::default())),
row.get(2)?,
))
},
)
.optional()
.map_err(|error| CodexxError::Database(error.to_string()))
}
fn document_mcp_ids(doc: &toml_edit::DocumentMut) -> HashSet<String> {
doc.get("mcp_servers")
.and_then(|item| item.as_table())
.map(|table| {
table
.iter()
.filter(|(_, item)| item.is_table())
.map(|(id, _)| id.to_string())
.collect()
})
.unwrap_or_default()
}
fn document_bytes(snapshot: Option<&[u8]>, doc: &toml_edit::DocumentMut) -> Option<Vec<u8>> {
let bytes = doc.to_string().into_bytes();
if snapshot.is_none() && bytes.is_empty() {
None
} else {
Some(bytes)
}
}
fn commit_mcp_transaction_with_config<BeforeApply, BeforeCommit>(
transaction: Transaction<'_>,
codex_dir: &Path,
before: Option<Vec<u8>>,
after: Option<Vec<u8>>,
backup_action: &str,
before_apply: BeforeApply,
before_commit: BeforeCommit,
) -> Result<()>
where
BeforeApply: FnOnce(&Path) -> Result<()>,
BeforeCommit: FnOnce(&Transaction<'_>) -> Result<()>,
{
let cfg = config_path(codex_dir);
let mut changes = Vec::new();
if before != after {
create_backup(codex_dir, backup_action)?;
before_apply(&cfg)?;
changes.push(apply_file_change(&cfg, before, after)?);
}
if let Err(error) = before_commit(&transaction) {
return fail_with_file_rollback(error, &changes);
}
if let Err(error) = transaction
.commit()
.map_err(|error| CodexxError::Database(error.to_string()))
{
return fail_with_file_rollback(error, &changes);
}
Ok(())
}
pub(super) fn db_managed_mcp() -> Result<Vec<(String, String, Value, bool)>> {
let conn = open_db()?;
let mut stmt = conn.prepare("SELECT id, name, server_config, enabled FROM managed_mcp_servers ORDER BY name ASC, id ASC")
@@ -206,6 +297,7 @@ pub(super) fn list_mcp_from_config(codex_dir: &Path) -> Result<Vec<ManagedMcpSer
enabled: true,
source: "config.toml".to_string(),
summary,
note: None,
command,
url,
config_json: config,
@@ -264,36 +356,76 @@ pub(super) fn import_ccswitch_mcp_servers_for_codex(
))
})
.map_err(|e| CodexxError::Database(e.to_string()))?;
let cfg = config_path(codex_dir);
let text = read_to_string_if_exists(&cfg)?;
let mut doc = parse_toml_document(&cfg, &text)?;
let live_enabled = list_mcp_from_config(codex_dir)?
.into_iter()
.map(|server| server.id)
.collect::<HashSet<_>>();
let mut imported = 0usize;
let mut changed_config = false;
let mut candidates = Vec::new();
for row in rows {
let (id, name, config_text, enabled_codex) =
row.map_err(|e| CodexxError::Database(e.to_string()))?;
let config: Value =
serde_json::from_str(&config_text).unwrap_or(Value::Object(Default::default()));
if !imported_ids.insert(id.clone()) {
continue;
}
let enabled = enabled_codex || live_enabled.contains(&id);
save_managed_mcp(&id, &name, &config, enabled)?;
if enabled_codex && !live_enabled.contains(&id) {
ensure_table(doc.as_table_mut(), "mcp_servers")?
.insert(&id, json_to_toml_item(&config));
changed_config = true;
}
imported += 1;
candidates.push((id, name, config, enabled_codex));
}
if changed_config {
write_text(&cfg, &doc.to_string())?;
import_ccswitch_mcp_candidates_with_hooks(
codex_dir,
imported_ids,
candidates,
|_| Ok(()),
|_| Ok(()),
)
}
fn import_ccswitch_mcp_candidates_with_hooks<BeforeApply, BeforeCommit>(
codex_dir: &Path,
imported_ids: &mut HashSet<String>,
candidates: Vec<CcSwitchMcpCandidate>,
before_apply: BeforeApply,
before_commit: BeforeCommit,
) -> Result<usize>
where
BeforeApply: FnOnce(&Path) -> Result<()>,
BeforeCommit: FnOnce(&Transaction<'_>) -> Result<()>,
{
let mut staged_ids = HashSet::new();
let candidates = candidates
.into_iter()
.filter(|(id, _, _, _)| !imported_ids.contains(id) && staged_ids.insert(id.clone()))
.collect::<Vec<_>>();
if candidates.is_empty() {
return Ok(0);
}
ensure_directory(codex_dir)?;
let _live_lock = acquire_live_config_lock(codex_dir)?;
let cfg = config_path(codex_dir);
let before = read_file_snapshot(&cfg)?;
let text = text_from_snapshot(&cfg, before.as_deref())?;
let mut doc = parse_toml_document(&cfg, &text)?;
let live_enabled = document_mcp_ids(&doc);
let mut app_conn = open_db()?;
let transaction = app_conn
.transaction_with_behavior(TransactionBehavior::Immediate)
.map_err(|error| CodexxError::Database(error.to_string()))?;
for (id, name, config, enabled_codex) in &candidates {
let enabled = *enabled_codex || live_enabled.contains(id);
save_managed_mcp_on_connection(&transaction, id, name, config, enabled)?;
if *enabled_codex && !live_enabled.contains(id) {
ensure_table(doc.as_table_mut(), "mcp_servers")?.insert(id, json_to_toml_item(config));
}
}
let after = document_bytes(before.as_deref(), &doc);
commit_mcp_transaction_with_config(
transaction,
codex_dir,
before,
after,
"import-ccswitch-mcp",
before_apply,
before_commit,
)?;
let imported = candidates.len();
imported_ids.extend(staged_ids);
Ok(imported)
}
@@ -355,6 +487,7 @@ pub(super) fn preview_ccswitch_mcp_servers_for_codex(
enabled: enabled_codex || live_enabled.contains(&id),
source: "cc-switch".to_string(),
summary,
note: None,
command,
url,
config_json: config,
@@ -368,31 +501,287 @@ pub(crate) fn toggle_codex_mcp_inner(
id: String,
enabled: bool,
) -> Result<SkillsMcpState> {
toggle_codex_mcp_with_hooks(config_dir, id, enabled, |_| Ok(()), |_| Ok(()))
}
fn toggle_codex_mcp_with_hooks<BeforeApply, BeforeCommit>(
config_dir: Option<String>,
id: String,
enabled: bool,
before_apply: BeforeApply,
before_commit: BeforeCommit,
) -> Result<SkillsMcpState>
where
BeforeApply: FnOnce(&Path) -> Result<()>,
BeforeCommit: FnOnce(&Transaction<'_>) -> Result<()>,
{
let codex_dir = resolve_codex_dir(config_dir.clone())?;
ensure_directory(&codex_dir)?;
let _live_lock = acquire_live_config_lock(&codex_dir)?;
let cfg = config_path(&codex_dir);
let text = read_to_string_if_exists(&cfg)?;
let before = read_file_snapshot(&cfg)?;
let text = text_from_snapshot(&cfg, before.as_deref())?;
let mut doc = parse_toml_document(&cfg, &text)?;
let mut conn = open_db()?;
let transaction = conn
.transaction_with_behavior(TransactionBehavior::Immediate)
.map_err(|error| CodexxError::Database(error.to_string()))?;
let stored = managed_mcp_on_connection(&transaction, &id)?;
if enabled {
let db = db_managed_mcp()?
.into_iter()
.find(|(sid, _, _, _)| sid == &id)
.ok_or_else(|| CodexxError::Config(format!("未找到 MCP: {id}")))?;
ensure_table(doc.as_table_mut(), "mcp_servers")?.insert(&id, json_to_toml_item(&db.2));
save_managed_mcp(&id, &db.1, &db.2, true)?;
let (name, config, _) =
stored.ok_or_else(|| CodexxError::Config(format!("未找到 MCP: {id}")))?;
ensure_table(doc.as_table_mut(), "mcp_servers")?.insert(&id, json_to_toml_item(&config));
save_managed_mcp_on_connection(&transaction, &id, &name, &config, true)?;
} else {
if let Some(item) = doc
let live_config = doc
.get("mcp_servers")
.and_then(|m| m.as_table())
.and_then(|tbl| tbl.get(&id))
{
let config = toml_item_to_json(item);
save_managed_mcp(&id, &id, &config, false)?;
.map(toml_item_to_json);
if let Some(config) = live_config {
let name = stored
.as_ref()
.map(|(name, _, _)| name.as_str())
.unwrap_or(&id);
save_managed_mcp_on_connection(&transaction, &id, name, &config, false)?;
} else if let Some((name, config, _)) = stored {
save_managed_mcp_on_connection(&transaction, &id, &name, &config, false)?;
}
if let Some(tbl) = doc.get_mut("mcp_servers").and_then(|m| m.as_table_mut()) {
tbl.remove(&id);
}
}
write_text(&cfg, &doc.to_string())?;
let after = document_bytes(before.as_deref(), &doc);
commit_mcp_transaction_with_config(
transaction,
&codex_dir,
before,
after,
"toggle-mcp",
before_apply,
before_commit,
)?;
build_skills_mcp_state_inner(config_dir)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::path::PathBuf;
use std::sync::atomic::{AtomicU64, Ordering};
fn test_case(name: &str) -> (PathBuf, String) {
static COUNTER: AtomicU64 = AtomicU64::new(0);
let suffix = COUNTER.fetch_add(1, Ordering::Relaxed);
let dir = std::env::temp_dir().join(format!(
"codex-x-mcp-{name}-{}-{suffix}",
std::process::id()
));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).expect("create MCP test directory");
(dir, format!("test-mcp-{name}-{suffix}"))
}
fn database_error(error: rusqlite::Error) -> CodexxError {
CodexxError::Database(error.to_string())
}
fn remove_test_mcp(id: &str) {
open_db()
.expect("open test database")
.execute("DELETE FROM managed_mcp_servers WHERE id = ?1", [id])
.expect("remove test MCP");
}
#[test]
fn toggle_updates_only_the_target_mcp_and_keeps_database_in_sync() {
let _db_guard = crate::app_db::test_db_guard();
let (codex_dir, id) = test_case("success");
let cfg = config_path(&codex_dir);
let original = b"# keep this comment\nmodel = \"gpt-5.5\"\n\n[features]\njs_repl = false\n\n[mcp_servers.existing]\ncommand = \"existing\"\n";
fs::write(&cfg, original).expect("seed original config");
save_managed_mcp(&id, "Managed", &json!({ "command": "managed" }), false)
.expect("seed managed MCP");
let enabled_state =
toggle_codex_mcp_inner(Some(codex_dir.display().to_string()), id.clone(), true)
.expect("enable managed MCP");
assert!(enabled_state
.mcp_servers
.iter()
.any(|server| server.id == id && server.enabled));
let enabled_text = fs::read_to_string(&cfg).expect("read enabled config");
let enabled_doc = enabled_text
.parse::<toml_edit::DocumentMut>()
.expect("parse enabled config");
assert!(enabled_text.contains("# keep this comment"));
assert_eq!(enabled_doc["features"]["js_repl"].as_bool(), Some(false));
assert_eq!(
enabled_doc["mcp_servers"]["existing"]["command"].as_str(),
Some("existing")
);
assert_eq!(
enabled_doc["mcp_servers"][&id]["command"].as_str(),
Some("managed")
);
let conn = open_db().expect("open test database");
assert!(
managed_mcp_on_connection(&conn, &id)
.expect("read enabled MCP")
.expect("enabled MCP exists")
.2
);
drop(conn);
toggle_codex_mcp_inner(Some(codex_dir.display().to_string()), id.clone(), false)
.expect("disable managed MCP");
let disabled_text = fs::read_to_string(&cfg).expect("read disabled config");
let disabled_doc = disabled_text
.parse::<toml_edit::DocumentMut>()
.expect("parse disabled config");
assert!(disabled_text.contains("# keep this comment"));
assert!(disabled_doc["mcp_servers"].get(&id).is_none());
assert_eq!(
disabled_doc["mcp_servers"]["existing"]["command"].as_str(),
Some("existing")
);
let conn = open_db().expect("open test database");
let (name, config, enabled) = managed_mcp_on_connection(&conn, &id)
.expect("read disabled MCP")
.expect("disabled MCP exists");
assert_eq!(name, "Managed");
assert_eq!(config["command"].as_str(), Some("managed"));
assert!(!enabled);
drop(conn);
assert!(codex_dir.join(".codexx-test-backups").is_dir());
remove_test_mcp(&id);
fs::remove_dir_all(codex_dir).expect("remove MCP test directory");
}
#[test]
fn toggle_rejects_a_stale_snapshot_without_changing_database_or_external_config() {
let _db_guard = crate::app_db::test_db_guard();
let (codex_dir, id) = test_case("stale");
let cfg = config_path(&codex_dir);
let original = b"# original\nmodel = \"gpt-5.5\"\n";
let external = b"# external update\nmodel = \"gpt-5.5\"\n\n[mcp_servers.external]\ncommand = \"external\"\n";
fs::write(&cfg, original).expect("seed original config");
save_managed_mcp(&id, "Managed", &json!({ "command": "managed" }), false)
.expect("seed managed MCP");
let error = toggle_codex_mcp_with_hooks(
Some(codex_dir.display().to_string()),
id.clone(),
true,
|path| {
fs::write(path, external).map_err(|error| CodexxError::Config(error.to_string()))
},
|_| Ok(()),
)
.expect_err("stale MCP toggle must fail");
assert!(error.to_string().contains("已被其他程序修改"));
assert_eq!(fs::read(&cfg).expect("read external config"), external);
let conn = open_db().expect("open test database");
let (_, config, enabled) = managed_mcp_on_connection(&conn, &id)
.expect("read managed MCP")
.expect("managed MCP still exists");
assert_eq!(config["command"].as_str(), Some("managed"));
assert!(!enabled);
drop(conn);
remove_test_mcp(&id);
fs::remove_dir_all(codex_dir).expect("remove MCP test directory");
}
#[test]
fn toggle_rolls_back_config_when_database_commit_fails() {
let _db_guard = crate::app_db::test_db_guard();
let (codex_dir, id) = test_case("commit");
let cfg = config_path(&codex_dir);
let original = b"# keep exact bytes\nmodel = \"gpt-5.5\"\n\n[features]\njs_repl = false\n";
fs::write(&cfg, original).expect("seed original config");
save_managed_mcp(&id, "Managed", &json!({ "command": "managed" }), false)
.expect("seed managed MCP");
toggle_codex_mcp_with_hooks(
Some(codex_dir.display().to_string()),
id.clone(),
true,
|_| Ok(()),
|transaction| {
transaction
.execute_batch("ROLLBACK")
.map_err(database_error)
},
)
.expect_err("database commit failure must fail the toggle");
assert_eq!(fs::read(&cfg).expect("read rolled-back config"), original);
let conn = open_db().expect("open test database");
let (_, _, enabled) = managed_mcp_on_connection(&conn, &id)
.expect("read managed MCP")
.expect("managed MCP still exists");
assert!(!enabled);
drop(conn);
assert!(codex_dir.join(".codexx-test-backups").is_dir());
remove_test_mcp(&id);
fs::remove_dir_all(codex_dir).expect("remove MCP test directory");
}
#[test]
fn ccswitch_import_rolls_back_file_database_and_ids_when_commit_fails() {
let _db_guard = crate::app_db::test_db_guard();
let (codex_dir, id) = test_case("import-commit");
let second_id = format!("{id}-second");
let cfg = config_path(&codex_dir);
let original = b"# import baseline\nmodel = \"gpt-5.5\"\n";
fs::write(&cfg, original).expect("seed original config");
let mut imported_ids = HashSet::new();
import_ccswitch_mcp_candidates_with_hooks(
&codex_dir,
&mut imported_ids,
vec![
(
id.clone(),
"Imported".to_string(),
json!({ "command": "imported" }),
true,
),
(
second_id.clone(),
"Imported second".to_string(),
json!({ "command": "imported-second" }),
true,
),
],
|_| Ok(()),
|transaction| {
transaction
.execute_batch("ROLLBACK")
.map_err(database_error)
},
)
.expect_err("database commit failure must fail the import");
assert_eq!(fs::read(&cfg).expect("read rolled-back config"), original);
assert!(imported_ids.is_empty());
let conn = open_db().expect("open test database");
assert!(managed_mcp_on_connection(&conn, &id)
.expect("read imported MCP")
.is_none());
assert!(managed_mcp_on_connection(&conn, &second_id)
.expect("read second imported MCP")
.is_none());
drop(conn);
assert!(codex_dir.join(".codexx-test-backups").is_dir());
fs::remove_dir_all(codex_dir).expect("remove MCP test directory");
}
}
+276 -2
View File
@@ -18,17 +18,74 @@ pub(crate) use types::ManagedSkill;
use crate::error::Result;
use crate::file_io::{ensure_directory, io_err};
use crate::paths::home_dir;
use crate::paths::{home_dir, normalized_path_scope};
use crate::resolve_codex_dir;
use crate::{now_rfc3339, open_db};
use mcp::{
db_managed_mcp, import_ccswitch_mcp_servers_for_codex, list_mcp_from_config, mcp_summary,
preview_ccswitch_mcp_servers_for_codex, save_managed_mcp,
};
use rusqlite::params;
use skills::{
codex_skills_dir, copy_dir_recursive, disabled_skills_dir, sanitize_dir_name, scan_skill_dir,
};
use std::collections::HashSet;
use std::collections::{HashMap, HashSet};
use std::fs;
use std::path::Path;
const SKILLS_MCP_NOTE_MAX_CHARS: usize = 1000;
fn normalized_note_item_kind(item_kind: &str) -> Result<&'static str> {
match item_kind.trim().to_ascii_lowercase().as_str() {
"skill" => Ok("skill"),
"mcp" => Ok("mcp"),
_ => Err(crate::error::CodexxError::Config(
"备注类型必须是 skill 或 mcp".to_string(),
)),
}
}
fn skills_mcp_notes(codex_dir: &Path) -> Result<HashMap<(String, String), String>> {
let conn = open_db()?;
let mut stmt = conn
.prepare(
"SELECT item_kind, item_id, note
FROM skills_mcp_notes
WHERE codex_dir = ?1
ORDER BY item_kind ASC, item_id ASC",
)
.map_err(|error| crate::error::CodexxError::Database(error.to_string()))?;
let rows = stmt
.query_map([normalized_path_scope(codex_dir)], |row| {
Ok((
(row.get::<_, String>(0)?, row.get::<_, String>(1)?),
row.get::<_, String>(2)?,
))
})
.map_err(|error| crate::error::CodexxError::Database(error.to_string()))?;
let mut notes = HashMap::new();
for row in rows {
let (key, note) =
row.map_err(|error| crate::error::CodexxError::Database(error.to_string()))?;
notes.insert(key, note);
}
Ok(notes)
}
fn attach_skills_mcp_notes(
codex_dir: &Path,
skills: &mut [types::ManagedSkill],
mcp_servers: &mut [ManagedMcpServer],
) -> Result<()> {
let notes = skills_mcp_notes(codex_dir)?;
for skill in skills {
skill.note = notes.get(&("skill".to_string(), skill.id.clone())).cloned();
}
for server in mcp_servers {
server.note = notes.get(&("mcp".to_string(), server.id.clone())).cloned();
}
Ok(())
}
fn extend_unmanaged_mcp_candidates(
output: &mut Vec<ManagedMcpServer>,
@@ -82,11 +139,13 @@ pub(crate) fn build_skills_mcp_state_inner(config_dir: Option<String>) -> Result
enabled,
source: "Codex-X".to_string(),
summary,
note: None,
command,
url,
config_json: config,
});
}
attach_skills_mcp_notes(&codex_dir, &mut skills, &mut mcp_servers)?;
sort_managed_mcp_servers(&mut mcp_servers);
sort_managed_skills(&mut skills);
Ok(SkillsMcpState {
@@ -99,6 +158,63 @@ pub(crate) fn build_skills_mcp_state_inner(config_dir: Option<String>) -> Result
})
}
pub(crate) fn save_skills_mcp_note_inner(
config_dir: Option<String>,
item_kind: String,
id: String,
note: String,
) -> Result<SkillsMcpState> {
let item_kind = normalized_note_item_kind(&item_kind)?;
if id.trim().is_empty() {
return Err(crate::error::CodexxError::Config(
"备注对象 ID 不能为空".to_string(),
));
}
let id = id.as_str();
let note = note.trim();
if note.chars().count() > SKILLS_MCP_NOTE_MAX_CHARS {
return Err(crate::error::CodexxError::Config(format!(
"备注不能超过 {SKILLS_MCP_NOTE_MAX_CHARS} 个字符"
)));
}
let codex_dir = resolve_codex_dir(config_dir.clone())?;
let codex_dir_scope = normalized_path_scope(&codex_dir);
let current = build_skills_mcp_state_inner(config_dir.clone())?;
let exists = match item_kind {
"skill" => current.skills.iter().any(|skill| skill.id == id),
"mcp" => current.mcp_servers.iter().any(|server| server.id == id),
_ => false,
};
if !exists {
return Err(crate::error::CodexxError::Config(format!(
"未找到要备注的 {item_kind}: {id}"
)));
}
let conn = open_db()?;
if note.is_empty() {
conn.execute(
"DELETE FROM skills_mcp_notes
WHERE codex_dir = ?1 AND item_kind = ?2 AND item_id = ?3",
params![codex_dir_scope, item_kind, id],
)
.map_err(|error| crate::error::CodexxError::Database(error.to_string()))?;
} else {
conn.execute(
"INSERT INTO skills_mcp_notes
(codex_dir, item_kind, item_id, note, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5)
ON CONFLICT(codex_dir, item_kind, item_id) DO UPDATE SET
note = excluded.note,
updated_at = excluded.updated_at",
params![codex_dir_scope, item_kind, id, note, now_rfc3339()],
)
.map_err(|error| crate::error::CodexxError::Database(error.to_string()))?;
}
build_skills_mcp_state_inner(config_dir)
}
pub(crate) fn import_existing_skills_mcp_inner(
config_dir: Option<String>,
) -> Result<SkillsMcpActionResult> {
@@ -223,6 +339,7 @@ mod tests {
enabled: true,
source: source.to_string(),
summary: id.to_string(),
note: None,
command: Some(id.to_string()),
url: None,
config_json: json!({ "command": id }),
@@ -271,4 +388,161 @@ mod tests {
);
assert!(second_preview.is_empty());
}
#[test]
fn custom_notes_are_isolated_by_kind_and_codex_home_and_empty_text_clears_them() {
let _db_guard = crate::app_db::test_db_guard();
let item_id = format!("shared-note-item-{}", std::process::id());
let spaced_id = " spaced-mcp ";
let codex_dir =
std::env::temp_dir().join(format!("codex-x-note-state-{}", std::process::id()));
let other_codex_dir =
std::env::temp_dir().join(format!("codex-x-note-state-other-{}", std::process::id()));
let prepare_codex_dir = |dir: &Path| {
let _ = fs::remove_dir_all(dir);
let skill_dir = dir.join("skills").join(&item_id);
fs::create_dir_all(&skill_dir).expect("create test skill directory");
fs::write(
skill_dir.join("SKILL.md"),
format!("---\nname: {item_id}\ndescription: test\n---\n"),
)
.expect("write test skill");
fs::write(
dir.join("config.toml"),
format!(
"[mcp_servers.{item_id}]\ncommand = \"test-server\"\n\
[mcp_servers.\"{spaced_id}\"]\ncommand = \"spaced-server\"\n"
),
)
.expect("write test MCP config");
};
prepare_codex_dir(&codex_dir);
prepare_codex_dir(&other_codex_dir);
let conn = open_db().expect("open app database");
conn.execute(
"DELETE FROM skills_mcp_notes WHERE item_id IN (?1, ?2)",
params![&item_id, spaced_id],
)
.expect("clear stale test notes");
drop(conn);
let config_dir = Some(codex_dir.display().to_string());
save_skills_mcp_note_inner(
config_dir.clone(),
"skill".to_string(),
item_id.clone(),
" 我的 Skill 备注 ".to_string(),
)
.expect("save skill note");
let state = save_skills_mcp_note_inner(
config_dir.clone(),
"mcp".to_string(),
item_id.clone(),
"MCP note".to_string(),
)
.expect("save MCP note");
assert_eq!(
state
.skills
.iter()
.find(|skill| skill.id == item_id)
.and_then(|skill| skill.note.as_deref()),
Some("我的 Skill 备注")
);
assert_eq!(
state
.mcp_servers
.iter()
.find(|server| server.id == item_id)
.and_then(|server| server.note.as_deref()),
Some("MCP note")
);
let state = save_skills_mcp_note_inner(
config_dir.clone(),
"mcp".to_string(),
spaced_id.to_string(),
"Spaced ID note".to_string(),
)
.expect("save note without normalizing the MCP ID");
assert_eq!(
state
.mcp_servers
.iter()
.find(|server| server.id == spaced_id)
.and_then(|server| server.note.as_deref()),
Some("Spaced ID note")
);
let other_config_dir = Some(other_codex_dir.display().to_string());
let other_state = build_skills_mcp_state_inner(other_config_dir.clone())
.expect("load other CODEX_HOME state");
assert!(other_state
.mcp_servers
.iter()
.find(|server| server.id == item_id)
.is_some_and(|server| server.note.is_none()));
save_skills_mcp_note_inner(
other_config_dir,
"mcp".to_string(),
item_id.clone(),
"Other MCP note".to_string(),
)
.expect("save note for other CODEX_HOME");
let original_state = build_skills_mcp_state_inner(config_dir.clone())
.expect("reload original CODEX_HOME state");
assert_eq!(
original_state
.mcp_servers
.iter()
.find(|server| server.id == item_id)
.and_then(|server| server.note.as_deref()),
Some("MCP note")
);
let state = save_skills_mcp_note_inner(
config_dir.clone(),
"skill".to_string(),
item_id.clone(),
" ".to_string(),
)
.expect("clear skill note");
assert!(state
.skills
.iter()
.find(|skill| skill.id == item_id)
.is_some_and(|skill| skill.note.is_none()));
assert_eq!(
state
.mcp_servers
.iter()
.find(|server| server.id == item_id)
.and_then(|server| server.note.as_deref()),
Some("MCP note")
);
assert!(save_skills_mcp_note_inner(
config_dir.clone(),
"unknown".to_string(),
item_id.clone(),
"note".to_string(),
)
.is_err());
assert!(save_skills_mcp_note_inner(
config_dir,
"mcp".to_string(),
item_id.clone(),
"x".repeat(SKILLS_MCP_NOTE_MAX_CHARS + 1),
)
.is_err());
let conn = open_db().expect("reopen app database");
conn.execute(
"DELETE FROM skills_mcp_notes WHERE item_id IN (?1, ?2)",
params![&item_id, spaced_id],
)
.expect("remove test notes");
drop(conn);
fs::remove_dir_all(&codex_dir).expect("remove test Codex directory");
fs::remove_dir_all(&other_codex_dir).expect("remove other test Codex directory");
}
}
@@ -258,6 +258,7 @@ pub(super) fn scan_skill_dir(
id: id.clone(),
name,
description,
note: None,
directory,
enabled,
source: source.to_string(),
@@ -10,6 +10,7 @@ pub(crate) struct ManagedMcpServer {
pub(crate) enabled: bool,
pub(crate) source: String,
pub(crate) summary: String,
pub(crate) note: Option<String>,
pub(crate) command: Option<String>,
pub(crate) url: Option<String>,
pub(crate) config_json: Value,
@@ -21,6 +22,7 @@ pub(crate) struct ManagedSkill {
pub(crate) id: String,
pub(crate) name: String,
pub(crate) description: Option<String>,
pub(crate) note: Option<String>,
pub(crate) directory: String,
pub(crate) enabled: bool,
pub(crate) source: String,
@@ -1,21 +0,0 @@
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] {
&[]
}
@@ -1,130 +0,0 @@
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"),
})
}
@@ -1,786 +0,0 @@
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);
}
}
@@ -1,73 +0,0 @@
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))
}
@@ -1,199 +0,0 @@
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);
}
}
@@ -1,121 +0,0 @@
#[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(),
))
}
}
@@ -1,234 +0,0 @@
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));
}
}
@@ -1,869 +0,0 @@
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"));
}
}
-276
View File
@@ -1,276 +0,0 @@
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_catalog_is_not_embedded_but_source_remains_valid() {
let assets = builtin_skin_assets();
assert!(assets.is_empty());
let manifest: SkinThemeManifest = serde_json::from_str(include_str!(
"../resources/skin-presets/shiina-mashiro-blossom/theme.json"
))
.expect("parse retained Shiina theme source");
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");
}
File diff suppressed because it is too large Load Diff
+58 -16
View File
@@ -1,11 +1,16 @@
use crate::backups::{latest_backup, BackupEntry};
use crate::backups::BackupEntry;
#[cfg(test)]
use crate::config_migration::migrate_legacy_prompt_config;
use crate::error::Result;
use crate::file_io::{io_err, json_err, parse_toml_document, read_to_string_if_exists};
use crate::prompts::{
agents_path, managed_agents_template_key, prompt_template_key_for_instruction,
};
use crate::providers::{list_saved_providers_inner, SavedProvider};
use crate::providers::{
clear_active_provider_on_connection, detected_live_custom_provider, document_is_official,
list_saved_providers_on_connection, matching_saved_provider_ids_for_live,
official_auth_available, open_store, reconcile_active_provider_on_connection, SavedProvider,
};
use crate::{auth_path, config_path, string_value};
use serde::Serialize;
use serde_json::Value;
@@ -27,7 +32,7 @@ struct ProviderSummary {
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct CodexState {
codex_dir: String,
pub(crate) codex_dir: String,
config_path: String,
auth_path: String,
config_exists: bool,
@@ -35,12 +40,14 @@ pub(crate) struct CodexState {
official_auth_available: bool,
pub(crate) model: Option<String>,
pub(crate) model_provider: Option<String>,
pub(crate) is_official_provider: bool,
instruction_file: Option<String>,
pub(crate) instruction_enabled: bool,
pub(crate) instruction_injection_mode: Option<String>,
pub(crate) instruction_template_key: Option<String>,
agents_path: String,
active_saved_provider_id: Option<String>,
pub(crate) active_saved_provider_id: Option<String>,
pub(crate) active_official_profile_id: Option<String>,
providers: Vec<ProviderSummary>,
pub(crate) config_text: String,
auth_preview: Option<Value>,
@@ -62,7 +69,13 @@ fn redacted_auth_preview(path: &Path) -> Result<Option<Value>> {
return Ok(None);
}
let text = fs::read_to_string(path).map_err(|e| io_err(path, e))?;
let mut value: Value = serde_json::from_str(&text).map_err(|e| json_err(path, e))?;
// Keep the UI responsive when another tool or an interrupted write leaves
// auth.json malformed. Mutating flows validate or replace auth explicitly;
// the read-only state path should still report the rest of the config.
let mut value: Value = match serde_json::from_str(&text) {
Ok(value) => value,
Err(_) => return Ok(None),
};
if let Some(obj) = value.as_object_mut() {
for (key, val) in obj.iter_mut() {
let lower = key.to_ascii_lowercase();
@@ -148,15 +161,15 @@ fn normalized_provider_toml_for_match(text: &str) -> String {
.to_string()
}
pub(crate) fn active_saved_provider_id_from_config(
fn matching_saved_provider_ids_from_config(
config_text: &str,
providers: &[SavedProvider],
) -> Option<String> {
) -> Vec<String> {
let live = normalized_provider_toml_for_match(config_text);
if live.is_empty() {
return None;
return Vec::new();
}
let matches = providers
providers
.iter()
.filter(|provider| {
provider
@@ -164,18 +177,33 @@ pub(crate) fn active_saved_provider_id_from_config(
.as_deref()
.is_some_and(|toml| normalized_provider_toml_for_match(toml) == live)
})
.collect::<Vec<_>>();
(matches.len() == 1).then(|| matches[0].id.clone())
.map(|provider| provider.id.clone())
.collect()
}
#[cfg(test)]
pub(crate) fn active_saved_provider_id_from_config(
config_text: &str,
providers: &[SavedProvider],
) -> Option<String> {
let matches = matching_saved_provider_ids_from_config(config_text, providers);
(matches.len() == 1).then(|| matches[0].clone())
}
#[cfg(test)]
pub(crate) fn build_state(codex_dir: PathBuf) -> Result<CodexState> {
migrate_legacy_prompt_config(&codex_dir)?;
build_state_after_migration(codex_dir)
}
pub(crate) fn build_state_after_migration(codex_dir: PathBuf) -> Result<CodexState> {
let cfg = config_path(&codex_dir);
let auth = auth_path(&codex_dir);
migrate_legacy_prompt_config(&codex_dir)?;
let text = read_to_string_if_exists(&cfg)?;
let doc = parse_toml_document(&cfg, &text)?;
let model = string_value(&doc, "model");
let model_provider = string_value(&doc, "model_provider");
let is_official_provider = document_is_official(&doc);
let instruction_file = string_value(&doc, "model_instructions_file");
let model_template_key = instruction_file
.as_deref()
@@ -193,10 +221,17 @@ pub(crate) fn build_state(codex_dir: PathBuf) -> Result<CodexState> {
};
let instruction_enabled = instruction_template_key.is_some();
let providers = extract_providers(&doc, model_provider.as_deref());
let active_saved_provider_id = if model_provider.as_deref() == Some("openai") {
let conn = open_store()?;
let saved_providers = list_saved_providers_on_connection(&conn)?;
let active_saved_provider_id = if is_official_provider {
clear_active_provider_on_connection(&conn, &codex_dir)?;
None
} else if let Some(live) = detected_live_custom_provider(&codex_dir)? {
let candidates = matching_saved_provider_ids_for_live(&live, &saved_providers);
reconcile_active_provider_on_connection(&conn, &codex_dir, &candidates)?
} else {
active_saved_provider_id_from_config(&text, &list_saved_providers_inner()?)
let candidates = matching_saved_provider_ids_from_config(&text, &saved_providers);
reconcile_active_provider_on_connection(&conn, &codex_dir, &candidates)?
};
Ok(CodexState {
@@ -205,19 +240,26 @@ pub(crate) fn build_state(codex_dir: PathBuf) -> Result<CodexState> {
auth_path: auth.display().to_string(),
config_exists: cfg.exists(),
auth_exists: auth.exists(),
official_auth_available: auth_has_material(&auth)?,
official_auth_available: official_auth_available(&codex_dir)?,
model,
model_provider,
is_official_provider,
instruction_file,
instruction_enabled,
instruction_injection_mode,
instruction_template_key,
agents_path: agents_path(&codex_dir).display().to_string(),
active_saved_provider_id,
active_official_profile_id: crate::providers::official_profiles::active_profile_id(
&codex_dir,
is_official_provider,
)?,
providers,
config_text: text,
auth_preview: redacted_auth_preview(&auth)?,
auth_text: read_to_string_if_exists(&auth)?,
last_backup: latest_backup()?,
// Backup discovery can traverse years of history. Keep core state fast;
// restore actions resolve history only when the user requests it.
last_backup: None,
})
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+2 -6
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Codex-X",
"version": "0.3.4",
"version": "0.3.16",
"identifier": "com.yynxxxxx.codexx",
"build": {
"frontendDist": "../dist",
@@ -29,11 +29,7 @@
}
],
"security": {
"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/**"]
}
"csp": "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src 'self' ipc: http://ipc.localhost"
}
},
"bundle": {
@@ -1,7 +0,0 @@
{
"bundle": {
"resources": {
"runtime/windows/node/*": "skin-runtime/node/"
}
}
}
+1 -64
View File
@@ -310,70 +310,6 @@ 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;
@@ -430,6 +366,7 @@ export function StartupWizardDialog({
value={configDir || diagnostics.codexDir}
onChange={(event) => onConfigDirChange(event.target.value)}
placeholder="~/.codex"
disabled={loading}
spellCheck={false}
/>
<Button
+1 -6
View File
@@ -9,7 +9,6 @@ import {
LayoutDashboard,
LoaderCircle,
Moon,
Palette,
RotateCcw,
Settings,
Sparkles,
@@ -29,7 +28,6 @@ export type AppTab =
| "provider"
| "sessions"
| "skillsMcp"
| "skins"
| "instruction"
| "toml"
| "settings"
@@ -46,7 +44,6 @@ const NAV_ITEMS: readonly NavItem[] = [
{ id: "provider", icon: Zap, label: { zh: "供应商", en: "Providers" } },
{ id: "sessions", icon: History, label: { zh: "会话管理", en: "Sessions" } },
{ id: "skillsMcp", icon: Blocks, label: { zh: "技能和MCP", en: "Skills & MCP" } },
{ id: "skins", icon: Palette, label: { zh: "皮肤中心", en: "Skins" } },
{ id: "instruction", icon: Sparkles, label: { zh: "指令提示词", en: "Prompts" } },
{ id: "toml", icon: FileCode2, label: { zh: "TOML", en: "TOML" } },
{ id: "settings", icon: Settings, label: { zh: "设置", en: "Settings" } },
@@ -65,7 +62,6 @@ export type AppShellProps = {
updatePhase?: AppUpdaterPhase;
onOpenUpdate?: () => void;
isMacRuntime?: boolean;
skinCenterEnabled?: boolean;
children: ReactNode;
sidebarFooter?: ReactNode;
className?: string;
@@ -84,7 +80,6 @@ export function AppShell({
updatePhase = "idle",
onOpenUpdate,
isMacRuntime = false,
skinCenterEnabled = true,
children,
sidebarFooter,
className,
@@ -160,7 +155,7 @@ export function AppShell({
</div>
<nav className="cx-sidebar-nav" aria-label={navigationLabel}>
{NAV_ITEMS.filter((item) => item.id !== "skins" || skinCenterEnabled).map((item) => {
{NAV_ITEMS.map((item) => {
const Icon = item.icon;
const isActive = activeTab === item.id;
@@ -5,7 +5,6 @@ import {
ChevronRight,
Edit2,
FileText,
Palette,
Plus,
Settings2,
Trash2,
@@ -32,7 +31,6 @@ type PromptCategoryManagerProps = {
onRenameCategory: (categoryId: string, name: string) => boolean;
onDeleteCategory: (categoryId: string) => boolean;
onMovePrompt: (promptKey: string, categoryId: string) => void;
subject?: "prompt" | "skin";
};
export function PromptCategoryManager({
@@ -46,7 +44,6 @@ export function PromptCategoryManager({
onRenameCategory,
onDeleteCategory,
onMovePrompt,
subject = "prompt",
}: PromptCategoryManagerProps) {
const isChinese = lang === "zh";
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set());
@@ -75,38 +72,11 @@ 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: subjectCopy.deleteTitle,
deleteDescription: subjectCopy.deleteDescription,
deleteTitle: "删除提示词分类",
deleteDescription: (name: string) => `“${name}”中的提示词会移到默认分类,提示词本身不会被删除。`,
addCategory: "新增分类",
namePlaceholder: "输入分类名称",
cancel: "取消",
@@ -116,16 +86,16 @@ export function PromptCategoryManager({
edit: "编辑分类",
remove: "删除分类",
keepOne: "至少保留一个分类",
empty: subjectCopy.empty,
move: subjectCopy.move,
empty: "该分类下暂无提示词",
move: (title: string) => `移动“${title}”到分类`,
duplicate: `分类名称不能为空、重复或超过 ${PROMPT_CATEGORY_NAME_MAX_LENGTH} 个字符`,
expand: (name: string) => `展开${name}`,
collapse: (name: string) => `收起${name}`,
}
: {
title: "Manage categories",
deleteTitle: subjectCopy.deleteTitle,
deleteDescription: subjectCopy.deleteDescription,
deleteTitle: "Delete prompt category",
deleteDescription: (name: string) => `Prompts in “${name}” will move to the default category. The prompts are not deleted.`,
addCategory: "Add category",
namePlaceholder: "Category name",
cancel: "Cancel",
@@ -135,14 +105,12 @@ export function PromptCategoryManager({
edit: "Edit category",
remove: "Delete category",
keepOne: "Keep at least one category",
empty: subjectCopy.empty,
move: subjectCopy.move,
empty: "No prompts in this category",
move: (title: string) => `Move “${title}” to category`,
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) {
setCategoryToDelete(null);
@@ -292,7 +260,7 @@ export function PromptCategoryManager({
<div className="cx-prompt-category-prompts">
{categoryPrompts.length ? categoryPrompts.map((prompt) => (
<div className="cx-prompt-category-prompt" key={prompt.key}>
<ItemIcon size={14} aria-hidden="true" />
<FileText size={14} aria-hidden="true" />
<span title={prompt.title}>{prompt.title}</span>
<select
value={category.id}
@@ -1,150 +0,0 @@
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>
);
}
-260
View File
@@ -1,260 +0,0 @@
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 = {
enabled: boolean;
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({
enabled,
lang,
tab,
ready,
setActionBusy,
setError,
setToast,
}: UseSkinCenterOptions) {
const [state, setState] = React.useState<SkinCenterState | null>(null);
const [restartRequest, setRestartRequest] = React.useState<SkinRestartRequest | null>(null);
const [pauseBusy, setPauseBusy] = React.useState(false);
const zipInputRef = React.useRef<HTMLInputElement | null>(null);
const imageInputRef = React.useRef<HTMLInputElement | null>(null);
const loadedRef = React.useRef(false);
const pauseInFlightRef = React.useRef(false);
const load = React.useCallback(async ({
quiet = false,
notify = false,
}: { quiet?: boolean; notify?: boolean } = {}) => {
if (!enabled) return;
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("");
}
}, [enabled, lang, setActionBusy, setError, setToast]);
const refresh = React.useCallback(() => load({ notify: true }), [load]);
React.useEffect(() => {
if (!enabled || tab !== "skins" || loadedRef.current) return;
loadedRef.current = true;
void load();
}, [enabled, load, tab]);
React.useEffect(() => {
if (!enabled || !ready || loadedRef.current) return;
loadedRef.current = true;
void load({ quiet: true });
}, [enabled, 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 () => {
if (pauseInFlightRef.current) return;
pauseInFlightRef.current = true;
setPauseBusy(true);
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 {
pauseInFlightRef.current = false;
setPauseBusy(false);
}
};
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,
pauseBusy,
zipInputRef,
imageInputRef,
refresh,
importZip,
createFromImage,
updateSettings,
apply,
pause,
confirmRestart,
closeRestart: () => setRestartRequest(null),
exportTheme,
};
}
+1124 -496
View File
File diff suppressed because it is too large Load Diff
+49 -21
View File
@@ -1,9 +1,11 @@
import {
ArrowUpRight,
CheckCircle2,
CircleAlert,
Code2,
FileText,
KeyRound,
Loader2,
RefreshCw,
Sparkles,
} from "lucide-react";
@@ -14,6 +16,7 @@ export type OverviewLanguage = "zh" | "en";
export type OverviewPageProps = {
lang: OverviewLanguage;
ready: boolean;
model?: string | null;
configDir: string;
resolvedCodexDir: string;
@@ -21,6 +24,7 @@ export type OverviewPageProps = {
providerLabel?: string | null;
instructionEnabled: boolean;
authExists: boolean;
officialAuthAvailable: boolean;
configPath?: string | null;
modelProvider?: string | null;
instructionPath?: string | null;
@@ -69,6 +73,7 @@ function ConfigRow({ label, value }: ConfigRowProps) {
export function OverviewPage({
lang,
ready,
model,
configDir,
resolvedCodexDir,
@@ -76,6 +81,7 @@ export function OverviewPage({
providerLabel,
instructionEnabled,
authExists,
officialAuthAvailable,
configPath,
modelProvider,
instructionPath,
@@ -103,8 +109,9 @@ export function OverviewPage({
instruction: "指令提示词",
enabled: "已启用",
disabled: "未启用",
auth: "认证文件",
authFile: "auth.json",
auth: "认证状态",
authFile: "auth.json 已找到",
officialAuth: "官方认证已保存",
noAuth: "未找到",
updateFound: "发现新版本",
updateAvailable: (version: string) => `Codex-X ${version} 已发布`,
@@ -118,6 +125,8 @@ export function OverviewPage({
model: "模型",
providerName: "供应商标识",
instructionFile: "指令文件",
reading: "正在读取",
readFailed: "读取失败",
}
: {
eyebrow: "CODEX CONFIG MANAGER",
@@ -134,8 +143,9 @@ export function OverviewPage({
instruction: "Instructions",
enabled: "Enabled",
disabled: "Disabled",
auth: "Auth file",
authFile: "auth.json",
auth: "Authentication",
authFile: "auth.json found",
officialAuth: "Official auth saved",
noAuth: "Not found",
updateFound: "New version available",
updateAvailable: (version: string) => `Codex-X ${version} is available`,
@@ -149,16 +159,29 @@ export function OverviewPage({
model: "Model",
providerName: "Provider",
instructionFile: "Instruction file",
reading: "Loading",
readFailed: "Load failed",
};
const displayModel = model?.trim() || text.modelMissing;
const displayProvider = providerLabel?.trim() || modelProvider?.trim() || text.official;
const displayModelProvider = modelProvider?.trim() || text.notConfigured;
const displayDirectory = resolvedCodexDir.trim() || configDir.trim() || text.notConfigured;
const displayConfigPath = configPath?.trim() || text.notConfigured;
const displayInstructionPath = instructionPath?.trim() || text.notConfigured;
const unresolvedStatus = loading ? text.reading : text.readFailed;
const displayModel = ready ? (model?.trim() || text.modelMissing) : unresolvedStatus;
const displayProvider = ready
? (providerLabel?.trim() || modelProvider?.trim() || text.official)
: unresolvedStatus;
const displayModelProvider = ready ? (modelProvider?.trim() || text.notConfigured) : unresolvedStatus;
const displayDirectory = ready
? (resolvedCodexDir.trim() || configDir.trim() || text.notConfigured)
: (configDir.trim() || unresolvedStatus);
const displayConfigPath = ready ? (configPath?.trim() || text.notConfigured) : unresolvedStatus;
const displayInstructionPath = ready ? (instructionPath?.trim() || text.notConfigured) : unresolvedStatus;
const updateVersion = latestVersion?.trim() || "";
const homeInputValue = configDir || resolvedCodexDir;
const homeInputValue = configDir;
const authAvailable = ready && (authExists || officialAuthAvailable);
const authStatus = ready
? authExists
? text.authFile
: officialAuthAvailable ? text.officialAuth : text.noAuth
: unresolvedStatus;
return (
<section className="cx-overview-page" aria-label={isChinese ? "概览" : "Overview"}>
@@ -179,6 +202,7 @@ export function OverviewPage({
value={homeInputValue}
onChange={(event) => onConfigDirChange(event.target.value)}
placeholder={text.directoryPlaceholder}
disabled={loading}
spellCheck={false}
aria-label={text.codexHome}
/>
@@ -209,26 +233,26 @@ export function OverviewPage({
<StatusCard
icon={FileText}
label={text.config}
value={configExists ? text.found : text.missing}
tone={configExists ? "success" : "muted"}
value={ready ? (configExists ? text.found : text.missing) : unresolvedStatus}
tone={ready && configExists ? "success" : "muted"}
/>
<StatusCard
icon={Code2}
label={text.provider}
value={displayProvider}
tone={modelProvider ? "active" : "muted"}
tone={ready && modelProvider ? "active" : "muted"}
/>
<StatusCard
icon={Sparkles}
label={text.instruction}
value={instructionEnabled ? text.enabled : text.disabled}
tone={instructionEnabled ? "success" : "muted"}
value={ready ? (instructionEnabled ? text.enabled : text.disabled) : unresolvedStatus}
tone={ready && instructionEnabled ? "success" : "muted"}
/>
<StatusCard
icon={KeyRound}
label={text.auth}
value={authExists ? text.authFile : text.noAuth}
tone={authExists ? "success" : "muted"}
value={authStatus}
tone={authAvailable ? "success" : "muted"}
/>
</div>
@@ -238,9 +262,13 @@ export function OverviewPage({
<p className="cx-overview-section-label">{text.liveStatus}</p>
<h3>{text.currentConfig}</h3>
</div>
<span className={`cx-overview-instruction-pill${instructionEnabled ? " cx-overview-instruction-pill--active" : ""}`}>
<CheckCircle2 size={14} strokeWidth={2} aria-hidden="true" />
{instructionEnabled ? text.on : text.off}
<span className={`cx-overview-instruction-pill${ready && instructionEnabled ? " cx-overview-instruction-pill--active" : ""}`}>
{!ready && loading
? <Loader2 className="cx-overview-spin" size={14} strokeWidth={2} aria-hidden="true" />
: !ready
? <CircleAlert size={14} strokeWidth={2} aria-hidden="true" />
: <CheckCircle2 size={14} strokeWidth={2} aria-hidden="true" />}
{ready ? (instructionEnabled ? text.on : text.off) : unresolvedStatus}
</span>
</div>
+52 -7
View File
@@ -23,6 +23,7 @@ import type { PromptCategoryItem } from "../components/PromptCategoryManager";
import { Button, IconButton, StatusBadge, Toggle, cx } from "../components/ui";
import { usePromptCategories } from "../promptCategories";
import type {
BuiltinPromptDetail,
BuiltinPromptStatus,
InstructionMode,
InstructionTemplate,
@@ -53,6 +54,7 @@ export type PromptsPageProps = {
instructionMode: InstructionMode;
promptForm: SavedPrompt;
editingPromptId: string | null;
editingBuiltinPrompt: BuiltinPromptDetail | null;
loading: boolean;
actionBusy: string;
promptSyncing: boolean;
@@ -84,6 +86,7 @@ export type PromptsPageProps = {
onEnableSavedPrompt: (id: string) => void | Promise<void>;
onDisableExternalPrompt: MaybeAsyncAction;
onEditPrompt: (prompt: SavedPrompt) => void;
onEditBuiltinPrompt: (id: string) => void | Promise<void>;
onDeletePrompt: (id: string) => void | Promise<void>;
onPromptFormFieldChange: (field: PromptFormField, value: string) => void;
onSavePrompt: MaybeAsyncAction;
@@ -129,11 +132,17 @@ function getCopy(lang: Lang) {
preservedDescription: "用户原有提示词,追加模式下继续生效。",
existingPrompt: "用户原有指令提示词",
edit: "编辑",
editBuiltin: "查看/编辑本地模板",
remove: "删除",
formEyebrow: "CUSTOM PROMPT",
addFormTitle: "添加提示词",
editFormTitle: "编辑提示词",
formDescription: "保存为 Markdown 文件,之后可在列表中单独启用。",
builtinFormEyebrow: "LOCAL TEMPLATE",
builtinFormTitle: "查看与编辑本地模板",
builtinFormDescription: "名称和文件名跟随模板目录;内容修改在下次启用时生效,之后 GitHub 同步将永久跳过这份模板。",
locallyModified: "本地已修改",
saveLocalChanges: "保存本地修改",
back: "返回",
promptDetails: "提示词详情",
promptName: "提示词名称",
@@ -183,11 +192,17 @@ function getCopy(lang: Lang) {
preservedDescription: "Existing user prompt preserved by append mode.",
existingPrompt: "Existing user prompt",
edit: "Edit",
editBuiltin: "View or edit local template",
remove: "Delete",
formEyebrow: "CUSTOM PROMPT",
addFormTitle: "Add prompt",
editFormTitle: "Edit prompt",
formDescription: "Save it as Markdown, then enable it separately from the list.",
builtinFormEyebrow: "LOCAL TEMPLATE",
builtinFormTitle: "View and edit local template",
builtinFormDescription: "The name and filename follow the template catalog. Content changes apply on the next activation, then GitHub sync permanently skips this template.",
locallyModified: "Locally modified",
saveLocalChanges: "Save local changes",
back: "Back",
promptDetails: "Prompt details",
promptName: "Prompt name",
@@ -269,6 +284,7 @@ function PromptFormView({
lang,
promptForm,
editingPromptId,
editingBuiltinPrompt,
loading,
onInstructionModeChange,
onPromptFormFieldChange,
@@ -278,6 +294,7 @@ function PromptFormView({
| "lang"
| "promptForm"
| "editingPromptId"
| "editingBuiltinPrompt"
| "loading"
| "onInstructionModeChange"
| "onPromptFormFieldChange"
@@ -287,14 +304,15 @@ function PromptFormView({
const titleId = useId();
const filenameId = useId();
const contentId = useId();
const isBuiltin = Boolean(editingBuiltinPrompt);
return (
<div className="cx-prompts-form-page">
<header className="cx-prompts-header cx-prompts-form-header">
<div className="cx-prompts-heading">
<p><PencilLine size={14} aria-hidden="true" />{copy.formEyebrow}</p>
<h2>{editingPromptId ? copy.editFormTitle : copy.addFormTitle}</h2>
<span>{copy.formDescription}</span>
<p><PencilLine size={14} aria-hidden="true" />{isBuiltin ? copy.builtinFormEyebrow : copy.formEyebrow}</p>
<h2>{isBuiltin ? copy.builtinFormTitle : editingPromptId ? copy.editFormTitle : copy.addFormTitle}</h2>
<span>{isBuiltin ? copy.builtinFormDescription : copy.formDescription}</span>
</div>
<Button
variant="secondary"
@@ -310,6 +328,9 @@ function PromptFormView({
<div className="cx-prompts-form-panel-head">
<FileText size={18} aria-hidden="true" />
<h3 id={`${titleId}-panel`}>{copy.promptDetails}</h3>
{editingBuiltinPrompt?.customized && (
<StatusBadge tone="info" dot={false}>{copy.locallyModified}</StatusBadge>
)}
</div>
<div className="cx-prompts-form-grid">
<label className="cx-prompts-field" htmlFor={titleId}>
@@ -320,6 +341,7 @@ function PromptFormView({
value={promptForm.title}
onChange={(event) => onPromptFormFieldChange("title", event.currentTarget.value)}
placeholder={copy.promptNamePlaceholder}
readOnly={isBuiltin}
disabled={loading}
autoComplete="off"
/>
@@ -332,6 +354,7 @@ function PromptFormView({
value={promptForm.filename}
onChange={(event) => onPromptFormFieldChange("filename", event.currentTarget.value)}
placeholder={copy.filenamePlaceholder}
readOnly={isBuiltin}
disabled={loading}
autoComplete="off"
spellCheck={false}
@@ -358,7 +381,7 @@ function PromptFormView({
onClick={() => run(onSavePrompt)}
disabled={loading}
>
{copy.save}
{isBuiltin ? copy.saveLocalChanges : copy.save}
</Button>
</div>
</div>
@@ -370,6 +393,7 @@ export function PromptsPage({
instructionMode,
promptForm,
editingPromptId,
editingBuiltinPrompt,
loading,
actionBusy,
promptSyncing,
@@ -382,6 +406,7 @@ export function PromptsPage({
activeInstructionTitle,
activeInjectionMode,
instructionTemplates,
builtinPromptStatuses,
activeBuiltinTemplateId,
orphanedBuiltinPrompt,
savedPrompts,
@@ -400,6 +425,7 @@ export function PromptsPage({
onEnableSavedPrompt,
onDisableExternalPrompt,
onEditPrompt,
onEditBuiltinPrompt,
onDeletePrompt,
onPromptFormFieldChange,
onSavePrompt,
@@ -412,6 +438,10 @@ export function PromptsPage({
const importBusy = actionBusy === "importPrompt";
const [categoryManagerOpen, setCategoryManagerOpen] = useState(false);
const promptCategories = usePromptCategories(lang);
const builtinStatusById = useMemo(
() => new Map(builtinPromptStatuses.map((status) => [status.id, status])),
[builtinPromptStatuses],
);
const categoryItems = useMemo<PromptCategoryItem[]>(() => [
...instructionTemplates.map((template) => ({
key: promptCategoryKey("builtin", template.id),
@@ -441,11 +471,13 @@ export function PromptsPage({
if (instructionMode === "form") {
return (
<PageTransition pageKey={`prompts:${instructionMode}`}>
<section className={cx("cx-prompts-page", "cx-prompts-page--form", className)} aria-label={editingPromptId ? copy.editFormTitle : copy.addFormTitle}>
<section className={cx("cx-prompts-page", "cx-prompts-page--form", className)} aria-label={editingBuiltinPrompt ? copy.builtinFormTitle : editingPromptId ? copy.editFormTitle : copy.addFormTitle}>
<PromptFormView
key={editingBuiltinPrompt?.id || editingPromptId || "new"}
lang={lang}
promptForm={promptForm}
editingPromptId={editingPromptId}
editingBuiltinPrompt={editingBuiltinPrompt}
loading={loading}
onInstructionModeChange={onInstructionModeChange}
onPromptFormFieldChange={onPromptFormFieldChange}
@@ -605,6 +637,7 @@ export function PromptsPage({
{instructionTemplates.filter((template) =>
promptIsVisible(promptCategoryKey("builtin", template.id))).map((template) => {
const enabled = template.id === activeBuiltinTemplateId;
const customized = Boolean(builtinStatusById.get(template.id)?.customized);
return (
<PromptRow
key={template.id}
@@ -614,10 +647,22 @@ export function PromptsPage({
loading={loading}
toggleLabel={enabled ? copy.disable : copy.enable}
onToggle={() => enabled ? onDisableInstruction() : onEnableBuiltinPrompt(template.id)}
actions={(
<div className="cx-prompts-icon-actions">
<IconButton
icon={<PencilLine size={15} />}
label={copy.editBuiltin}
size="sm"
onClick={() => onEditBuiltinPrompt(template.id)}
disabled={loading}
/>
</div>
)}
>
{enabled && (
{(enabled || customized) && (
<div className="cx-prompts-row-meta">
<StatusBadge tone="accent" dot={false}>{copy.current} · {activeModeLabel}</StatusBadge>
{enabled && <StatusBadge tone="accent" dot={false}>{copy.current} · {activeModeLabel}</StatusBadge>}
{customized && <StatusBadge tone="info" dot={false}>{copy.locallyModified}</StatusBadge>}
</div>
)}
</PromptRow>
+272 -30
View File
@@ -1,10 +1,13 @@
import { useId, useState } from "react";
import { useEffect, useId, useRef, useState } from "react";
import type { ReactNode } from "react";
import { invoke } from "@tauri-apps/api/core";
import {
Activity,
AlertTriangle,
ArrowLeft,
CheckCircle2,
Copy,
Download,
Eye,
EyeOff,
FilePlus2,
@@ -34,8 +37,10 @@ export type ProviderRow = {
wireApi: string;
requiresOpenaiAuth: boolean;
isCurrent: boolean;
isDefaultOfficial?: boolean;
sourceLabel?: string;
editable?: boolean;
duplicable?: boolean;
deletable?: boolean;
testable?: boolean;
testingKey?: string;
@@ -52,8 +57,10 @@ export type ProviderFormValue = {
};
export type OfficialFormValue = {
providerName: string;
model: string;
authJson: string;
configText: string;
};
export type ProviderCopy = {
@@ -67,6 +74,7 @@ export type ProviderCopy = {
enableLabel: string;
testLabel: string;
editLabel: string;
duplicateLabel: string;
removeLabel: string;
deleteTitle: string;
deleteDescription: (providerName: string) => string;
@@ -81,7 +89,9 @@ export type ProviderCopy = {
authPathLabel: string;
officialCurrentLabel: string;
officialAuthLabel: string;
officialTomlLabel: string;
officialSaveLabel: string;
loadCcSwitchOfficialLabel: string;
restoreOfficialLabel: string;
resetOfficialLabel: string;
resetOfficialTitle: string;
@@ -131,9 +141,14 @@ export type ProvidersPageProps = {
testingId: string;
actionBusy?: string;
editingProviderId: string | null;
creatingProvider: boolean;
providerKind: "api" | "official";
providerForm: ProviderFormValue;
officialForm: OfficialFormValue;
officialProfileIsDefault: boolean;
canLoadCurrentOfficial: boolean;
officialAuthRef?: Ref<HTMLTextAreaElement>;
officialTomlRef?: Ref<HTMLTextAreaElement>;
officialInfo: ProviderOfficialInfo;
providerAuthPreview: ReactNode;
providerTomlDraft: string;
@@ -143,15 +158,21 @@ export type ProvidersPageProps = {
fetchingModels: boolean;
onImportCcSwitch: () => void;
onAddProvider: () => void;
onProviderKindChange: (kind: "api" | "official") => void;
onLoadCcSwitchOfficial: () => void;
onLoadCurrentOfficial: () => void;
onRestoreOfficial: () => void;
onResetOfficial: () => void;
onEnableProvider: (row: ProviderRow) => void;
onTestProvider: (row: ProviderRow) => void;
onEditProvider: (row: ProviderRow) => void;
onDuplicateProvider: (row: ProviderRow) => void;
onDeleteProvider: (row: ProviderRow) => Promise<boolean>;
onCancelMode: () => void;
onOfficialNameChange: (value: string) => void;
onOfficialModelChange: (value: string) => void;
onOfficialAuthChange: (value: string) => void;
onOfficialConfigChange: (value: string) => void;
onSaveOfficial: () => void;
onApiKeyChange: (value: string) => void;
onBaseUrlChange: (value: string) => void;
@@ -181,6 +202,145 @@ function Field({ label, children, className }: FieldProps) {
);
}
type ContextWindowValues = {
contextWindow: number | null;
compactTokenLimit: number | null;
};
type ContextWindowConfig = ContextWindowValues & {
configText: string;
enabled: boolean;
};
function ContextWindowControl({
lang,
configText,
disabled,
onConfigChange,
onBusyChange,
}: {
lang: "zh" | "en";
configText: string;
disabled: boolean;
onConfigChange: (text: string) => void;
onBusyChange: (busy: boolean) => void;
}) {
const [settings, setSettings] = useState<ContextWindowConfig | null>(null);
const [error, setError] = useState("");
const [changing, setChanging] = useState(false);
const currentText = useRef(configText);
const requestId = useRef(0);
const previousValues = useRef<ContextWindowValues | null>(null);
currentText.current = configText;
useEffect(() => {
const id = ++requestId.current;
let mounted = true;
setError("");
const timer = setTimeout(() => {
void invoke<ContextWindowConfig>("update_codex_context_window", {
configText,
enabled: null,
previousValues: null,
}).then((result) => {
if (mounted && requestId.current === id && currentText.current === configText) {
setSettings(result);
}
}).catch((cause: unknown) => {
if (mounted && requestId.current === id && currentText.current === configText) {
setSettings(null);
setError(String(cause));
}
});
}, 100);
return () => {
mounted = false;
++requestId.current;
clearTimeout(timer);
};
}, [configText]);
const toggle = async (enabled: boolean) => {
if (disabled || changing || !settings || settings.configText !== configText) return;
const originalText = configText;
const id = ++requestId.current;
setChanging(true);
onBusyChange(true);
setError("");
try {
const result = await invoke<ContextWindowConfig>("update_codex_context_window", {
configText: originalText,
enabled,
previousValues: enabled ? null : previousValues.current,
});
if (requestId.current !== id || currentText.current !== originalText) return;
previousValues.current = enabled
? { contextWindow: settings.contextWindow, compactTokenLimit: settings.compactTokenLimit }
: null;
setSettings(result);
onConfigChange(result.configText);
} catch (cause: unknown) {
if (requestId.current === id && currentText.current === originalText) setError(String(cause));
} finally {
setChanging(false);
onBusyChange(false);
}
};
return (
<div className="cx-providers-context-control">
<Checkbox
className="cx-providers-checkbox cx-providers-context-checkbox"
label={lang === "zh" ? "开启 1M 上下文窗口" : "Enable 1M context window"}
checked={Boolean(settings?.enabled)}
onCheckedChange={(enabled) => void toggle(enabled)}
disabled={disabled || changing || !settings || settings.configText !== configText}
/>
{error ? (
<span className="cx-providers-context-error" role="status" title={error}>
{lang === "zh" ? "请先修正 config.toml 的格式或上下文字段" : "Check the TOML syntax and context settings first"}
</span>
) : settings?.enabled && settings.configText === configText ? (
<span className="cx-providers-context-hint">
{settings.compactTokenLimit !== null
? lang === "zh"
? `自动压缩阈值:${settings.compactTokenLimit.toLocaleString("zh-CN")} tokens`
: `Auto-compact at ${settings.compactTokenLimit.toLocaleString("en-US")} tokens`
: lang === "zh" ? "自动压缩使用 Codex 默认值" : "Codex determines the compaction limit"}
</span>
) : (
<span className="cx-providers-context-hint">
{lang === "zh" ? "保存后生效,需模型支持" : "Applies on save; requires model support"}
</span>
)}
</div>
);
}
function ProviderKindSelect({
lang,
creatingProvider,
providerKind,
onProviderKindChange,
disabled,
}: Pick<ProvidersPageProps, "lang" | "creatingProvider" | "providerKind" | "onProviderKindChange"> & { disabled: boolean }) {
if (!creatingProvider) return null;
return (
<div className="cx-providers-form-grid cx-providers-form-grid--single">
<Field label={lang === "zh" ? "供应商类型" : "Provider type"}>
<select
value={providerKind}
onChange={(event) => onProviderKindChange(event.target.value === "official" ? "official" : "api")}
disabled={disabled}
>
<option value="api">{lang === "zh" ? "第三方 API" : "Third-party API"}</option>
<option value="official">{lang === "zh" ? "官方 Codex 登录" : "Official Codex login"}</option>
</select>
</Field>
</div>
);
}
function ProviderAvatar({ row }: { row: ProviderRow }) {
const initial = row.providerName.trim().slice(0, 1).toUpperCase() || "?";
const className = [
@@ -235,10 +395,12 @@ function ListPage({
onEnableProvider,
onTestProvider,
onEditProvider,
onDuplicateProvider,
onDeleteProvider,
}: Pick<ProvidersPageProps, "copy" | "providerRows" | "loading" | "testingId" | "actionBusy" | "onImportCcSwitch" | "onAddProvider" | "onRestoreOfficial" | "onEnableProvider" | "onTestProvider" | "onEditProvider" | "onDeleteProvider">) {
}: Pick<ProvidersPageProps, "copy" | "providerRows" | "loading" | "testingId" | "actionBusy" | "onImportCcSwitch" | "onAddProvider" | "onRestoreOfficial" | "onEnableProvider" | "onTestProvider" | "onEditProvider" | "onDuplicateProvider" | "onDeleteProvider">) {
const [providerToDelete, setProviderToDelete] = useState<ProviderRow | null>(null);
const [deleting, setDeleting] = useState(false);
const providerActionsBusy = loading || Boolean(actionBusy);
const closeDeleteDialog = () => {
if (!deleting) setProviderToDelete(null);
@@ -267,12 +429,12 @@ function ListPage({
type="button"
className="cx-providers-button cx-providers-button--secondary"
onClick={onImportCcSwitch}
disabled={loading || Boolean(actionBusy)}
disabled={providerActionsBusy}
>
{actionBusy === "importCcSwitch" ? <Loader2 size={15} className="cx-providers-spin" aria-hidden="true" /> : <RefreshCw size={15} aria-hidden="true" />}
{copy.importLabel}
</button>
<button type="button" className="cx-providers-button cx-providers-button--dark" onClick={onAddProvider} disabled={loading}>
<button type="button" className="cx-providers-button cx-providers-button--dark" onClick={onAddProvider} disabled={providerActionsBusy}>
<Plus size={15} aria-hidden="true" />
{copy.addLabel}
</button>
@@ -306,16 +468,16 @@ function ListPage({
type="button"
className="cx-providers-button cx-providers-button--small cx-providers-button--secondary"
onClick={() => onEnableProvider(row)}
disabled={loading || row.isCurrent}
disabled={providerActionsBusy || row.isCurrent}
>
{copy.enableLabel}
</button>
{row.source === "official" && (
{row.source === "official" && row.isDefaultOfficial && (
<ActionIconButton
icon={RotateCcw}
label={copy.restoreOfficialLabel}
onClick={onRestoreOfficial}
disabled={loading}
disabled={providerActionsBusy}
/>
)}
{row.testable !== false && (
@@ -323,14 +485,17 @@ function ListPage({
icon={isTesting ? Loader2 : Activity}
label={copy.testLabel}
onClick={() => onTestProvider(row)}
disabled={loading || isTesting}
disabled={providerActionsBusy || isTesting}
/>
)}
{row.editable !== false && (
<ActionIconButton icon={PencilLine} label={copy.editLabel} onClick={() => onEditProvider(row)} disabled={loading} />
<ActionIconButton icon={PencilLine} label={copy.editLabel} onClick={() => onEditProvider(row)} disabled={providerActionsBusy} />
)}
{row.duplicable && (
<ActionIconButton icon={Copy} label={copy.duplicateLabel} onClick={() => onDuplicateProvider(row)} disabled={providerActionsBusy} />
)}
{row.deletable && (
<ActionIconButton icon={Trash2} label={copy.removeLabel} onClick={() => setProviderToDelete(row)} disabled={loading} danger />
<ActionIconButton icon={Trash2} label={copy.removeLabel} onClick={() => setProviderToDelete(row)} disabled={providerActionsBusy} danger />
)}
</div>
</article>
@@ -374,7 +539,7 @@ function ListPage({
);
}
function ModeHeader({ eyebrow, title, description, cancelLabel, onCancel }: { eyebrow: string; title: string; description: string; cancelLabel: string; onCancel: () => void }) {
function ModeHeader({ eyebrow, title, description, cancelLabel, onCancel, disabled = false }: { eyebrow: string; title: string; description: string; cancelLabel: string; onCancel: () => void; disabled?: boolean }) {
return (
<header className="cx-providers-form-header">
<div>
@@ -382,7 +547,7 @@ function ModeHeader({ eyebrow, title, description, cancelLabel, onCancel }: { ey
<h2>{title}</h2>
<p>{description}</p>
</div>
<button type="button" className="cx-providers-button cx-providers-button--secondary" onClick={onCancel}>
<button type="button" className="cx-providers-button cx-providers-button--secondary" onClick={onCancel} disabled={disabled}>
<ArrowLeft size={15} aria-hidden="true" />
{cancelLabel}
</button>
@@ -391,35 +556,80 @@ function ModeHeader({ eyebrow, title, description, cancelLabel, onCancel }: { ey
}
function OfficialForm({
lang,
copy,
creatingProvider,
providerKind,
onProviderKindChange,
officialForm,
officialProfileIsDefault,
canLoadCurrentOfficial,
officialAuthRef,
officialTomlRef,
officialInfo,
loading,
actionBusy,
onCancelMode,
onOfficialNameChange,
onOfficialModelChange,
onOfficialAuthChange,
onOfficialConfigChange,
onSaveOfficial,
onLoadCcSwitchOfficial,
onLoadCurrentOfficial,
onRestoreOfficial,
onResetOfficial,
}: Pick<ProvidersPageProps, "copy" | "officialForm" | "officialAuthRef" | "officialInfo" | "loading" | "onCancelMode" | "onOfficialModelChange" | "onOfficialAuthChange" | "onSaveOfficial" | "onRestoreOfficial" | "onResetOfficial">) {
}: Pick<ProvidersPageProps, "lang" | "copy" | "creatingProvider" | "providerKind" | "onProviderKindChange" | "officialForm" | "officialProfileIsDefault" | "canLoadCurrentOfficial" | "officialAuthRef" | "officialTomlRef" | "officialInfo" | "loading" | "actionBusy" | "onCancelMode" | "onOfficialNameChange" | "onOfficialModelChange" | "onOfficialAuthChange" | "onOfficialConfigChange" | "onSaveOfficial" | "onLoadCcSwitchOfficial" | "onLoadCurrentOfficial" | "onRestoreOfficial" | "onResetOfficial">) {
const [resetConfirmOpen, setResetConfirmOpen] = useState(false);
const [contextWindowBusy, setContextWindowBusy] = useState(false);
const loadingCcSwitch = actionBusy === "loadCcSwitchOfficial";
const loadingCurrent = actionBusy === "loadCurrentOfficial";
const formBusy = loading || actionBusy === "loadOfficialDraft" || loadingCcSwitch || loadingCurrent || contextWindowBusy;
const showDefaultActions = officialProfileIsDefault && !creatingProvider;
const profileHint = lang === "zh"
? "认证内容可留空;保存并启用后,在 Codex 中完成登录。复制官方供应商会保留原认证,并可修改名称。"
: "Authentication may be left empty. Save and enable this provider, then sign in through Codex. A copied official provider keeps its authentication and can be renamed.";
const confirmReset = () => {
if (!showDefaultActions || formBusy) return;
setResetConfirmOpen(false);
onResetOfficial();
};
return (
<>
<ModeHeader eyebrow={copy.officialEyebrow} title={copy.officialTitle} description={copy.officialHint} cancelLabel={copy.cancelLabel} onCancel={onCancelMode} />
<ModeHeader
eyebrow={copy.officialEyebrow}
title={creatingProvider ? copy.formAddTitle : copy.officialTitle}
description={showDefaultActions ? `${copy.officialHint} ${profileHint}` : profileHint}
cancelLabel={copy.cancelLabel}
onCancel={onCancelMode}
disabled={formBusy}
/>
<ProviderKindSelect lang={lang} creatingProvider={creatingProvider} providerKind={providerKind} onProviderKindChange={onProviderKindChange} disabled={formBusy} />
<div className="cx-providers-info-grid">
<div><span>{copy.officialUrlLabel}</span><code>{officialInfo.officialUrl}</code></div>
<div><span>{copy.authPathLabel}</span><code>{officialInfo.authPath}</code></div>
<div><span>{copy.officialCurrentLabel}</span><code>{officialInfo.current}</code></div>
</div>
<div className="cx-providers-form-grid cx-providers-form-grid--single">
<Field label={copy.modelLabel}><input value={officialForm.model} onChange={(event) => onOfficialModelChange(event.target.value)} /></Field>
<Field label={copy.nameLabel}><input value={officialForm.providerName} onChange={(event) => onOfficialNameChange(event.target.value)} disabled={formBusy} /></Field>
<Field label={copy.modelLabel}><input value={officialForm.model} onChange={(event) => onOfficialModelChange(event.target.value)} disabled={formBusy} /></Field>
</div>
<div className="cx-providers-editor-field">
<div className="cx-providers-context-heading">
<span>{copy.officialTomlLabel}</span>
<ContextWindowControl lang={lang} configText={officialForm.configText} onConfigChange={onOfficialConfigChange} disabled={formBusy} onBusyChange={setContextWindowBusy} />
</div>
<textarea
ref={officialTomlRef}
className="cx-providers-code-editor cx-providers-toml-editor"
value={officialForm.configText}
aria-label={copy.officialTomlLabel}
onChange={(event) => onOfficialConfigChange(event.target.value)}
disabled={formBusy}
spellCheck={false}
/>
</div>
<Field label={copy.officialAuthLabel} className="cx-providers-editor-field">
<textarea
@@ -427,21 +637,38 @@ function OfficialForm({
className="cx-providers-code-editor cx-providers-auth-editor"
value={officialForm.authJson}
onChange={(event) => onOfficialAuthChange(event.target.value)}
disabled={formBusy}
wrap="soft"
spellCheck={false}
/>
</Field>
<div className="cx-providers-form-actions cx-providers-form-actions--save cx-providers-official-actions">
<button type="button" className="cx-providers-button cx-providers-button--secondary" onClick={onRestoreOfficial} disabled={loading}>
<RotateCcw size={15} aria-hidden="true" />{copy.restoreOfficialLabel}
<button type="button" className="cx-providers-button cx-providers-button--secondary" onClick={onLoadCurrentOfficial} disabled={formBusy || !canLoadCurrentOfficial}>
{loadingCurrent
? <Loader2 size={15} className="cx-providers-spin" aria-hidden="true" />
: <Download size={15} aria-hidden="true" />}
{lang === "zh" ? "读取当前官方登录" : "Load current official login"}
</button>
<button type="button" className="cx-providers-button cx-providers-button--secondary" onClick={() => setResetConfirmOpen(true)} disabled={loading}>
<FilePlus2 size={15} aria-hidden="true" />{copy.resetOfficialLabel}
<button type="button" className="cx-providers-button cx-providers-button--secondary" onClick={onLoadCcSwitchOfficial} disabled={formBusy}>
{loadingCcSwitch
? <Loader2 size={15} className="cx-providers-spin" aria-hidden="true" />
: <Download size={15} aria-hidden="true" />}
{copy.loadCcSwitchOfficialLabel}
</button>
<button type="button" className="cx-providers-button cx-providers-button--primary" onClick={onSaveOfficial} disabled={loading}><CheckCircle2 size={15} aria-hidden="true" />{copy.officialSaveLabel}</button>
{showDefaultActions && (
<>
<button type="button" className="cx-providers-button cx-providers-button--secondary" onClick={onRestoreOfficial} disabled={formBusy}>
<RotateCcw size={15} aria-hidden="true" />{copy.restoreOfficialLabel}
</button>
<button type="button" className="cx-providers-button cx-providers-button--secondary" onClick={() => setResetConfirmOpen(true)} disabled={formBusy}>
<FilePlus2 size={15} aria-hidden="true" />{copy.resetOfficialLabel}
</button>
</>
)}
<button type="button" className="cx-providers-button cx-providers-button--primary" onClick={onSaveOfficial} disabled={formBusy}><CheckCircle2 size={15} aria-hidden="true" />{copy.officialSaveLabel}</button>
</div>
<ModalShell
open={resetConfirmOpen}
open={resetConfirmOpen && showDefaultActions}
onClose={() => setResetConfirmOpen(false)}
title={copy.resetOfficialTitle}
description={copy.resetOfficialDescription}
@@ -456,7 +683,7 @@ function OfficialForm({
>
<div className="cx-provider-delete-warning">
<span aria-hidden="true"><AlertTriangle size={22} /></span>
<strong>auth.json</strong>
<strong>config.toml + auth.json</strong>
</div>
</ModalShell>
</>
@@ -464,7 +691,11 @@ function OfficialForm({
}
function ProviderForm({
lang,
copy,
creatingProvider,
providerKind,
onProviderKindChange,
providerForm,
loading,
editingProviderId,
@@ -486,9 +717,11 @@ function ProviderForm({
onProviderTomlDraftChange,
onResetProviderToml,
onSaveProvider,
}: Pick<ProvidersPageProps, "copy" | "providerForm" | "loading" | "editingProviderId" | "providerAuthPreview" | "providerTomlDraft" | "providerTomlRef" | "apiKeyVisible" | "availableModels" | "fetchingModels" | "onCancelMode" | "onApiKeyChange" | "onBaseUrlChange" | "onProviderNameChange" | "onProviderModelChange" | "onFetchModels" | "onWireApiChange" | "onRequiresAuthChange" | "onToggleApiKeyVisibility" | "onProviderTomlDraftChange" | "onResetProviderToml" | "onSaveProvider">) {
}: Pick<ProvidersPageProps, "lang" | "copy" | "creatingProvider" | "providerKind" | "onProviderKindChange" | "providerForm" | "loading" | "editingProviderId" | "providerAuthPreview" | "providerTomlDraft" | "providerTomlRef" | "apiKeyVisible" | "availableModels" | "fetchingModels" | "onCancelMode" | "onApiKeyChange" | "onBaseUrlChange" | "onProviderNameChange" | "onProviderModelChange" | "onFetchModels" | "onWireApiChange" | "onRequiresAuthChange" | "onToggleApiKeyVisibility" | "onProviderTomlDraftChange" | "onResetProviderToml" | "onSaveProvider">) {
const modelListId = useId();
const [contextWindowBusy, setContextWindowBusy] = useState(false);
const canFetchModels = Boolean(providerForm.baseUrl.trim() && providerForm.apiKey.trim());
const formBusy = loading || fetchingModels || contextWindowBusy;
return (
<>
@@ -498,7 +731,9 @@ function ProviderForm({
description={copy.formHint}
cancelLabel={copy.cancelLabel}
onCancel={onCancelMode}
disabled={formBusy}
/>
<ProviderKindSelect lang={lang} creatingProvider={creatingProvider} providerKind={providerKind} onProviderKindChange={onProviderKindChange} disabled={formBusy} />
<section className="cx-providers-form-section">
<div className="cx-providers-section-heading">
@@ -512,14 +747,15 @@ function ProviderForm({
value={providerForm.apiKey}
onChange={(event) => onApiKeyChange(event.target.value)}
placeholder={copy.apiKeyPlaceholder}
disabled={formBusy}
/>
<button type="button" onClick={onToggleApiKeyVisibility} title={apiKeyVisible ? copy.hideApiKeyLabel : copy.showApiKeyLabel} aria-label={apiKeyVisible ? copy.hideApiKeyLabel : copy.showApiKeyLabel}>
<button type="button" onClick={onToggleApiKeyVisibility} title={apiKeyVisible ? copy.hideApiKeyLabel : copy.showApiKeyLabel} aria-label={apiKeyVisible ? copy.hideApiKeyLabel : copy.showApiKeyLabel} disabled={formBusy}>
{apiKeyVisible ? <EyeOff size={15} aria-hidden="true" /> : <Eye size={15} aria-hidden="true" />}
</button>
</div>
</Field>
<Field label={copy.baseUrlLabel} className="cx-providers-field--full"><input value={providerForm.baseUrl} onChange={(event) => onBaseUrlChange(event.target.value)} /></Field>
<Field label={copy.nameLabel}><input value={providerForm.providerName} onChange={(event) => onProviderNameChange(event.target.value)} /></Field>
<Field label={copy.baseUrlLabel} className="cx-providers-field--full"><input value={providerForm.baseUrl} onChange={(event) => onBaseUrlChange(event.target.value)} disabled={formBusy} /></Field>
<Field label={copy.nameLabel}><input value={providerForm.providerName} onChange={(event) => onProviderNameChange(event.target.value)} disabled={formBusy} /></Field>
<Field label={copy.modelLabel}>
<div className="cx-providers-model-input-row">
<input
@@ -527,6 +763,7 @@ function ProviderForm({
list={availableModels.length ? modelListId : undefined}
aria-label={copy.modelLabel}
onChange={(event) => onProviderModelChange(event.target.value)}
disabled={formBusy}
/>
<button
type="button"
@@ -551,6 +788,7 @@ function ProviderForm({
className="cx-providers-model-select"
value=""
aria-label={copy.chooseModelLabel(availableModels.length)}
disabled={formBusy}
onChange={(event) => {
if (event.target.value) onProviderModelChange(event.target.value);
}}
@@ -562,7 +800,7 @@ function ProviderForm({
)}
</Field>
<Field label={copy.wireApiLabel}>
<select value={providerForm.wireApi} onChange={(event) => onWireApiChange(event.target.value)}>
<select value={providerForm.wireApi} onChange={(event) => onWireApiChange(event.target.value)} disabled={formBusy}>
<option value="responses">responses</option>
<option value="chat">chat</option>
</select>
@@ -572,6 +810,7 @@ function ProviderForm({
checked={providerForm.requiresOpenaiAuth}
onCheckedChange={onRequiresAuthChange}
label={copy.requiresAuthLabel}
disabled={formBusy}
/>
</div>
</section>
@@ -584,13 +823,16 @@ function ProviderForm({
<section className="cx-providers-form-section">
<div className="cx-providers-section-heading cx-providers-section-heading--with-action">
<div><h3>{copy.tomlTitle}</h3><p>{copy.tomlDescription}</p></div>
<button type="button" className="cx-providers-button cx-providers-button--secondary cx-providers-button--small" onClick={onResetProviderToml}><RefreshCw size={14} aria-hidden="true" />{copy.resetTomlLabel}</button>
<div className="cx-providers-context-actions">
<ContextWindowControl lang={lang} configText={providerTomlDraft} onConfigChange={onProviderTomlDraftChange} disabled={formBusy} onBusyChange={setContextWindowBusy} />
<button type="button" className="cx-providers-button cx-providers-button--secondary cx-providers-button--small" onClick={onResetProviderToml} disabled={formBusy}><RefreshCw size={14} aria-hidden="true" />{copy.resetTomlLabel}</button>
</div>
</div>
<textarea ref={providerTomlRef} className="cx-providers-code-editor cx-providers-toml-editor" value={providerTomlDraft} onChange={(event) => onProviderTomlDraftChange(event.target.value)} spellCheck={false} />
<textarea ref={providerTomlRef} className="cx-providers-code-editor cx-providers-toml-editor" aria-label={copy.tomlTitle} value={providerTomlDraft} onChange={(event) => onProviderTomlDraftChange(event.target.value)} disabled={formBusy} spellCheck={false} />
</section>
<div className="cx-providers-form-actions cx-providers-form-actions--save">
<button type="button" className="cx-providers-button cx-providers-button--primary" onClick={onSaveProvider} disabled={loading}>
<button type="button" className="cx-providers-button cx-providers-button--primary" onClick={onSaveProvider} disabled={formBusy}>
{loading ? <Loader2 size={15} className="cx-providers-spin" aria-hidden="true" /> : <CheckCircle2 size={15} aria-hidden="true" />}
{loading ? copy.savingLabel : copy.saveLabel}
</button>
@@ -41,7 +41,10 @@ export type SessionSyncStatus = {
topLevelThreads: number;
subagentThreads: number;
mismatchedThreads: number;
mismatchedSessions: number;
needsSync: boolean;
scanComplete: boolean;
scanFailures: string[];
backupDir?: string | null;
warnings: string[];
sessions: SessionPreview[];
@@ -153,13 +156,14 @@ export function SessionManagementPage({
? {
syncEyebrow: "会话同步",
title: "会话管理",
description: "检查本地会话是否跟当前供应商一致,需要时一键同步。不会修改聊天内容。",
description: "检查本地会话是否位于官方与中转共用的会话列表,需要时一键同步。不会修改聊天内容。",
syncTo: "同步到",
check: "检查会话",
checking: "检查中...",
sync: "同步会话",
syncing: "同步中...",
clickToCheck: "点击检查会话",
scanIncomplete: "无法确认同步状态,请查看下方原因",
needsSync: (count: number) => `有 ${count} 条会话需要同步`,
allSynced: "全部会话已同步",
sessionCount: (count: number) => `${count} 条会话`,
@@ -201,13 +205,14 @@ export function SessionManagementPage({
: {
syncEyebrow: "SESSION SYNC",
title: "Session management",
description: "Check whether local sessions match the current provider and sync them when needed. Chat content is not changed.",
description: "Keep local sessions in one history shared by official and third-party providers. Chat content is not changed.",
syncTo: "Sync to",
check: "Check sessions",
checking: "Checking...",
sync: "Sync sessions",
syncing: "Syncing...",
clickToCheck: "Check sessions to get started",
scanIncomplete: "Unable to verify sync status. See the reason below.",
needsSync: (count: number) => `${count} session(s) need syncing`,
allSynced: "All sessions are synced",
sessionCount: (count: number) => `${count} sessions`,
@@ -247,6 +252,11 @@ export function SessionManagementPage({
confirmDelete: (count: number) => `Delete ${count} permanently`,
};
const scanIncomplete = Boolean(sessionStatus && !sessionStatus.scanComplete);
const diagnostics = [
...(sessionStatus?.scanFailures || []).map((message) => ({ message, blocking: true })),
...(sessionStatus?.warnings || []).map((message) => ({ message, blocking: false })),
];
const dialogOpen = sessionDeleteConfirmOpen && selectedSessions.length > 0;
const selectedVisibleCount = filteredSessions.filter((item) => selectedSessionSet.has(item.id)).length;
const allVisibleSelected = filteredSessions.length > 0 && selectedVisibleCount === filteredSessions.length;
@@ -327,17 +337,17 @@ export function SessionManagementPage({
{actionBusy === "checkSessions" ? <Loader2 size={16} className="cx-session-spin" aria-hidden="true" /> : <RefreshCw size={16} aria-hidden="true" />}
{actionBusy === "checkSessions" ? copy.checking : copy.check}
</button>
<button type="button" className="cx-session-button cx-session-button--primary" onClick={onSyncSessions} disabled={loading || !sessionHasMismatches} aria-busy={actionBusy === "syncSessions"}>
<button type="button" className="cx-session-button cx-session-button--primary" onClick={onSyncSessions} disabled={loading || scanIncomplete || !sessionHasMismatches} aria-busy={actionBusy === "syncSessions"}>
{actionBusy === "syncSessions" ? <Loader2 size={16} className="cx-session-spin" aria-hidden="true" /> : <Zap size={16} aria-hidden="true" />}
{actionBusy === "syncSessions" ? copy.syncing : copy.sync}
</button>
</div>
</header>
<div className={cx("cx-session-summary", sessionHasMismatches ? "cx-session-summary--needs-sync" : "cx-session-summary--synced")}>
<div className={cx("cx-session-summary", scanIncomplete || sessionHasMismatches ? "cx-session-summary--needs-sync" : "cx-session-summary--synced")}>
<span className="cx-session-summary-status">
{!sessionStatus ? <Info size={15} aria-hidden="true" /> : sessionHasMismatches ? <AlertCircle size={15} aria-hidden="true" /> : <CheckCircle2 size={15} aria-hidden="true" />}
{!sessionStatus ? copy.clickToCheck : sessionHasMismatches ? copy.needsSync(sessionSyncCount) : copy.allSynced}
{!sessionStatus ? <Info size={15} aria-hidden="true" /> : scanIncomplete || sessionHasMismatches ? <AlertCircle size={15} aria-hidden="true" /> : <CheckCircle2 size={15} aria-hidden="true" />}
{!sessionStatus ? copy.clickToCheck : scanIncomplete ? copy.scanIncomplete : sessionHasMismatches ? copy.needsSync(sessionSyncCount) : copy.allSynced}
</span>
<span className="cx-session-summary-count">{copy.sessionCount(sessionStatus?.topLevelThreads ?? 0)}</span>
</div>
@@ -475,15 +485,15 @@ export function SessionManagementPage({
)}
</div>
{sessionStatus?.warnings?.length ? (
<details className="cx-session-diagnostics">
{diagnostics.length ? (
<details className="cx-session-diagnostics" open={scanIncomplete || undefined}>
<summary>
<AlertCircle size={15} strokeWidth={1.9} aria-hidden="true" />
<span>{copy.diagnostics}</span>
<small>{copy.diagnosticsCount(sessionStatus.warnings.length)}</small>
<small>{copy.diagnosticsCount(diagnostics.length)}</small>
</summary>
<div className="cx-session-diagnostic-items">
{sessionStatus.warnings.map((item, index) => <p key={`${index}-${item}`}><Info size={14} aria-hidden="true" />{item}</p>)}
{diagnostics.map((item, index) => <p key={`${index}-${item.message}`}>{item.blocking ? <AlertCircle size={14} aria-hidden="true" /> : <Info size={14} aria-hidden="true" />}{item.message}</p>)}
</div>
</details>
) : null}
+127 -7
View File
@@ -1,4 +1,4 @@
import { useId } from "react";
import { useId, useState } from "react";
import type { ChangeEvent, RefObject } from "react";
import {
AlertCircle,
@@ -7,6 +7,7 @@ import {
FolderInput,
Loader2,
PackageOpen,
PencilLine,
PlugZap,
RefreshCw,
Sparkles,
@@ -14,11 +15,12 @@ import {
} from "lucide-react";
import { PageTransition } from "../components/PageTransition";
import { Button, ModalShell, StatusBadge, Toggle, cx } from "../components/ui";
import { Button, IconButton, ModalShell, StatusBadge, Toggle, cx } from "../components/ui";
import type { Lang, ManagedMcpServer, ManagedSkill, SkillsMcpImportPreview, SkillsMcpState } from "../types";
import "../styles/skills-prompts-pages.css";
export type SkillsMcpTab = "mcp" | "skills";
export type SkillsMcpNoteKind = "mcp" | "skill";
type MaybeAsyncAction = () => void | Promise<void>;
@@ -27,6 +29,7 @@ export type SkillsMcpPageProps = {
state: SkillsMcpState | null;
activeTab: SkillsMcpTab;
actionBusy: string;
noteBusyKey: string;
importOpen: boolean;
importPreview: SkillsMcpImportPreview | null;
zipInputRef: RefObject<HTMLInputElement>;
@@ -40,6 +43,14 @@ export type SkillsMcpPageProps = {
onCheckUpdates: MaybeAsyncAction;
onToggleSkill: (id: string, enabled: boolean) => void | Promise<void>;
onToggleMcp: (id: string, enabled: boolean) => void | Promise<void>;
onSaveNote: (itemKind: SkillsMcpNoteKind, id: string, note: string) => Promise<boolean>;
};
type NoteTarget = {
itemKind: SkillsMcpNoteKind;
id: string;
name: string;
note: string;
};
type SkillsMcpCopy = ReturnType<typeof getCopy>;
@@ -65,6 +76,16 @@ function getCopy(lang: Lang) {
enableSkill: "启用 Skill",
disableSkill: "禁用 Skill",
updateStatus: "更新状态",
addNote: "添加备注",
editNote: "编辑备注",
noteTitle: "自定义备注",
noteDescription: (name: string) => `为“${name}”添加便于识别的说明。`,
noteLabel: "备注内容",
notePlaceholder: "例如:用于查询项目文档,依赖本地 Node.js",
noteEmptyHint: "留空保存将清除现有备注",
noteCount: (count: number) => `${count} / 1000`,
saveNote: "保存备注",
savingNote: "保存中",
importTitle: "确认导入已有内容",
importDescription: "以下内容来自本机现有配置。导入后可在此统一启用或禁用。",
noImportItems: "没有发现可导入的已有 Skills / MCP。",
@@ -94,6 +115,16 @@ function getCopy(lang: Lang) {
enableSkill: "Enable Skill",
disableSkill: "Disable Skill",
updateStatus: "Update status",
addNote: "Add note",
editNote: "Edit note",
noteTitle: "Custom note",
noteDescription: (name: string) => `Add a description that helps you recognize “${name}”.`,
noteLabel: "Note",
notePlaceholder: "For example: searches project docs; requires local Node.js",
noteEmptyHint: "Save an empty note to remove it",
noteCount: (count: number) => `${count} / 1000`,
saveNote: "Save note",
savingNote: "Saving",
importTitle: "Confirm import",
importDescription: "These items were found in the existing local configuration. Import them to manage their state here.",
noImportItems: "No existing Skills / MCP items were found.",
@@ -116,22 +147,33 @@ function McpRow({
disabled,
copy,
onToggle,
onEditNote,
}: {
server: ManagedMcpServer;
busy: boolean;
disabled: boolean;
copy: SkillsMcpCopy;
onToggle: SkillsMcpPageProps["onToggleMcp"];
onEditNote: (target: NoteTarget) => void;
}) {
const name = server.name || server.id;
const details = [server.summary, server.transport, server.source].filter(Boolean).join("\n");
const details = [server.note, server.summary, server.transport, server.source].filter(Boolean).join("\n");
return (
<article className="cx-skills-row">
<div className="cx-skills-row-copy">
<div className={cx("cx-skills-row-copy", Boolean(server.note) && "cx-skills-row-copy--noted")}>
<strong title={details ? `${name}\n${details}` : name}>{name}</strong>
{server.note && <span className="cx-skills-row-note" title={server.note}>{server.note}</span>}
</div>
<div className="cx-skills-row-control">
{busy && <Loader2 size={15} className="cx-skills-spin" aria-hidden="true" />}
<IconButton
icon={<PencilLine size={15} strokeWidth={1.9} />}
label={server.note ? copy.editNote : copy.addNote}
size="sm"
className="cx-skills-note-button"
onClick={() => onEditNote({ itemKind: "mcp", id: server.id, name, note: server.note || "" })}
disabled={disabled}
/>
<Toggle
checked={server.enabled}
onCheckedChange={(enabled) => void onToggle(server.id, enabled)}
@@ -149,12 +191,14 @@ function SkillRow({
disabled,
copy,
onToggle,
onEditNote,
}: {
skill: ManagedSkill;
busy: boolean;
disabled: boolean;
copy: SkillsMcpCopy;
onToggle: SkillsMcpPageProps["onToggleSkill"];
onEditNote: (target: NoteTarget) => void;
}) {
const name = skill.name || skill.directory;
const updateTone = skill.updateStatus.includes("失败") || skill.updateStatus.toLowerCase().includes("fail")
@@ -164,20 +208,29 @@ function SkillRow({
: "neutral";
const showUpdateStatus = Boolean(skill.updateStatus && skill.updateStatus !== "未检查");
const details = [skill.description, skill.source].filter(Boolean).join("\n");
const details = [skill.note, skill.description, skill.source].filter(Boolean).join("\n");
return (
<article className="cx-skills-row">
<div className="cx-skills-row-copy">
<div className={cx("cx-skills-row-copy", Boolean(skill.note) && "cx-skills-row-copy--noted")}>
<strong title={details ? `${name}\n${details}` : name}>{name}</strong>
{skill.note && <span className="cx-skills-row-note" title={skill.note}>{skill.note}</span>}
{showUpdateStatus && (
<StatusBadge tone={updateTone} dot={false} title={copy.updateStatus}>
<StatusBadge tone={updateTone} dot={false} title={`${copy.updateStatus}: ${skill.updateStatus}`}>
{skill.updateStatus}
</StatusBadge>
)}
</div>
<div className="cx-skills-row-control">
{busy && <Loader2 size={15} className="cx-skills-spin" aria-hidden="true" />}
<IconButton
icon={<PencilLine size={15} strokeWidth={1.9} />}
label={skill.note ? copy.editNote : copy.addNote}
size="sm"
className="cx-skills-note-button"
onClick={() => onEditNote({ itemKind: "skill", id: skill.id, name, note: skill.note || "" })}
disabled={disabled}
/>
<Toggle
checked={skill.enabled}
onCheckedChange={(enabled) => void onToggle(skill.id, enabled)}
@@ -255,6 +308,7 @@ export function SkillsMcpPage({
state,
activeTab,
actionBusy,
noteBusyKey,
importOpen,
importPreview,
zipInputRef,
@@ -268,12 +322,34 @@ export function SkillsMcpPage({
onCheckUpdates,
onToggleSkill,
onToggleMcp,
onSaveNote,
}: SkillsMcpPageProps) {
const copy = getCopy(lang);
const tabId = useId();
const anyBusy = Boolean(actionBusy);
const importBusy = actionBusy === "importExistingSkillsMcp";
const activeItems = activeTab === "mcp" ? state?.mcpServers ?? [] : state?.skills ?? [];
const [noteTarget, setNoteTarget] = useState<NoteTarget | null>(null);
const [noteDraft, setNoteDraft] = useState("");
const noteBusy = noteTarget
? noteBusyKey === `note:${noteTarget.itemKind}:${noteTarget.id}`
: false;
const openNoteEditor = (target: NoteTarget) => {
setNoteTarget(target);
setNoteDraft(target.note);
};
const closeNoteEditor = () => {
if (!noteBusy) setNoteTarget(null);
};
const updateNoteDraft = (value: string) => {
setNoteDraft(Array.from(value).slice(0, 1000).join(""));
};
const saveNote = async () => {
if (!noteTarget || noteBusy) return;
if (await onSaveNote(noteTarget.itemKind, noteTarget.id, noteDraft)) {
setNoteTarget(null);
}
};
const handleZipChange = (event: ChangeEvent<HTMLInputElement>) => {
void onInstallZip(event.currentTarget.files?.[0]);
};
@@ -395,6 +471,7 @@ export function SkillsMcpPage({
disabled={anyBusy}
copy={copy}
onToggle={onToggleMcp}
onEditNote={openNoteEditor}
/>
))
) : state.skills.length === 0 ? (
@@ -407,6 +484,7 @@ export function SkillsMcpPage({
disabled={anyBusy}
copy={copy}
onToggle={onToggleSkill}
onEditNote={openNoteEditor}
/>
))}
</div>
@@ -453,6 +531,48 @@ export function SkillsMcpPage({
>
<ImportPreviewContent preview={importPreview} copy={copy} />
</ModalShell>
<ModalShell
open={Boolean(noteTarget)}
onClose={closeNoteEditor}
title={copy.noteTitle}
description={noteTarget ? copy.noteDescription(noteTarget.name) : undefined}
size="sm"
closeOnBackdrop={!noteBusy}
closeOnEscape={!noteBusy}
showCloseButton={!noteBusy}
className="cx-skills-note-modal"
footer={(
<>
<Button variant="secondary" onClick={closeNoteEditor} disabled={noteBusy}>
{copy.cancel}
</Button>
<Button
icon={noteBusy ? <Loader2 className="cx-skills-spin" /> : <PencilLine />}
onClick={() => void saveNote()}
disabled={noteBusy}
>
{noteBusy ? copy.savingNote : copy.saveNote}
</Button>
</>
)}
>
<label className="cx-skills-note-field">
<span>{copy.noteLabel}</span>
<textarea
value={noteDraft}
onChange={(event) => updateNoteDraft(event.target.value)}
placeholder={copy.notePlaceholder}
rows={5}
disabled={noteBusy}
data-initial-focus
/>
</label>
<div className="cx-skills-note-meta">
<span>{copy.noteEmptyHint}</span>
<span>{copy.noteCount(Array.from(noteDraft).length)}</span>
</div>
</ModalShell>
</section>
);
}
-475
View File
@@ -1,475 +0,0 @@
import { useEffect, useMemo, useState } from "react";
import type { CSSProperties, ChangeEvent, RefObject } from "react";
import { convertFileSrc } from "@tauri-apps/api/core";
import {
Check,
Download,
ImagePlus,
Loader2,
Palette,
Pencil,
Play,
Power,
RefreshCw,
Settings2,
Upload,
} from "lucide-react";
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;
pauseBusy: boolean;
zipInputRef: RefObject<HTMLInputElement>;
imageInputRef: RefObject<HTMLInputElement>;
onLoad: MaybeAsyncAction;
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) {
return lang === "zh"
? {
eyebrow: "SKIN CENTER",
title: "皮肤中心",
description: "在 Codex-X 中管理主题包,并直接应用到官方 Codex Desktop。",
refresh: "刷新",
refreshingButton: "刷新中",
refreshing: "正在刷新主题库...",
importZip: "导入主题包",
createFromImage: "从图片创建皮肤",
creatingFromImage: "正在创建",
pause: "关闭皮肤",
pausing: "关闭中",
loading: "正在读取皮肤库...",
builtin: "内置",
imported: "导入",
selected: "已选择",
applied: "已启用",
apply: "启用",
export: "导出",
edit: "编辑",
applying: "启用中",
exporting: "导出中",
current: "当前皮肤",
runtime: "Codex 运行状态",
noCurrent: "尚未启用皮肤",
noThemes: "还没有皮肤。选择一张图片即可创建。",
manageCategories: "分类管理",
emptyCategory: "该分类下暂无皮肤",
statusReady: "主题库已就绪",
selectTheme: (name: string) => `选择皮肤:${name}`,
}
: {
eyebrow: "SKIN CENTER",
title: "Skin Center",
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",
selected: "Selected",
applied: "Enabled",
apply: "Enable",
export: "Export",
edit: "Edit",
applying: "Enabling",
exporting: "Exporting",
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}`,
};
}
function run(action: MaybeAsyncAction) {
void action();
}
function sourceLabel(theme: SkinThemeSummary, copy: ReturnType<typeof getCopy>) {
return theme.source === "builtin" ? copy.builtin : copy.imported;
}
function ThemeCard({
theme,
copy,
runtimeActive,
activeThemeId,
selected,
onSelect,
}: {
theme: SkinThemeSummary;
copy: ReturnType<typeof getCopy>;
runtimeActive: boolean;
activeThemeId?: string | null;
selected: boolean;
onSelect: () => void;
}) {
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,
"--skin-secondary": theme.colors.secondary,
"--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 || 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>
<div className="cx-skins-card-body">
<div className="cx-skins-card-title">
<div>
<strong>{theme.name}</strong>
<span>{theme.tagline}</span>
</div>
<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 aria-hidden={!theme.quote}>{theme.quote || "\u00a0"}</p>
</div>
</article>
);
}
export function SkinsPage({
lang,
state,
actionBusy,
pauseBusy,
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 controlsBusy = Boolean(actionBusy) || pauseBusy;
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).then(selectCreatedTheme);
};
const handleImageChange = (event: ChangeEvent<HTMLInputElement>) => {
void onCreateFromImage(event.currentTarget.files?.[0] ?? null).then(selectCreatedTheme);
};
return (
<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"
type="file"
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">
<p><Palette size={15} aria-hidden="true" />{copy.eyebrow}</p>
<h2>{copy.title}</h2>
<span>{copy.description}</span>
</div>
<div className="cx-skins-actions">
{selectedTheme && (
<>
<Button
variant="secondary"
onClick={() => setEditorOpen(true)}
disabled={controlsBusy}
icon={<Pencil size={15} />}
>
{copy.edit}
</Button>
<Button
variant="secondary"
onClick={() => run(() => onEnableTheme(selectedTheme.id))}
disabled={controlsBusy || 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={controlsBusy}
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(onPauseTheme)}
disabled={pauseBusy}
icon={pauseBusy ? <Loader2 className="cx-skins-spin" size={15} /> : <Power size={15} />}
>
{pauseBusy ? copy.pausing : copy.pause}
</Button>
<Button
variant="secondary"
onClick={() => zipInputRef.current?.click()}
disabled={controlsBusy || !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={controlsBusy || !state}
icon={actionBusy === "createSkinFromImage"
? <Loader2 className="cx-skins-spin" size={15} />
: <ImagePlus size={15} />}
>
{actionBusy === "createSkinFromImage" ? copy.creatingFromImage : copy.createFromImage}
</Button>
</div>
</header>
<section className="cx-skins-status" aria-label={copy.statusReady}>
<div>
<span>{copy.current}</span>
<strong>{current?.name || copy.noCurrent}</strong>
</div>
<div>
<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={controlsBusy}
/>
</section>
{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>
);
}
@@ -0,0 +1,316 @@
import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
import type { KeyboardEvent, ReactNode } from "react";
import { invoke } from "@tauri-apps/api/core";
import {
Activity, AlertCircle, ArrowDownLeft, ArrowUpRight, BarChart3,
CalendarDays, Check, Database, Loader2, MessageSquare, RefreshCw,
} from "lucide-react";
import type { LucideIcon } from "lucide-react";
import type { UsageDay, UsageModel, UsageRange, UsageStatistics } from "../usageTypes";
import "../styles/usage-statistics.css";
type Language = "zh" | "en";
export type UsageStatisticsPageProps = {
lang: Language;
configDir: string;
active?: boolean;
};
export function formatUsageTokens(value: number): string {
if (!Number.isFinite(value) || value <= 0) return "0";
const units: [number, string][] = [[1_000_000_000, "B"], [1_000_000, "M"], [1_000, "K"]];
for (const [divisor, unit] of units) {
if (value >= divisor) {
const scaled = value / divisor;
return `${Number(scaled.toFixed(scaled < 10 ? 2 : 1))}${unit}`;
}
}
return Math.round(value).toString();
}
const exact = (value: number, lang: Language) => value.toLocaleString(lang === "zh" ? "zh-CN" : "en-US");
const ratio = (part: number, total: number) => total > 0 ? Math.min(100, Math.max(0, part / total * 100)) : 0;
const percentage = (value: number) => `${Number(value.toFixed(1))}%`;
function modelLabel(model: string, lang: Language): string {
if (model === "unknown") return lang === "zh" ? "未知模型" : "Unknown model";
if (model === "multiple") return lang === "zh" ? "多个模型" : "Multiple models";
return model;
}
function sessionTitleLabel(title: string, lang: Language): string {
return title && title !== "未命名会话" ? title : lang === "zh" ? "未命名会话" : "Untitled session";
}
function dateLabel(date: string, lang: Language, includeYear = false): string {
const value = new Date(date.length === 10 ? `${date}T12:00:00` : date);
if (Number.isNaN(value.getTime())) return date;
return value.toLocaleDateString(lang === "zh" ? "zh-CN" : "en-US", {
...(includeYear ? { year: "numeric" as const } : {}), month: "short", day: "numeric",
});
}
function timestampLabel(date: string, lang: Language): string {
const value = new Date(date);
return Number.isNaN(value.getTime()) ? date : value.toLocaleString(lang === "zh" ? "zh-CN" : "en-US", {
month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hour12: false,
});
}
function copyFor(lang: Language) {
return lang === "zh" ? {
title: "Token 用量统计",
description: "查看 Codex 的 Token 使用情况,了解用量趋势与模型分布。",
range: "统计范围", ranges: { today: "今天", "7d": "近 7 天", "30d": "近 30 天", all: "全部" },
model: "模型", allModels: "全部模型", refresh: "刷新用量", refreshing: "更新中", retry: "重试",
total: "总 Token 用量", totalHint: "输入 + 输出", input: "输入 Token", output: "输出 Token",
freshInput: "未缓存输入", cache: "缓存输入", cached: "已缓存", cacheRate: "缓存命中率",
inputHint: "包含缓存输入", outputHint: "包含推理 Token", reasoning: "推理 Token",
sessions: "活跃会话", sessionsHint: "主会话数量,含子代理产生的用量", tokens: "Token",
trend: "用量趋势", trendHint: "按日查看输入、缓存和输出的构成", peak: "最高用量日",
legendLabel: "Token 构成", chartLabel: "每日 Token 用量;左右方向键切换日期",
breakdown: "每日明细", date: "日期", totalColumn: "合计", modelTitle: "模型分布",
modelHint: "按 Token 用量排序", modelCount: "个模型", modelFilter: "筛选此模型",
recent: "最近会话", recentHint: "展示最近 10 个有用量的主会话;子代理用量合并到所属主会话,总量包含全部符合条件的记录。",
session: "会话", lastActive: "最近活动", noData: "这里将记录你的 Codex 用量",
noDataDescription: "当前范围内还没有可统计的会话记录。使用 Codex 后点击刷新,或试试更大的日期范围。",
showAll: "查看全部时间", loading: "正在整理本地会话用量…",
error: "暂时无法读取用量", stale: "刷新失败,下面保留上次读取的结果。",
partial: "部分记录未计入", partialHint: "部分本地记录不完整,当前结果仅包含可读取的用量。",
source: "本地会话记录", updated: "更新于", notes: "缓存输入已包含在输入 Token 中,推理 Token 已包含在输出 Token 中。历史记录缺失的用量无法补算。",
scanned: "已检查", files: "份记录", filtered: "当前模型", activity: "使用概览",
noModels: "暂无模型用量", zeroDay: "当天无用量",
} : {
title: "Token usage statistics",
description: "Explore your Codex usage, daily activity, and model distribution.",
range: "Date range", ranges: { today: "Today", "7d": "7 days", "30d": "30 days", all: "All time" },
model: "Model", allModels: "All models", refresh: "Refresh usage", refreshing: "Updating", retry: "Retry",
total: "Total token usage", totalHint: "Input + output", input: "Input tokens", output: "Output tokens",
freshInput: "Uncached input", cache: "Cached input", cached: "cached", cacheRate: "Cache hit rate",
inputHint: "Includes cached input", outputHint: "Includes reasoning tokens", reasoning: "Reasoning tokens",
sessions: "Active sessions", sessionsHint: "Main conversations, including subagent usage", tokens: "tokens",
trend: "Usage over time", trendHint: "Daily input, cache, and output breakdown", peak: "Peak day",
legendLabel: "Token breakdown", chartLabel: "Daily token usage; use left and right arrows to explore dates",
breakdown: "Daily details", date: "Date", totalColumn: "Total", modelTitle: "Model distribution",
modelHint: "Ranked by token usage", modelCount: "models", modelFilter: "Filter by this model",
recent: "Recent sessions", recentHint: "Showing the 10 most recent main conversations. Subagent usage is included in its parent conversation; totals include all matching records.",
session: "Session", lastActive: "Last active", noData: "Your Codex usage will appear here",
noDataDescription: "No usage records in this range yet. Refresh after using Codex, or try a broader date range.",
showAll: "View all time", loading: "Reading local session usage…",
error: "Unable to load usage", stale: "Refresh failed. The last available results are shown below.",
partial: "Some records were excluded", partialHint: "Some local records are incomplete. These results include readable usage only.",
source: "Local session records", updated: "Updated", notes: "Cached input is included in input tokens; reasoning is included in output tokens. Usage cannot be recovered from missing historical records.",
scanned: "Checked", files: "records", filtered: "Selected model", activity: "Usage overview",
noModels: "No model usage yet", zeroDay: "No usage this day",
};
}
type UsageCopy = ReturnType<typeof copyFor>;
function TokenValue({ value, lang, className = "" }: { value: number; lang: Language; className?: string }) {
return <span className={`cx-usage-number ${className}`} title={exact(value, lang)} aria-label={exact(value, lang)}>{formatUsageTokens(value)}</span>;
}
function Metric({ icon: Icon, label, children, hint, accent }: {
icon: LucideIcon; label: string; children: ReactNode; hint: ReactNode; accent: string;
}) {
return <article className={`cx-usage-metric cx-usage-metric--${accent}`}>
<div className="cx-usage-metric-label"><Icon size={15} aria-hidden="true" /><span>{label}</span></div>
<div className="cx-usage-metric-value">{children}</div>
<p>{hint}</p>
</article>;
}
function Legend({ copy }: { copy: UsageCopy }) {
return <div className="cx-usage-legend" aria-label={copy.legendLabel}>
<span><i className="cx-usage-swatch--input" />{copy.freshInput}</span>
<span><i className="cx-usage-swatch--cache" />{copy.cache}</span>
<span><i className="cx-usage-swatch--output" />{copy.output}</span>
</div>;
}
function UsageTrend({ days, lang, copy }: { days: UsageDay[]; lang: Language; copy: UsageCopy }) {
const chartId = useId();
const [selectedDate, setSelectedDate] = useState<string | null>(null);
const barsRef = useRef<(SVGGElement | null)[]>([]);
const peakIndex = days.reduce((winner, day, index) => day.totalTokens > (days[winner]?.totalTokens ?? -1) ? index : winner, 0);
const selectedIndex = Math.max(0, selectedDate ? days.findIndex((day) => day.date === selectedDate) : peakIndex);
const selected = days[selectedIndex];
const chartWidth = Math.max(600, days.length * 13 + 56);
const chartHeight = 218;
const baseline = 181;
const top = 15;
const left = 48;
const drawableWidth = chartWidth - left - 12;
const max = Math.max(...days.map((day) => day.inputTokens + day.outputTokens), 1);
const ceiling = max <= 5 ? 5 : Math.ceil(max / 10 ** Math.floor(Math.log10(max))) * 10 ** Math.floor(Math.log10(max));
const step = drawableWidth / Math.max(days.length, 1);
const width = Math.min(38, step * 0.62);
const spansYears = days[0]?.date.slice(0, 4) !== days[days.length - 1]?.date.slice(0, 4);
const scale = (value: number) => value / ceiling * (baseline - top);
const keyDown = (event: KeyboardEvent<SVGGElement>, index: number) => {
let next = index;
if (event.key === "Enter" || event.key === " ") next = index;
else if (event.key === "ArrowLeft") next = Math.max(0, index - 1);
else if (event.key === "ArrowRight") next = Math.min(days.length - 1, index + 1);
else if (event.key === "Home") next = 0;
else if (event.key === "End") next = days.length - 1;
else return;
event.preventDefault();
setSelectedDate(days[next].date);
barsRef.current[next]?.focus();
};
return <section className="cx-usage-card cx-usage-trend">
<div className="cx-usage-card-heading"><div><h3>{copy.trend}</h3><p>{copy.trendHint}</p></div><BarChart3 size={18} aria-hidden="true" /></div>
<Legend copy={copy} />
{selected && <div className="cx-usage-day-summary" id={`${chartId}-selection`} aria-live="polite" aria-atomic="true">
<div><strong>{dateLabel(selected.date, lang, true)}</strong>{selectedIndex === peakIndex && selected.totalTokens > 0 && <span>{copy.peak}</span>}</div>
<div><TokenValue value={selected.totalTokens} lang={lang} /><small>{copy.tokens}</small></div>
<p>{copy.input} {exact(selected.inputTokens, lang)} · {copy.cache} {exact(selected.cachedInputTokens, lang)} · {copy.output} {exact(selected.outputTokens, lang)}</p>
</div>}
<div className="cx-usage-chart-scroll">
<svg className="cx-usage-chart" viewBox={`0 0 ${chartWidth} ${chartHeight}`} style={{ minWidth: Math.max(440, days.length * 11 + 56) }} role="group" aria-label={copy.chartLabel}>
{[0, 0.5, 1].map((fraction) => <g key={fraction} aria-hidden="true">
<line x1={left} x2={chartWidth - 12} y1={baseline - scale(ceiling * fraction)} y2={baseline - scale(ceiling * fraction)} className="cx-usage-gridline" />
<text x={left - 9} y={baseline - scale(ceiling * fraction) + 3} textAnchor="end" className="cx-usage-axis">{formatUsageTokens(ceiling * fraction)}</text>
</g>)}
{days.map((day, index) => {
const cached = Math.min(day.cachedInputTokens, day.inputTokens);
const fresh = Math.max(0, day.inputTokens - cached);
const inputHeight = scale(fresh);
const cachedHeight = scale(cached);
const outputHeight = scale(day.outputTokens);
const x = left + step * index + (step - width) / 2;
const showLabel = days.length <= 8 || index === days.length - 1 || index % Math.ceil(days.length / 7) === 0;
const label = `${dateLabel(day.date, lang, true)}: ${copy.input} ${exact(day.inputTokens, lang)}, ${copy.cache} ${exact(cached, lang)}, ${copy.output} ${exact(day.outputTokens, lang)}, ${copy.totalColumn} ${exact(day.totalTokens, lang)}`;
return <g key={day.date} ref={(element) => { barsRef.current[index] = element; }} className={`cx-usage-day${index === selectedIndex ? " cx-usage-day--selected" : ""}`} role="button" tabIndex={index === selectedIndex ? 0 : -1} aria-label={label} aria-pressed={index === selectedIndex} onMouseEnter={() => setSelectedDate(day.date)} onFocus={() => setSelectedDate(day.date)} onClick={() => setSelectedDate(day.date)} onKeyDown={(event) => keyDown(event, index)}>
<title>{label}</title>
<rect x={left + step * index} y={top - 4} width={step} height={baseline - top + 10} rx={4} className="cx-usage-chart-hit" />
<rect x={x} y={baseline - inputHeight} width={width} height={inputHeight} className="cx-usage-swatch--input" />
<rect x={x} y={baseline - inputHeight - cachedHeight} width={width} height={cachedHeight} className="cx-usage-swatch--cache" />
<rect x={x} y={baseline - inputHeight - cachedHeight - outputHeight} width={width} height={outputHeight} className="cx-usage-swatch--output" />
{day.totalTokens === 0 && <rect x={x} y={baseline - 2} width={width} height={2} rx={1} className="cx-usage-zero-bar" />}
{showLabel && <text x={x + width / 2} y={baseline + 22} textAnchor="middle" className="cx-usage-axis" aria-hidden="true">{dateLabel(day.date, lang, spansYears)}</text>}
</g>;
})}
</svg>
</div>
<details className="cx-usage-daily-details"><summary>{copy.breakdown}<span>{days.length}</span></summary>
<div className="cx-usage-table-scroll"><table className="cx-usage-table"><caption className="cx-usage-sr-only">{copy.breakdown}</caption><thead><tr><th>{copy.date}</th><th>{copy.input}</th><th>{copy.cache}</th><th>{copy.output}</th><th>{copy.totalColumn}</th></tr></thead><tbody>
{days.map((day) => <tr key={day.date}><th scope="row">{dateLabel(day.date, lang, true)}</th><td>{exact(day.inputTokens, lang)}</td><td>{exact(day.cachedInputTokens, lang)}</td><td>{exact(day.outputTokens, lang)}</td><td>{exact(day.totalTokens, lang)}</td></tr>)}
</tbody></table></div>
</details>
</section>;
}
function ModelDistribution({ models, lang, copy, onModel }: { models: UsageModel[]; lang: Language; copy: UsageCopy; onModel: (model: string) => void }) {
const rows = [...models].sort((a, b) => b.totalTokens - a.totalTokens);
const total = rows.reduce((sum, model) => sum + model.totalTokens, 0);
const circumference = 2 * Math.PI * 58;
let cumulative = 0;
return <section className="cx-usage-card cx-usage-models">
<div className="cx-usage-card-heading"><div><h3>{copy.modelTitle}</h3><p>{copy.modelHint}</p></div></div>
<div className="cx-usage-donut-wrap"><svg className="cx-usage-donut" viewBox="0 0 160 160" role="img" aria-label={`${copy.modelTitle}: ${rows.map((row) => `${modelLabel(row.model, lang)} ${percentage(ratio(row.totalTokens, total))}`).join(", ")}`}>
<circle cx="80" cy="80" r="58" fill="none" className="cx-usage-donut-track" strokeWidth="15" />
{rows.map((row, index) => {
const fraction = total > 0 ? row.totalTokens / total : 0;
const offset = cumulative;
cumulative += fraction;
return <circle key={row.model} cx="80" cy="80" r="58" fill="none" strokeWidth="15" strokeDasharray={`${Math.max(0, fraction * circumference - (rows.length > 1 ? 3 : 0))} ${circumference}`} strokeDashoffset={-offset * circumference} transform="rotate(-90 80 80)" className={`cx-usage-model-color-${index % 5}`}><title>{modelLabel(row.model, lang)}: {exact(row.totalTokens, lang)} {copy.tokens}</title></circle>;
})}
</svg><div className="cx-usage-donut-label"><strong>{rows.length}</strong><span>{copy.modelCount}</span></div></div>
<div className="cx-usage-model-list">
{rows.length === 0 && <p className="cx-usage-muted">{copy.noModels}</p>}
{rows.map((row, index) => <button key={row.model} type="button" className="cx-usage-model-row" onClick={() => onModel(row.model)} title={`${copy.modelFilter}: ${modelLabel(row.model, lang)}`}>
<span className={`cx-usage-model-dot cx-usage-model-color-${index % 5}`} aria-hidden="true" />
<span className="cx-usage-model-name">{modelLabel(row.model, lang)}</span>
<span className="cx-usage-model-amount"><TokenValue value={row.totalTokens} lang={lang} /><small>{percentage(ratio(row.totalTokens, total))}</small></span>
</button>)}
</div>
</section>;
}
export function UsageStatisticsPage({ lang, configDir, active = true }: UsageStatisticsPageProps) {
const copy = copyFor(lang);
const [range, setRange] = useState<UsageRange>("7d");
const [model, setModel] = useState("");
const [record, setRecord] = useState<{ key: string; data: UsageStatistics } | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const requestRef = useRef(0);
const previousDir = useRef(configDir);
const queryKey = JSON.stringify([configDir, range, model]);
const data = record?.key === queryKey ? record.data : null;
const load = useCallback(async (forceRefresh = false) => {
if (!active) return;
const request = ++requestRef.current;
setBusy(true);
setError("");
try {
const next = await invoke<UsageStatistics>("get_usage_statistics", { configDir, range, model: model || null, forceRefresh });
if (request === requestRef.current) setRecord({ key: queryKey, data: next });
} catch (cause) {
if (request === requestRef.current) setError(cause instanceof Error ? cause.message : String(cause));
} finally {
if (request === requestRef.current) setBusy(false);
}
}, [active, configDir, range, model, queryKey]);
useEffect(() => {
if (previousDir.current !== configDir) {
previousDir.current = configDir;
if (model) {
setModel("");
return;
}
}
void load();
return () => { requestRef.current += 1; };
}, [configDir, model, load]);
const modelOptions = useMemo(() => {
const available = record?.key && JSON.parse(record.key)[0] === configDir ? record.data.availableModels : [];
return Array.from(new Set([...available, ...(model ? [model] : [])])).sort();
}, [record, configDir, model]);
const partial = data && (data.coverage.skippedFiles > 0 || data.coverage.truncated || data.coverage.warnings.length > 0);
const hasUsage = data && data.totals.totalTokens > 0;
const cacheRate = data ? ratio(data.totals.cachedInputTokens, data.totals.inputTokens) : 0;
return <div className="cx-usage" aria-busy={busy}>
<header className="cx-usage-heading"><div><h3>{copy.title}</h3><p>{copy.description}</p></div><span className="cx-usage-local-badge"><Database size={12} aria-hidden="true" />{copy.source}</span></header>
<div className="cx-usage-toolbar">
<div className="cx-usage-range" role="group" aria-label={copy.range}>
{(Object.keys(copy.ranges) as UsageRange[]).map((value) => <button key={value} type="button" aria-pressed={range === value} className={range === value ? "cx-usage-range-active" : ""} onClick={() => setRange(value)}>{copy.ranges[value]}</button>)}
</div>
<div className="cx-usage-toolbar-actions"><label className="cx-usage-model-select"><span className="cx-usage-sr-only">{copy.model}</span><select value={model} onChange={(event) => setModel(event.currentTarget.value)} aria-label={copy.model}><option value="">{copy.allModels}</option>{modelOptions.map((value) => <option key={value} value={value}>{modelLabel(value, lang)}</option>)}</select></label>
<button type="button" className="cx-page-button cx-page-button--secondary cx-usage-refresh" disabled={busy} onClick={() => void load(true)}><RefreshCw size={14} className={busy ? "cx-page-spin" : ""} aria-hidden="true" />{busy ? copy.refreshing : copy.refresh}</button>
</div>
</div>
{error && <div className="cx-usage-notice cx-usage-notice--error" role="alert"><AlertCircle size={18} aria-hidden="true" /><div><strong>{copy.error}</strong>{data && <p>{copy.stale}</p>}<p>{error}</p></div><button type="button" onClick={() => void load(true)} disabled={busy}>{copy.retry}</button></div>}
{!data && !error && <div className="cx-usage-loading" role="status"><Loader2 size={27} className="cx-page-spin" aria-hidden="true" /><p>{copy.loading}</p><div className="cx-usage-loading-cards" aria-hidden="true"><span /><span /><span /></div></div>}
{partial && <div className="cx-usage-notice cx-usage-notice--warning" role="status"><AlertCircle size={18} aria-hidden="true" /><div><strong>{copy.partial}</strong><p>{copy.partialHint}</p>{data.coverage.warnings.length > 0 && <details><summary>{lang === "zh" ? "查看详情" : "View details"}</summary><ul>{data.coverage.warnings.map((warning, index) => <li key={`${index}-${warning}`}>{warning}</li>)}</ul></details>}</div></div>}
{data && !hasUsage && <section className="cx-usage-empty"><div className="cx-usage-empty-visual" aria-hidden="true"><span /><span /><span /><span /><BarChart3 size={23} /></div><h3>{copy.noData}</h3><p>{copy.noDataDescription}</p>{(range !== "all" || model) && <button type="button" className="cx-page-button cx-page-button--secondary" onClick={() => { setRange("all"); setModel(""); }}>{copy.showAll}</button>}</section>}
{data && hasUsage && <>
<section className="cx-usage-summary" aria-label={copy.activity}>
<article className="cx-usage-total">
<div className="cx-usage-total-heading"><span><Activity size={16} aria-hidden="true" />{copy.total}</span><span className="cx-usage-period-label">{copy.ranges[range]}</span></div>
<div className="cx-usage-total-value"><TokenValue value={data.totals.totalTokens} lang={lang} /><span>{copy.tokens}</span></div>
<div className="cx-usage-total-caption"><span>{exact(data.totals.totalTokens, lang)} {copy.tokens}</span><span>{copy.totalHint}</span></div>
<div className="cx-usage-composition" aria-hidden="true"><span className="cx-usage-swatch--input" style={{ width: `${ratio(data.totals.inputTokens - data.totals.cachedInputTokens, data.totals.totalTokens)}%` }} /><span className="cx-usage-swatch--cache" style={{ width: `${ratio(data.totals.cachedInputTokens, data.totals.totalTokens)}%` }} /><span className="cx-usage-swatch--output" style={{ width: `${ratio(data.totals.outputTokens, data.totals.totalTokens)}%` }} /></div>
<div className="cx-usage-total-footer"><CalendarDays size={12} aria-hidden="true" /><span>{data.rangeStart ? `${dateLabel(data.rangeStart, lang, true)} – ${dateLabel(data.rangeEnd, lang, true)}` : copy.ranges.all}</span></div>
</article>
<div className="cx-usage-metrics">
<Metric icon={ArrowDownLeft} label={copy.input} hint={copy.inputHint} accent="input"><TokenValue value={data.totals.inputTokens} lang={lang} /></Metric>
<Metric icon={ArrowUpRight} label={copy.output} hint={<>{copy.reasoning} <TokenValue value={data.totals.reasoningTokens} lang={lang} /></>} accent="output"><TokenValue value={data.totals.outputTokens} lang={lang} /></Metric>
<Metric icon={Database} label={copy.cacheRate} hint={<><TokenValue value={data.totals.cachedInputTokens} lang={lang} /> {copy.cached}</>} accent="cache"><span className="cx-usage-number">{percentage(cacheRate)}</span></Metric>
<Metric icon={MessageSquare} label={copy.sessions} hint={copy.sessionsHint} accent="sessions"><TokenValue value={data.totals.sessionCount} lang={lang} /></Metric>
</div>
</section>
<div className="cx-usage-charts"><UsageTrend days={data.days} lang={lang} copy={copy} /><ModelDistribution models={data.models} lang={lang} copy={copy} onModel={setModel} /></div>
<section className="cx-usage-card cx-usage-sessions"><div className="cx-usage-card-heading"><div><h3>{copy.recent}<span className="cx-usage-count">{data.sessions.length}</span></h3><p>{copy.recentHint}</p></div><MessageSquare size={18} aria-hidden="true" /></div>
<div className="cx-usage-table-scroll"><table className="cx-usage-table"><caption className="cx-usage-sr-only">{copy.recent}</caption><thead><tr><th>{copy.session}</th><th>{copy.model}</th><th>{copy.input}</th><th>{copy.output}</th><th>{copy.totalColumn}</th><th>{copy.lastActive}</th></tr></thead><tbody>{data.sessions.map((session) => <tr key={`${session.id}-${session.model}`}><th scope="row"><span className="cx-usage-session-name" title={sessionTitleLabel(session.title, lang)}>{sessionTitleLabel(session.title, lang)}</span></th><td><span className="cx-usage-session-model" title={modelLabel(session.model, lang)}>{modelLabel(session.model, lang)}</span></td><td><TokenValue value={session.inputTokens} lang={lang} /></td><td><TokenValue value={session.outputTokens} lang={lang} /></td><td className="cx-usage-cell-total"><TokenValue value={session.totalTokens} lang={lang} /></td><td className="cx-usage-cell-date">{timestampLabel(session.lastActiveAt, lang)}</td></tr>)}</tbody></table></div>
</section>
</>}
{data && <footer className="cx-usage-footer"><div><Check size={12} aria-hidden="true" /><span>{copy.updated} {timestampLabel(data.refreshedAt, lang)} · {data.timezone} · {copy.scanned} {exact(data.coverage.scannedFiles, lang)} {copy.files}</span></div><p>{copy.notes}</p></footer>}
</div>;
}
+167 -50
View File
@@ -1,14 +1,21 @@
import { createContext, useContext, useId, useRef, useState } from "react";
import type { ReactNode } from "react";
import {
CheckCircle2,
BarChart3,
Download,
ExternalLink,
Globe2,
Loader2,
Power,
RefreshCw,
Sparkles,
SlidersHorizontal,
} from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { PageTransition } from "../components/PageTransition";
import { Button, ModalShell } from "../components/ui";
import { UsageStatisticsPage } from "./UsageStatisticsPage";
import "../styles/utility-pages.css";
export type UtilityLanguage = "zh" | "en";
@@ -84,16 +91,39 @@ export type SettingsCopy = {
recheckTitle: ReactNode;
recheckDescription: ReactNode;
recheckLabel: ReactNode;
restartTitle: ReactNode;
restartDescription: ReactNode;
restartLabel: ReactNode;
restartTargetLabel: ReactNode;
restartConfirmTitle: ReactNode;
restartConfirmDescription: ReactNode;
restartCancelLabel: string;
restartConfirmLabel: ReactNode;
restartingLabel: ReactNode;
};
export type SettingsPageProps = {
lang: UtilityLanguage;
configDir: string;
active?: boolean;
copy: SettingsCopy;
onLanguageChange: (lang: UtilityLanguage) => void;
onRecheck: () => void;
onRestartCodex: () => Promise<boolean>;
recheckBusy?: boolean;
restartBusy?: boolean;
};
// PageTransition keeps the previous content during its exit animation. Context
// still propagates the current activity state so requests stop immediately,
// while the retained usage component keeps its filters between tabs.
const SettingsUsageActiveContext = createContext(false);
function SettingsUsagePanel({ lang, configDir }: Pick<SettingsPageProps, "lang" | "configDir">) {
const active = useContext(SettingsUsageActiveContext);
return <UsageStatisticsPage lang={lang} configDir={configDir} active={active} />;
}
type SettingRowProps = {
icon: LucideIcon;
title: ReactNode;
@@ -118,65 +148,152 @@ function SettingRow({ icon: Icon, title, description, action }: SettingRowProps)
export function SettingsPage({
lang,
configDir,
active = true,
copy,
onLanguageChange,
onRecheck,
onRestartCodex,
recheckBusy = false,
restartBusy = false,
}: SettingsPageProps) {
const [tab, setTab] = useState<"general" | "usage">("general");
const [usageOpened, setUsageOpened] = useState(false);
const tabId = useId();
const tabRefs = useRef<(HTMLButtonElement | null)[]>([]);
const [restartConfirmOpen, setRestartConfirmOpen] = useState(false);
const closeRestartConfirm = () => {
if (!restartBusy) setRestartConfirmOpen(false);
};
const confirmRestart = async () => {
if (restartBusy) return;
if (await onRestartCodex()) setRestartConfirmOpen(false);
};
return (
<section className="cx-utility cx-page cx-page--settings">
<PageHeader eyebrow={copy.eyebrow} title={copy.title} />
<div className="cx-page-settings-list">
<SettingRow
icon={Globe2}
title={copy.languageTitle}
description={copy.languageDescription}
action={(
<div className="cx-page-segmented" role="group" aria-label={String(copy.languageTitle)}>
<button
type="button"
className={lang === "zh" ? "cx-page-segmented-button cx-page-segmented-button--active" : "cx-page-segmented-button"}
onClick={() => onLanguageChange("zh")}
aria-pressed={lang === "zh"}
>
{copy.chineseLabel}
</button>
<button
type="button"
className={lang === "en" ? "cx-page-segmented-button cx-page-segmented-button--active" : "cx-page-segmented-button"}
onClick={() => onLanguageChange("en")}
aria-pressed={lang === "en"}
>
{copy.englishLabel}
</button>
</div>
)}
/>
<SettingRow
icon={Sparkles}
title={copy.productTitle}
description={copy.productDescription}
action={<span className="cx-page-value-pill">{copy.productValue}</span>}
/>
<SettingRow
icon={CheckCircle2}
title={copy.recheckTitle}
description={copy.recheckDescription}
action={(
<button
type="button"
className="cx-page-button cx-page-button--secondary"
onClick={onRecheck}
disabled={recheckBusy}
>
{recheckBusy && <Loader2 size={15} className="cx-page-spin" aria-hidden="true" />}
{copy.recheckLabel}
</button>
)}
/>
<div className="cx-settings-tabs" role="tablist" aria-label={lang === "zh" ? "设置页面" : "Settings pages"}>
{(["general", "usage"] as const).map((value, index) => {
const Icon = value === "general" ? SlidersHorizontal : BarChart3;
const select = () => { setTab(value); if (value === "usage") setUsageOpened(true); };
return <button key={value} ref={(element) => { tabRefs.current[index] = element; }} type="button" role="tab" id={`${tabId}-${value}-tab`} aria-controls={`${tabId}-${value}-panel`} aria-selected={tab === value} tabIndex={tab === value ? 0 : -1} className="cx-settings-tab" onClick={select} onKeyDown={(event) => {
if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) return;
event.preventDefault();
const next = event.key === "Home" ? 0 : event.key === "End" ? 1 : 1 - index;
tabRefs.current[next]?.click();
tabRefs.current[next]?.focus();
}}><Icon size={14} aria-hidden="true" />{value === "general" ? (lang === "zh" ? "通用设置" : "General") : (lang === "zh" ? "用量统计" : "Usage statistics")}</button>;
})}
</div>
<SettingsUsageActiveContext.Provider value={active && tab === "usage"}>
<PageTransition pageKey={`settings:${tab}`}>
<div className="cx-settings-panel cx-page-settings-list" role="tabpanel" id={`${tabId}-general-panel`} aria-labelledby={`${tabId}-general-tab`} hidden={tab !== "general"}>
<SettingRow
icon={Globe2}
title={copy.languageTitle}
description={copy.languageDescription}
action={(
<div className="cx-page-segmented" role="group" aria-label={String(copy.languageTitle)}>
<button
type="button"
className={lang === "zh" ? "cx-page-segmented-button cx-page-segmented-button--active" : "cx-page-segmented-button"}
onClick={() => onLanguageChange("zh")}
aria-pressed={lang === "zh"}
>
{copy.chineseLabel}
</button>
<button
type="button"
className={lang === "en" ? "cx-page-segmented-button cx-page-segmented-button--active" : "cx-page-segmented-button"}
onClick={() => onLanguageChange("en")}
aria-pressed={lang === "en"}
>
{copy.englishLabel}
</button>
</div>
)}
/>
<SettingRow
icon={Sparkles}
title={copy.productTitle}
description={copy.productDescription}
action={<span className="cx-page-value-pill">{copy.productValue}</span>}
/>
<SettingRow
icon={CheckCircle2}
title={copy.recheckTitle}
description={copy.recheckDescription}
action={(
<button
type="button"
className="cx-page-button cx-page-button--secondary"
onClick={onRecheck}
disabled={recheckBusy}
>
{recheckBusy && <Loader2 size={15} className="cx-page-spin" aria-hidden="true" />}
{copy.recheckLabel}
</button>
)}
/>
<SettingRow
icon={Power}
title={copy.restartTitle}
description={copy.restartDescription}
action={(
<button
type="button"
className="cx-page-button cx-page-button--secondary"
onClick={() => setRestartConfirmOpen(true)}
disabled={restartBusy}
>
{restartBusy ? <Loader2 size={15} className="cx-page-spin" aria-hidden="true" /> : <Power size={15} aria-hidden="true" />}
{restartBusy ? copy.restartingLabel : copy.restartLabel}
</button>
)}
/>
</div>
<div className="cx-settings-panel" role="tabpanel" id={`${tabId}-usage-panel`} aria-labelledby={`${tabId}-usage-tab`} hidden={tab !== "usage"}>
{usageOpened && <SettingsUsagePanel lang={lang} configDir={configDir} />}
</div>
</PageTransition>
</SettingsUsageActiveContext.Provider>
<ModalShell
open={restartConfirmOpen}
onClose={closeRestartConfirm}
title={copy.restartConfirmTitle}
description={copy.restartConfirmDescription}
size="sm"
closeLabel={copy.restartCancelLabel}
closeOnBackdrop={!restartBusy}
closeOnEscape={!restartBusy}
showCloseButton={!restartBusy}
footer={(
<>
<Button variant="secondary" onClick={closeRestartConfirm} disabled={restartBusy} data-initial-focus>
{copy.restartCancelLabel}
</Button>
<Button
variant="danger"
icon={restartBusy ? <Loader2 size={16} className="cx-page-spin" /> : <Power size={16} />}
onClick={() => void confirmRestart()}
disabled={restartBusy}
>
{restartBusy ? copy.restartingLabel : copy.restartConfirmLabel}
</Button>
</>
)}
>
<div className="cx-page-restart-target">
<Power size={18} aria-hidden="true" />
<strong>{copy.restartTargetLabel}</strong>
</div>
</ModalShell>
</section>
);
}
+36
View File
@@ -0,0 +1,36 @@
export type ProviderProfile = {
baseUrl?: string | null;
providerName?: string | null;
model?: string | null;
apiKey?: string | null;
};
function normalizeProviderBaseUrl(value?: string | null) {
const raw = (value || "").trim();
if (!raw) return "";
try {
const parsed = new URL(raw);
const credentials = parsed.username
? `${parsed.username}${parsed.password ? `:${parsed.password}` : ""}@`
: "";
const path = parsed.pathname.replace(/\/+$/, "");
return `${parsed.protocol.toLowerCase()}//${credentials}${parsed.host.toLowerCase()}${path}${parsed.search}`;
} catch {
return raw.replace(/\/+$/, "");
}
}
function normalizeProviderName(value?: string | null) {
return (value || "").trim().replace(/\s+/gu, " ").toLowerCase();
}
export function providerProfilesMatch(left: ProviderProfile, right: ProviderProfile) {
const leftUrl = normalizeProviderBaseUrl(left.baseUrl);
if (!leftUrl || leftUrl !== normalizeProviderBaseUrl(right.baseUrl)) return false;
if ((left.model || "").trim() !== (right.model || "").trim()) return false;
const leftKey = (left.apiKey || "").trim();
const rightKey = (right.apiKey || "").trim();
if (leftKey && rightKey) return leftKey === rightKey;
return normalizeProviderName(left.providerName) === normalizeProviderName(right.providerName);
}
+18
View File
@@ -0,0 +1,18 @@
type OrderedProviderRow = {
isCurrent: boolean;
};
export function orderProviderRows<
Official extends OrderedProviderRow,
Detected extends OrderedProviderRow,
Local extends OrderedProviderRow,
>(
official: Official,
detected: readonly Detected[],
local: readonly Local[],
): Array<Official | Detected | Local> {
const rows: Array<Official | Detected | Local> = [official];
if (!local.some((row) => row.isCurrent)) rows.push(...detected);
rows.push(...local);
return rows;
}
-22
View File
@@ -355,28 +355,6 @@
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); }
}
+20
View File
@@ -426,6 +426,26 @@
transform-origin: center top;
}
.cx-state-loading {
min-width: 0;
min-height: 280px;
flex: 1 1 auto;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
color: var(--ui-text-muted);
font-size: 13px;
font-weight: 620;
line-height: 1.45;
text-align: center;
}
.cx-state-loading svg {
flex: 0 0 auto;
color: var(--ui-accent);
}
.cx-page-transition--exit,
.cx-page-transition--enter {
will-change: opacity, transform;
+18 -61
View File
@@ -345,67 +345,6 @@
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);
@@ -499,6 +438,10 @@
border-color: transparent;
}
:root[data-theme="dark"] .cx-providers-row-meta {
color: #aeb3bc;
}
:root[data-theme="dark"] .cx-providers-info-grid code {
color: var(--cx-dark-muted);
background: transparent;
@@ -831,6 +774,20 @@
box-shadow: 0 7px 18px rgba(0, 0, 0, 0.18);
}
:root[data-theme="dark"] .cx-skills-note-field > span {
color: #d8dbe0;
}
:root[data-theme="dark"] .cx-skills-note-field textarea {
color: #e5e7eb;
background: var(--cx-dark-surface-recessed);
border-color: var(--cx-dark-border);
}
:root[data-theme="dark"] .cx-skills-note-meta {
color: var(--cx-dark-subtle);
}
:root[data-theme="dark"] .cx-prompts-row:hover {
background: rgba(255, 255, 255, 0.04);
border-color: rgba(139, 92, 246, 0.4);
@@ -632,6 +632,52 @@
line-height: 1.4;
}
.cx-providers-context-heading,
.cx-providers-context-actions {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 10px 18px;
}
.cx-providers-context-heading {
margin-bottom: 8px;
}
.cx-providers-context-heading > span {
color: var(--cx-provider-muted);
font-size: 11px;
font-weight: 660;
}
.cx-providers-context-actions {
justify-content: flex-end;
}
.cx-providers-context-control {
display: grid;
justify-items: start;
gap: 4px;
}
.cx-providers-context-checkbox {
min-height: 20px;
color: var(--cx-provider-text);
}
.cx-providers-context-hint,
.cx-providers-context-error {
max-width: 310px;
color: var(--cx-provider-muted);
font-size: 10px;
line-height: 1.4;
}
.cx-providers-context-error {
color: #c85042;
}
.cx-providers-preview {
min-width: 0;
max-height: 240px;
@@ -714,6 +760,10 @@
min-height: 360px;
}
.cx-providers-editor-field + .cx-providers-editor-field {
margin-top: 14px;
}
.cx-providers-form-actions {
padding: 2px 0 4px;
}
@@ -328,10 +328,13 @@
min-width: 0;
display: flex;
align-items: center;
flex-wrap: nowrap;
gap: 9px;
}
.cx-skills-row-copy > strong {
min-width: 0;
flex: 0 1 auto;
display: block;
overflow: hidden;
color: #2a2e34;
@@ -343,14 +346,38 @@
white-space: nowrap;
}
.cx-skills-row-copy--noted > strong {
max-width: min(40%, 360px);
flex: 0 1 auto;
}
.cx-skills-row-note {
min-width: 0;
flex: 1 1 180px;
overflow: hidden;
color: var(--cx-tool-muted);
font-size: 13px;
line-height: 1.35;
text-overflow: ellipsis;
white-space: nowrap;
}
.cx-skills-row-copy .ui-status-badge {
flex: 0 0 auto;
max-width: min(30%, 220px);
overflow: hidden;
min-height: 20px;
padding: 0 6px;
border-radius: 5px;
font-size: 11px;
}
.cx-skills-row-copy .ui-status-badge > span:last-child {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.cx-skills-row-control,
.cx-prompts-row-actions {
min-width: 58px;
@@ -360,6 +387,67 @@
gap: 9px;
}
.cx-skills-note-button {
flex: 0 0 auto;
}
.cx-skills-note-modal .ui-modal__body {
display: grid;
gap: 8px;
}
.cx-skills-note-field {
display: grid;
gap: 8px;
}
.cx-skills-note-field > span {
color: #3e434b;
font-size: 12px;
font-weight: 680;
}
.cx-skills-note-field textarea {
width: 100%;
min-height: 118px;
resize: vertical;
padding: 11px 12px;
border: 1px solid #d8dce2;
border-radius: 7px;
color: #292d33;
background: #ffffff;
font: inherit;
font-size: 13px;
line-height: 1.55;
overflow-wrap: anywhere;
}
.cx-skills-note-field textarea:focus {
border-color: #8db2f5;
outline: 2px solid rgba(37, 99, 235, 0.16);
outline-offset: 1px;
}
.cx-skills-note-field textarea:disabled {
cursor: not-allowed;
opacity: 0.6;
}
.cx-skills-note-meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
color: #858b94;
font-size: 11px;
line-height: 1.4;
}
.cx-skills-note-meta span:last-child {
flex: 0 0 auto;
font-variant-numeric: tabular-nums;
}
.cx-skills-empty {
min-height: 150px;
display: flex;
@@ -585,10 +673,6 @@
border-color: #5944d5;
}
.cx-prompts-heading > p {
color: var(--cx-prompt-accent);
}
.cx-prompts-mode-panel {
position: relative;
min-width: 0;
@@ -1288,6 +1372,10 @@
letter-spacing: 0;
}
.cx-prompts-form-panel-head .ui-status-badge {
margin-left: auto;
}
.cx-prompts-form-grid {
min-width: 0;
display: grid;
@@ -1362,6 +1450,12 @@
opacity: 0.6;
}
.cx-prompts-field input:read-only:not(:disabled) {
cursor: default;
color: #646971;
background: #f1f3f5;
}
.cx-prompts-field--content {
grid-column: 1 / -1;
}
@@ -1434,12 +1528,37 @@
}
.cx-skills-row {
grid-template-columns: 34px minmax(0, 1fr);
grid-template-columns: minmax(0, 1fr) auto;
align-items: start;
}
.cx-skills-row-control {
grid-column: 2;
justify-content: flex-start;
grid-row: 1;
justify-content: flex-end;
}
.cx-skills-row-copy {
grid-column: 1;
grid-row: 1;
}
.cx-skills-row-copy--noted {
flex-wrap: wrap;
}
.cx-skills-row-copy--noted > strong {
max-width: 100%;
flex: 1 1 auto;
}
.cx-skills-row-note {
flex-basis: 100%;
display: -webkit-box;
overflow: hidden;
white-space: normal;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.cx-prompts-list {
-648
View File
@@ -1,648 +0,0 @@
.cx-skins-page,
.cx-skins-page * {
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;
--cx-skins-line: #e1e4e8;
--cx-skins-surface: #ffffff;
--cx-skins-surface-muted: #f7f8fa;
--cx-skins-blue: #2563eb;
--cx-skins-blue-soft: #edf3ff;
min-width: 0;
min-height: 100%;
display: flex;
flex-direction: column;
gap: 16px;
color: var(--cx-skins-text);
}
.cx-skins-page .ui-button {
min-height: 40px;
padding-right: 15px;
padding-left: 15px;
border-radius: 8px;
box-shadow: 0 2px 6px rgba(32, 34, 38, 0.06);
font-size: 13px;
letter-spacing: 0;
}
.cx-skins-page .ui-button--primary {
background: var(--cx-skins-blue);
border-color: var(--cx-skins-blue);
box-shadow: 0 5px 12px rgba(37, 99, 235, 0.18);
}
.cx-skins-header {
min-width: 0;
flex: 0 0 auto;
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 24px;
padding: 4px 0 20px;
border-bottom: 1px solid var(--cx-skins-line);
}
.cx-skins-heading {
min-width: 260px;
}
.cx-skins-heading > p {
display: flex;
align-items: center;
gap: 7px;
margin: 0 0 7px;
color: var(--cx-skins-blue);
font-size: 11px;
font-weight: 720;
letter-spacing: 0;
line-height: 1.2;
}
.cx-skins-heading h2 {
margin: 0;
color: #181a1f;
font-size: 24px;
font-weight: 740;
letter-spacing: 0;
line-height: 1.2;
}
.cx-skins-heading > span {
display: block;
max-width: 760px;
margin-top: 7px;
color: var(--cx-skins-muted);
font-size: 12px;
line-height: 1.55;
}
.cx-skins-actions {
min-width: 0;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
flex-wrap: wrap;
}
.cx-skins-file-input {
position: fixed;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
clip-path: inset(50%);
white-space: nowrap;
}
.cx-skins-spin {
animation: cx-skins-spin 0.8s linear infinite;
}
.cx-skins-status {
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;
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 {
color: var(--cx-skins-muted);
font-size: 11px;
font-weight: 650;
}
.cx-skins-status strong {
min-width: 0;
overflow: hidden;
color: #252932;
font-size: 12px;
font-weight: 700;
text-overflow: ellipsis;
white-space: nowrap;
}
.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: #2454ad;
background: #f3f7ff;
font-size: 12px;
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(230px, 1fr));
align-items: start;
justify-content: start;
gap: 14px;
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: 8px;
background: #ffffff;
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 {
border-color: color-mix(in srgb, var(--skin-accent) 44%, #d8dee8);
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 {
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: 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 {
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;
min-height: 0;
display: grid;
grid-template-rows: 24px minmax(0, 1fr) 34px;
color: var(--skin-text);
background: color-mix(in srgb, var(--skin-bg) 68%, transparent);
}
.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-chat {
min-height: 0;
display: flex;
flex-direction: column;
gap: 6px;
padding: 7px 7px 5px;
}
.cx-skins-preview-message {
display: flex;
align-items: flex-start;
gap: 5px;
}
.cx-skins-preview-message i {
width: 9px;
height: 9px;
flex: 0 0 auto;
border-radius: 3px;
background: var(--skin-secondary);
}
.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: 6px;
padding: 10px 11px 11px;
}
.cx-skins-card-title {
min-width: 0;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
}
.cx-skins-card-title > div {
min-width: 0;
display: grid;
gap: 3px;
}
.cx-skins-card-title strong {
overflow: hidden;
color: #1f232a;
font-size: 14px;
font-weight: 740;
text-overflow: ellipsis;
white-space: nowrap;
}
.cx-skins-card-title span {
overflow: hidden;
color: var(--cx-skins-muted);
font-size: 12px;
line-height: 1.4;
text-overflow: ellipsis;
white-space: nowrap;
}
.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-skin-editor-title {
display: inline-flex;
align-items: center;
gap: 8px;
}
.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 {
min-height: 220px;
display: grid;
place-items: center;
gap: 10px;
border: 1px dashed #d7dce3;
border-radius: 14px;
color: var(--cx-skins-muted);
background: #fbfcfd;
font-size: 13px;
}
@keyframes cx-skins-spin {
to {
transform: rotate(360deg);
}
}
@media (max-width: 880px) {
.cx-skins-header {
align-items: stretch;
flex-direction: column;
}
.cx-skins-actions {
justify-content: flex-start;
}
.cx-skins-status {
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%;
}
}

Some files were not shown because too many files have changed in this diff Show More