Compare commits

..
Author SHA1 Message Date
yyyyyynnn 936f2f49e4 release: prepare v0.3.6 2026-07-31 12:05:34 +08:00
yyyyyynnn 9687a4b11c release: prepare v0.3.5 2026-07-28 19:50:41 +08:00
yyyyyynnn 32bab7731e release: prepare v0.3.4 2026-07-28 18:14:20 +08:00
yyyyyynnn f64848ca9c release: prepare v0.3.3 2026-07-26 11:54:32 +08:00
yyyyyynnn 94ab211431 release: prepare v0.3.2 2026-07-26 00:00:39 +08:00
yyyyyynnn e8b0e5b73c ci: stabilize updater release URLs 2026-07-24 16:06:57 +08:00
yyyyyynnn 5b6655754d ci: retry draft release lookup 2026-07-24 15:45:28 +08:00
yyyyyynnn 1fd5b1e608 feat: ship cross-platform skin center 2026-07-24 15:42:55 +08:00
yyyyyynnn 7d0e0064d5 Revert "feat: apply codex skins at runtime"
This reverts commit d38548a1f4.
2026-07-16 21:47:58 +08:00
yyyyyynnn d38548a1f4 feat: apply codex skins at runtime 2026-07-16 21:38:06 +08:00
yyyyyynnn f25e43d281 feat: add skin center theme library 2026-07-16 21:21:01 +08:00
yyyyyynnn d404905812 ci: clarify macOS release asset names 2026-07-16 18:34:09 +08:00
yyyyyynnn c200e6b86e ci: hide updater signature assets 2026-07-16 18:27:52 +08:00
96 changed files with 20536 additions and 1508 deletions
+84 -9
View File
@@ -131,9 +131,14 @@ jobs:
--draft
fi
release_id=$(gh api "/repos/${GITHUB_REPOSITORY}/releases?per_page=100" \
--jq ".[] | select(.tag_name == \"${RELEASE_TAG}\") | .id" \
| head -n 1)
release_id=""
for attempt in {1..10}; do
release_id=$(gh release view "$RELEASE_TAG" \
--json databaseId \
--jq '.databaseId' 2>/dev/null || true)
[ -z "$release_id" ] || break
sleep 2
done
if [ -z "$release_id" ]; then
echo "Unable to locate draft release for ${RELEASE_TAG}" >&2
exit 1
@@ -158,12 +163,12 @@ jobs:
platform: macos-latest
macos: true
args: --bundles app,dmg
asset_pattern: Codex-X-[version]-macos-arm64[ext]
asset_pattern: Codex-X-[version]-macos-apple-silicon[ext]
- name: macOS Intel
platform: macos-15-intel
macos: true
args: --bundles app,dmg
asset_pattern: Codex-X-[version]-macos-x64[ext]
asset_pattern: Codex-X-[version]-macos-intel[ext]
- name: Linux x64
platform: ubuntu-22.04
macos: false
@@ -216,6 +221,31 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Stage pinned Windows skin runtime
if: matrix.platform == 'windows-latest'
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$version = '22.23.1'
$expectedSha256 = '7df0bc9375723f4a86b3aa1b7cc73342423d9677a8df4538aca31a049e309c29'
$archive = Join-Path $env:RUNNER_TEMP "node-v$version-win-x64.zip"
$extractRoot = Join-Path $env:RUNNER_TEMP "codex-x-node-$version"
$targetRoot = 'apps/desktop/src-tauri/runtime/windows/node'
Invoke-WebRequest -Uri "https://nodejs.org/dist/v$version/node-v$version-win-x64.zip" -OutFile $archive
$actualSha256 = (Get-FileHash -LiteralPath $archive -Algorithm SHA256).Hash.ToLowerInvariant()
if ($actualSha256 -cne $expectedSha256) {
throw "Node.js archive checksum mismatch: $actualSha256"
}
Remove-Item -LiteralPath $extractRoot -Recurse -Force -ErrorAction SilentlyContinue
Expand-Archive -LiteralPath $archive -DestinationPath $extractRoot
$sourceRoot = Join-Path $extractRoot "node-v$version-win-x64"
New-Item -ItemType Directory -Path $targetRoot -Force | Out-Null
Copy-Item -LiteralPath (Join-Path $sourceRoot 'node.exe') -Destination $targetRoot -Force
Copy-Item -LiteralPath (Join-Path $sourceRoot 'LICENSE') -Destination $targetRoot -Force
if (-not (Test-Path -LiteralPath (Join-Path $targetRoot 'node.exe') -PathType Leaf)) {
throw 'Pinned Windows Node.js runtime was not staged.'
}
- name: Test Windows filesystem compatibility
if: matrix.platform == 'windows-latest'
run: cargo test --manifest-path apps/desktop/src-tauri/Cargo.toml --lib --locked
@@ -350,6 +380,9 @@ jobs:
rm -rf "$portable_root"
mkdir -p "$portable_dir"
cp apps/desktop/src-tauri/target/release/codex-x.exe "$portable_dir/Codex-X.exe"
mkdir -p "$portable_dir/skin-runtime/node"
cp apps/desktop/src-tauri/runtime/windows/node/node.exe "$portable_dir/skin-runtime/node/node.exe"
cp apps/desktop/src-tauri/runtime/windows/node/LICENSE "$portable_dir/skin-runtime/node/LICENSE"
touch "$portable_dir/Codex-X.portable"
cat > "$portable_dir/README-portable.txt" <<'EOF'
Codex-X Windows Portable
@@ -357,6 +390,7 @@ jobs:
Double click Codex-X.exe to run. No MSI installation is required.
The portable edition uses manual downloads instead of in-app installation.
Keep Codex-X.portable next to Codex-X.exe so the app can identify this edition.
Keep the skin-runtime directory beside Codex-X.exe; it contains the verified Windows skin runtime.
Microsoft Edge WebView2 Runtime is required; Windows 10/11 usually already includes it.
EOF
powershell -NoProfile -Command "Compress-Archive -Path '${portable_dir}/*' -DestinationPath '${archive}' -Force"
@@ -382,6 +416,28 @@ jobs:
with:
ref: ${{ needs.prepare.outputs.tag }}
- name: Match release asset labels to filenames
env:
GH_TOKEN: ${{ github.token }}
RELEASE_ID: ${{ needs.prepare.outputs.release_id }}
run: |
set -euo pipefail
assets_endpoint="/repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}/assets?per_page=100"
gh api "$assets_endpoint" \
--jq '.[] | select(.label != .name) | [.id, .name] | @tsv' \
| while IFS=$'\t' read -r asset_id asset_name; do
gh api --method PATCH \
"/repos/${GITHUB_REPOSITORY}/releases/assets/${asset_id}" \
-f "label=${asset_name}" >/dev/null
done
mismatched_labels=$(gh api "$assets_endpoint" \
--jq '[.[] | select(.label != .name)] | length')
if [ "$mismatched_labels" -ne 0 ]; then
echo "Release asset labels do not match their filenames" >&2
exit 1
fi
- name: Download and validate updater manifest
env:
GH_TOKEN: ${{ github.token }}
@@ -404,9 +460,24 @@ jobs:
--manifest latest.json \
--assets release-assets.json \
--version "$VERSION" \
--rewrite-download-urls
--repository "$GITHUB_REPOSITORY" \
--release-tag "$RELEASE_TAG" \
--rewrite-download-urls \
--require-signature-assets
gh release upload "$RELEASE_TAG" latest.json --clobber
- name: Remove public signature assets
env:
GH_TOKEN: ${{ github.token }}
RELEASE_ID: ${{ needs.prepare.outputs.release_id }}
run: |
set -euo pipefail
gh api "/repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}/assets?per_page=100" \
--jq '.[] | select(.name | endswith(".sig")) | .id' \
| while read -r asset_id; do
[ -z "$asset_id" ] || gh api --method DELETE "/repos/${GITHUB_REPOSITORY}/releases/assets/${asset_id}"
done
- name: Publish the verified release
env:
GH_TOKEN: ${{ github.token }}
@@ -429,7 +500,9 @@ jobs:
python3 scripts/validate_updater_release.py \
--manifest published-latest.json \
--assets release-assets.json \
--version "$VERSION"
--version "$VERSION" \
--repository "$GITHUB_REPOSITORY" \
--release-tag "$RELEASE_TAG"
python3 - <<'PY' > updater-urls.txt
import json
@@ -438,9 +511,11 @@ jobs:
print(entry["url"])
PY
sort -u updater-urls.txt | while read -r url; do
curl -fsSLI \
curl -fsSL \
--range 0-0 \
--retry 5 \
--retry-delay 3 \
--retry-all-errors \
"$url" >/dev/null
"$url" \
-o /dev/null
done
+92
View File
@@ -2,6 +2,98 @@
All notable changes to Codex-X will be documented here.
## [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
### 更新
- 在线提示词库新增 3 套软件开发模板和 3 套写作辅助模板,并按照模板文件名前缀自动归入对应分类;用户手动分类仍然优先。
- 软件开发分类新增“长期维护工程师”“系统化调试与根因修复”“严格代码审查”,其中“长期维护工程师”来自项目当前实际使用的维护规范。
- 写作辅助分类新增“清晰表达与润色”“技术文档写作”“结构化长文起草”。6 套新增模板通过 GitHub 在线同步,不增加安装包体积。
### 修复 Bug
- 彻底隔离 OpenAI Official 与中转供应商认证:切换到中转前保存可信的官方配置快照,切回官方时恢复该快照,避免 `auth.json` 被中转或 cc-switch 覆盖后导致官方登录失效。
- “还原官方配置”现在只恢复独立快照,不再自动切换当前供应商;新增“新建官方配置”,可清除受污染的 live `auth.json` 并引导用户重新完成官方登录。
- 优化官方 `auth.json` 编辑器的高度、换行和长 Token 选取体验,避免内容看起来被截断或只能依赖底部横向滚动条。
## [v0.3.3] - 2026-07-26
### 调整
- 暂时下线皮肤中心并隐藏侧边栏入口,皮肤页面、运行时实现和用户本地主题文件继续保留,后续可以通过统一功能开关重新启用。
- 升级后会自动暂停 Codex-X 此前启用的皮肤并恢复官方显示;若自动停用失败,应用会显示明确错误,不会让用户在入口隐藏后失去恢复提示。
- 内置“椎名真白·樱花画室”不再注册为随应用编译的主题,其约 1.7 MB 壁纸不再进入正式安装包;主题源文件仍保留在仓库中。
## [v0.3.2] - 2026-07-25
### 修复 Bug
- 修复 Windows Codex 冷启动或进入新版首页、外观设置页时,皮肤运行时因只识别旧版主壳层和侧栏而错误提示“未找到经过验证的 Codex 渲染器”的问题;首次连接现在会等待页面渐进加载,并兼容渲染文档切换。
- “关闭皮肤”保持为随时可用的异常恢复入口,不再因运行状态刷新延迟、皮肤部分失效或其他操作卡住而无法点击;恢复期间仍会阻止重复提交和其他皮肤写操作。
### 安全
- 新版 Windows 页面检测继续要求官方进程、回环 CDP、`app://` 协议以及 Codex 壳层、首页或外观设置页的明确锚点,不会向普通网页或无标记辅助页面注入皮肤。
## [v0.3.1] - 2026-07-24
### 更新
- 皮肤中心重新设计主题画廊:直接展示真实壁纸和侧边栏、会话区、输入框、按钮等整套界面效果,单个主题不再拉伸占满整行,并移除端点、目录、`theme.json` 路径和主题包结构等开发者信息。
- 皮肤中心新增可管理的分类筛选并进一步压缩主题卡片密度;导出主题时改用系统“另存为”窗口选择目录和文件名。
- 支持直接从图片创建自适应主题,并可编辑主题名称、简介和界面透明度;内置主题与图片主题共用相同的编辑与预览规格。
- 主题画廊根据当前分类数量自适应 1 至 4 列,少量主题保持紧凑,多个主题充分利用可用宽度。
- 实机换肤扩展到 Windows:动态校验官方 `OpenAI.Codex` Store 包,安全启动仅绑定回环地址的 CDP 会话,并在 Windows 发布包中附带固定校验的 Node.js 22 运行时;不修改 `WindowsApps`、`app.asar` 或权限。
- 皮肤中心只保留“关闭皮肤”入口,移除与其重复的“恢复官方外观”按钮;底层完整恢复能力继续保留用于异常恢复。
### 修复 Bug
- 修复 macOS 皮肤已经注入后 Codex-X 仍长期显示“应用中”、重开后无法识别当前主题的问题;单次注入现在会明确退出,外部命令带有硬超时,并可从经过验证的 Codex 主进程恢复遗失的本机调试端口。
- 修复皮肤尚未运行时错误显示“重新应用”、刷新缺少明确反馈,以及提示词或皮肤卡片过多时页面无法继续向下滚动的问题。
- 优化普通横图在新任务首页超宽横幅中的自动焦点,避免人物头部被居中 `cover` 裁掉;主题包仍可通过 `art.focusY` 显式覆盖自动位置。
- 修复切换到中转供应商时覆盖官方 `auth.json` 的问题,官方登录凭据与供应商凭据现在分别保存和恢复。
- 改进 Codex Desktop 检测,兼容更多安装路径、包标识和版本输出格式,减少部分用户错误显示“未检测到 Codex”的情况。
- 修复启用皮肤后新会话输入框消失、输入框出现会话内容穿透,以及图片主题预览错误使用默认背景的问题。
- 修复浅色主题的固定白色遮罩洗淡壁纸颜色的问题;壁纸保持原图色彩,卡片和项目选择区域跟随透明度,同时为输入框保留可读性下限。
### 安全
- 换肤运行时只连接经过签名、进程归属和页面标记验证的官方 Codex,并将 CDP 限制在 `127.0.0.1`;停止注入器前会核对 PID、启动时间、Node 路径、脚本路径和端口。
- 主题导入增加 ZIP 越界、符号链接、重复文件、文件数量、图片大小和 Manifest 字段校验;运行时使用稳定快照,避免主题切换时配置与图片不一致。
### 开发
- 固定复用 Codex Dream Skin Studio `5fd8af532efbaa87d2d0092297fd2d45cd56574e` 的 MIT 注入实现,并加入 Rust 与 Node 专项回归测试;未包含上游声明为非 MIT 授权的真人主题素材。
## [v0.3.0] - 2026-07-16
### 更新
+47 -4
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, and inspect TOML and login credentials without repeatedly editing configuration files by hand.
A cross-platform desktop tool for **OpenAI Codex Desktop / Codex CLI**. Manage prompt templates, switch third-party APIs, organize / repair / permanently delete local sessions, manage Skills / MCP, apply macOS / Windows skins, and inspect TOML and login credentials without repeatedly editing configuration files by hand.
<p>
<img src="https://img.shields.io/github/v/release/yynxxxxx/Codex-X?label=version&color=blue" alt="version" />
@@ -131,6 +131,10 @@ You can use it to:
<td align="center">📝 <b>Manage config and login</b></td>
<td align="left">Inspect the active Codex <code>config.toml</code> and <code>auth.json</code> in one place, with a clear distinction between official login credentials and third-party API Keys. Important writes are backed up automatically and can be restored when needed.</td>
</tr>
<tr>
<td align="center">🎨 <b>Apply macOS / Windows skins</b></td>
<td align="left">Use the bundled Shiina Mashiro art-studio theme with real-wallpaper, full-interface previews. Import or export packs, hot-switch themes, and turn the skin off directly. The runtime is built into Codex-X; the separate Codex Dream Skin engine is not required.</td>
</tr>
<tr>
<td align="center">📦 <b>Use it across platforms</b></td>
<td align="left">Available for macOS Apple Silicon / Intel, Windows MSI / portable, and Linux packages. Installed editions can download, verify, and install updates in the app; the portable edition keeps manual downloads.</td>
@@ -143,7 +147,7 @@ You can use it to:
### 1. Prompt Template Center
<p align="center">
<img src="https://img.shields.io/badge/Template_library-5_total-2563eb?style=flat-square" alt="5 templates in the current library" />
<img src="https://img.shields.io/badge/Template_library-11_total-2563eb?style=flat-square" alt="11 templates in the current library" />
<img src="https://img.shields.io/badge/Bundled_offline-5_templates-16a34a?style=flat-square" alt="5 templates bundled for offline use" />
<img src="https://img.shields.io/badge/GitHub_sync-Automatic_updates-f59e0b?style=flat-square" alt="Automatic GitHub template sync" />
</p>
@@ -151,7 +155,7 @@ You can use it to:
> [!TIP]
> **Ready after installation, automatically expanded when online.**
>
> The app bundle includes all 5 current templates for offline use. After launch, it synchronizes updates and newly added templates from GitHub `examples/` in the background, without waiting for the **Prompt** page to open. Successfully synchronized online versions are cached locally and remain available during temporary network outages.
> The app bundle includes 5 templates for offline use. After launch, it synchronizes 6 additional software-development and writing templates, plus later updates, from GitHub `examples/` in the background without waiting for the **Prompt** page to open. Successfully synchronized online versions are cached locally and remain available during temporary network outages.
<div align="center">
<table>
@@ -185,6 +189,36 @@ You can use it to:
<td align="left">A Chinese technical-operator persona with routing for coding, CTF, reverse engineering, memory, and protocol work</td>
<td align="center">Bundled offline<br />GitHub updates</td>
</tr>
<tr>
<td><a href="examples/software-development-maintainer.md"><code>software-development-maintainer.md</code></a></td>
<td align="left">Long-term maintenance with reuse, minimal changes, safety, testing, and maintainability</td>
<td align="center">GitHub sync</td>
</tr>
<tr>
<td><a href="examples/software-development-debugging.md"><code>software-development-debugging.md</code></a></td>
<td align="left">Evidence-driven debugging from reliable reproduction to root-cause repair and regression checks</td>
<td align="center">GitHub sync</td>
</tr>
<tr>
<td><a href="examples/software-development-code-review.md"><code>software-development-code-review.md</code></a></td>
<td align="left">Severity-ranked review of defects, regressions, security risks, and test gaps</td>
<td align="center">GitHub sync</td>
</tr>
<tr>
<td><a href="examples/writing-clarity-editor.md"><code>writing-clarity-editor.md</code></a></td>
<td align="left">Chinese and English editing that preserves the author's meaning and facts</td>
<td align="center">GitHub sync</td>
</tr>
<tr>
<td><a href="examples/writing-technical-docs.md"><code>writing-technical-docs.md</code></a></td>
<td align="left">Source-grounded README, guide, API, architecture, and release documentation</td>
<td align="center">GitHub sync</td>
</tr>
<tr>
<td><a href="examples/writing-structured-draft.md"><code>writing-structured-draft.md</code></a></td>
<td align="left">Turns scattered material into a structured report, proposal, retrospective, or article draft</td>
<td align="center">GitHub sync</td>
</tr>
</table>
</div>
@@ -369,7 +403,16 @@ Build desktop bundles:
pnpm --dir apps/desktop tauri build
```
## macOS Installation Note
## Desktop Installation Notes
### Skin Center security boundary
- Live skin application supports macOS and Windows. Linux can still manage, import, and export packs but is never reported as applied.
- macOS uses only the signed Node.js bundled with the official Codex app and validates its bundle and process identity. Windows dynamically validates the current user's registered `OpenAI.Codex` Store package and uses the pinned Node.js 22 runtime shipped with Codex-X releases.
- CDP binds only to `127.0.0.1`; Codex-X does not modify the official `.app`, `WindowsApps`, `app.asar`, code signature, directory permissions, or `config.toml`.
- First application usually asks to restart Codex. Turn off skin removes the live interface skin immediately, and any theme can be applied again later.
- Themes adapt cropping from image dimensions and content. Subject-focused artwork can use `art.focusX`, `art.focusY` (`0` to `1`), and `art.safeArea` in `theme.json` for precise framing without generating duplicate images for the same theme.
- Injection is based on the MIT-licensed [Fei-Away/Codex-Dream-Skin](https://github.com/Fei-Away/Codex-Dream-Skin) source. The pinned source and license notice are in `apps/desktop/src-tauri/resources/skin-runtime/NOTICE.md`.
If you see “app is damaged” when opening an unsigned / unnotarized DMG, this is normal macOS Gatekeeper behavior.
+47 -4
View File
@@ -10,7 +10,7 @@
**Codex 可视化提示词注入 · Provider · 会话 · Skills / MCP 管理工具**
一款面向 **OpenAI Codex 桌面端 / Codex CLI** 的跨平台桌面工具。把提示词模板、自定义 Prompt、第三方 API 供应商、会话同步、Skills / MCP 和 TOML 配置都放进可视化界面里,不用反复手改文件。
一款面向 **OpenAI Codex 桌面端 / Codex CLI** 的跨平台桌面工具。把提示词模板、自定义 Prompt、第三方 API 供应商、会话同步、Skills / MCP、macOS / Windows 皮肤和 TOML 配置都放进可视化界面里,不用反复手改文件。
<p>
<img src="https://img.shields.io/github/v/release/yynxxxxx/Codex-X?label=version&color=blue" alt="version" />
@@ -111,6 +111,10 @@
<td align="center"><b>配置与登录</b></td>
<td align="left">集中查看 Codex 当前使用的 <code>config.toml</code> 与 <code>auth.json</code>,区分官方登录态和第三方 API Key;重要写入前自动备份。</td>
</tr>
<tr>
<td align="center"><b>macOS / Windows 皮肤中心</b></td>
<td align="left">内置“椎名真白·樱花画室”主题,以真实壁纸和完整界面缩略图展示主题效果,支持导入 / 导出、热切换和关闭皮肤;换肤运行时已经内置在 Codex-X 中,无需另装 Codex Dream Skin 引擎。</td>
</tr>
<tr>
<td align="center"><b>跨平台使用</b></td>
<td align="left">提供 macOS Apple Silicon / Intel、Windows MSI / 便携版和 Linux 安装包;安装版可在应用内直接下载、校验并安装更新,便携版继续使用手动下载。</td>
@@ -123,7 +127,7 @@
### 1. 可视化提示词注入中心
<p align="center">
<img src="https://img.shields.io/badge/当前模板库-5_套-2563eb?style=flat-square" alt="当前模板库 5 套" />
<img src="https://img.shields.io/badge/当前模板库-11_套-2563eb?style=flat-square" alt="当前模板库 11 套" />
<img src="https://img.shields.io/badge/离线内置-5_套-16a34a?style=flat-square" alt="离线内置 5 套" />
<img src="https://img.shields.io/badge/GitHub_同步-自动更新-f59e0b?style=flat-square" alt="GitHub 自动同步" />
<img src="https://img.shields.io/badge/自定义提示词-支持导入_编辑-7c3aed?style=flat-square" alt="支持自定义提示词" />
@@ -132,7 +136,7 @@
> [!TIP]
> **安装后就能用,联网后自动补齐,也能维护自己的提示词库。**
>
> 安装包离线自带当前全部 5 套模板;软件启动后可同步 GitHub `examples/` 的更新和新增模板。同步成功的在线版本会缓存到本地,临时离线仍可继续使用。你也可以导入自己的 `.md`、新增分类、编辑说明,并像切换插件一样启用或禁用任意提示词。
> 安装包离线自带 5 套模板;软件启动后会从 GitHub `examples/` 同步另外 6 套软件开发与写作辅助模板,以及后续更新。同步成功的在线版本会缓存到本地,临时离线仍可继续使用。你也可以导入自己的 `.md`、新增分类、编辑说明,并像切换插件一样启用或禁用任意提示词。
Codex-X 现在不只是“几套内置 Prompt”的启动器,而是一个可视化提示词注入与管理工具:
@@ -174,6 +178,36 @@ Codex-X 现在不只是“几套内置 Prompt”的启动器,而是一个可
<td align="left">中文技术操作员人格,覆盖 coding、CTF、逆向、内存与协议任务路由</td>
<td align="center">离线内置<br />GitHub 更新</td>
</tr>
<tr>
<td><a href="examples/software-development-maintainer.md"><code>software-development-maintainer.md</code></a></td>
<td align="left">长期维护正式项目,强调复用、最小改动、安全、测试与可维护性</td>
<td align="center">GitHub 在线同步</td>
</tr>
<tr>
<td><a href="examples/software-development-debugging.md"><code>software-development-debugging.md</code></a></td>
<td align="left">从稳定复现和证据采集推进到根因修复与回归验证</td>
<td align="center">GitHub 在线同步</td>
</tr>
<tr>
<td><a href="examples/software-development-code-review.md"><code>software-development-code-review.md</code></a></td>
<td align="left">按严重级别审查缺陷、回归、安全风险和测试缺口</td>
<td align="center">GitHub 在线同步</td>
</tr>
<tr>
<td><a href="examples/writing-clarity-editor.md"><code>writing-clarity-editor.md</code></a></td>
<td align="left">在保留原意和事实的前提下润色中英文表达</td>
<td align="center">GitHub 在线同步</td>
</tr>
<tr>
<td><a href="examples/writing-technical-docs.md"><code>writing-technical-docs.md</code></a></td>
<td align="left">基于代码与事实编写 README、指南、API 和发布文档</td>
<td align="center">GitHub 在线同步</td>
</tr>
<tr>
<td><a href="examples/writing-structured-draft.md"><code>writing-structured-draft.md</code></a></td>
<td align="left">将零散材料组织成报告、方案、复盘或文章初稿</td>
<td align="center">GitHub 在线同步</td>
</tr>
</table>
</div>
@@ -358,7 +392,16 @@ pnpm dev
pnpm --dir apps/desktop tauri build
```
## macOS 安装说明
## 桌面端安装说明
### 皮肤中心安全边界
- 实机换肤支持 macOS 和 Windows;Linux 仍可管理、导入和导出主题包,但不会显示为已应用。
- macOS 只使用官方 Codex 内置且签名一致的 Node.js,并校验 Bundle ID、代码签名和进程归属;Windows 动态校验当前用户注册的 `OpenAI.Codex` Store 包,并使用发布包内固定校验的 Node.js 22 运行时。
- CDP 仅绑定 `127.0.0.1`,不修改官方 `.app`、`WindowsApps`、`app.asar`、代码签名、目录权限或 `config.toml`。
- 首次应用通常需要确认重启 Codex;“关闭皮肤”会立即移除当前界面皮肤,之后可随时重新应用主题。
- 主题默认会根据图片尺寸和内容自适应裁切;人物或主体构图可在 `theme.json` 使用 `art.focusX`、`art.focusY`(`0` 到 `1`)和 `art.safeArea` 精确控制,无需为同一主题重复生成图片。
- 注入实现基于 [Fei-Away/Codex-Dream-Skin](https://github.com/Fei-Away/Codex-Dream-Skin) 的 MIT 源码,固定来源和许可见 `apps/desktop/src-tauri/resources/skin-runtime/NOTICE.md`。
如果你在未签名 / 未公证的 DMG 中看到“软件已损坏”提示,这是 macOS Gatekeeper 的正常行为。
+4 -2
View File
@@ -1,6 +1,6 @@
{
"name": "codex-x",
"version": "0.3.0",
"version": "0.3.6",
"private": true,
"description": "Codex Switch & Instruct desktop manager",
"type": "module",
@@ -10,10 +10,12 @@
"tauri": "tauri",
"dev:renderer": "vite --host 127.0.0.1 --port 1420",
"build:renderer": "vite build",
"typecheck": "tsc --noEmit"
"typecheck": "tsc --noEmit",
"test:skin-runtime": "node src-tauri/resources/skin-runtime/tests/run-tests.mjs"
},
"dependencies": {
"@tauri-apps/api": "^2.8.0",
"@tauri-apps/plugin-dialog": "^2.7.2",
"@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-updater": "^2.10.1",
"lucide-react": "^0.542.0",
+486 -1
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.0"
version = "0.3.6"
dependencies = [
"chrono",
"dirs 5.0.1",
@@ -360,7 +504,9 @@ 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",
@@ -378,6 +524,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"
@@ -790,6 +945,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"
@@ -817,6 +999,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"
@@ -961,6 +1163,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"
@@ -1333,6 +1548,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"
@@ -1382,6 +1603,12 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "http-range"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "21dec9db110f5f872ed9699c3ecf50cf16f423502706ba5c72462e28d3157573"
[[package]]
name = "httparse"
version = "1.10.1"
@@ -2245,6 +2472,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"
@@ -2284,6 +2521,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"
@@ -2372,6 +2615,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"
@@ -2417,6 +2671,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"
@@ -2655,6 +2923,30 @@ dependencies = [
"web-sys",
]
[[package]]
name = "rfd"
version = "0.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672"
dependencies = [
"block2",
"dispatch2",
"glib-sys",
"gobject-sys",
"gtk-sys",
"js-sys",
"log",
"objc2",
"objc2-app-kit",
"objc2-core-foundation",
"objc2-foundation",
"raw-window-handle",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"windows-sys 0.60.2",
]
[[package]]
name = "ring"
version = "0.17.14"
@@ -3093,6 +3385,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"
@@ -3404,6 +3706,7 @@ dependencies = [
"gtk",
"heck 0.5.0",
"http",
"http-range",
"jni 0.21.1",
"libc",
"log",
@@ -3516,6 +3819,48 @@ dependencies = [
"walkdir",
]
[[package]]
name = "tauri-plugin-dialog"
version = "2.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2d3c1dbe38037e7f590cdf2492594d5ceebe031e7bc7e827509b22a999d2940"
dependencies = [
"log",
"raw-window-handle",
"rfd",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"tauri-plugin-fs",
"thiserror 2.0.18",
"url",
]
[[package]]
name = "tauri-plugin-fs"
version = "2.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7ecc274121aca0c036a2b42d1cbe83d368d348f54e0bb8a735c2b1548e8f371"
dependencies = [
"anyhow",
"dunce",
"glob",
"log",
"objc2-foundation",
"percent-encoding",
"schemars 0.8.22",
"serde",
"serde_json",
"serde_repr",
"tauri",
"tauri-plugin",
"tauri-utils",
"thiserror 2.0.18",
"toml 1.1.2+spec-1.1.0",
"url",
]
[[package]]
name = "tauri-plugin-process"
version = "2.3.1"
@@ -3526,6 +3871,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"
@@ -4004,9 +4365,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"
@@ -4056,6 +4429,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"
@@ -5107,6 +5491,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"
@@ -5233,3 +5678,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",
]
+7 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "codex-x"
version = "0.3.0"
version = "0.3.6"
description = "Codex Switch & Instruct desktop manager"
authors = ["yynxxxxx"]
license = "MIT"
@@ -11,11 +11,14 @@ rust-version = "1.89.0"
name = "codexx_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[features]
windows-runtime-check = []
[build-dependencies]
tauri-build = { version = "2.4.0", features = [] }
[dependencies]
tauri = { version = "2.8.2", features = [] }
tauri = { version = "2.8.2", features = ["protocol-asset", "tray-icon"] }
tauri-plugin-process = "2.3.1"
tauri-plugin-updater = "2.10.1"
serde = { version = "1.0", features = ["derive"] }
@@ -37,3 +40,5 @@ 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,6 +6,7 @@
"permissions": [
"core:default",
"core:window:allow-start-dragging",
"dialog:allow-save",
"updater:allow-check",
"updater:allow-download",
"updater:allow-install",
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

@@ -0,0 +1,32 @@
{
"schemaVersion": 1,
"id": "shiina-mashiro-blossom",
"name": "椎名真白·樱花画室",
"brandSubtitle": "椎名真白主题",
"tagline": "椎名真白在白樱画室里安静作画。",
"projectPrefix": "选择项目 · ",
"projectLabel": "◉ 选择项目",
"statusText": "椎名真白主题运行中",
"quote": "安静创作,也会发光",
"image": "background.png",
"appearance": "light",
"surfaceOpacity": 0.62,
"art": {
"focusX": 0.28,
"focusY": 0.26,
"safeArea": "right",
"taskMode": "ambient"
},
"colors": {
"background": "#f9f4f7",
"panel": "#fffafd",
"panelAlt": "#fff1f7",
"accent": "#ef7da4",
"accentAlt": "#ffadc9",
"secondary": "#f5c2d6",
"highlight": "#c95f8a",
"text": "#2a2026",
"muted": "#8d7180",
"line": "rgba(239, 125, 164, .26)"
}
}
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Codex Dream Skin Studio contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@@ -0,0 +1,22 @@
# Codex Dream Skin Runtime Notice
The files in this directory are adapted from Codex Dream Skin Studio:
- Source: https://github.com/Fei-Away/Codex-Dream-Skin
- Pinned source commit: `5fd8af532efbaa87d2d0092297fd2d45cd56574e`
- Upstream runtime version: `1.2.0`
- License: MIT, reproduced in `LICENSE`
Codex-X embeds these runtime sources and manages them itself. It does not require
the separate Codex Dream Skin Studio installer or its `~/.codex` engine directory.
The Windows release also bundles Node.js 22.23.1 from nodejs.org, verified against
SHA-256 `7df0bc9375723f4a86b3aa1b7cc73342423d9677a8df4538aca31a049e309c29`;
its upstream license is distributed beside `node.exe`.
Only the upstream software source and procedurally generated abstract presets are
included. The Arina Hashimoto preset and other assets excluded by the upstream
notice are intentionally not distributed with Codex-X.
Codex Dream Skin Studio and Codex-X are unofficial projects and are not affiliated
with, endorsed by, or sponsored by OpenAI. Codex and related trademarks belong to
their respective owners.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,796 @@
((cssText, artDataUrl, themeConfig) => {
const STATE_KEY = "__CODEX_DREAM_SKIN_STATE__";
const DISABLED_KEY = "__CODEX_DREAM_SKIN_DISABLED__";
const STYLE_ID = "codex-dream-skin-style";
const CHROME_ID = "codex-dream-skin-chrome";
const SHELL_ATTR = "data-dream-shell";
const ART_ATTRS = [
"data-dream-art-wide", "data-dream-art-safe", "data-dream-task-mode",
"data-dream-art-safe-area", "data-dream-art-task-mode", "data-dream-art-aspect",
"data-dream-art-ready",
];
const VERSION = __DREAM_SKIN_VERSION_JSON__;
const STYLE_REVISION = __DREAM_SKIN_STYLE_REVISION_JSON__;
const THEME = themeConfig && typeof themeConfig === "object" ? themeConfig : {};
const ART = THEME.art && typeof THEME.art === "object" ? THEME.art : {};
const ART_METADATA = THEME.artMetadata && typeof THEME.artMetadata === "object"
? THEME.artMetadata : null;
const ANALYSIS_CACHE_KEY = "__CODEX_DREAM_SKIN_ANALYSIS_CACHE__";
const THEME_VARIABLES = [
"--ds-bg", "--ds-panel", "--ds-panel-2", "--ds-green", "--ds-lime",
"--ds-cyan", "--ds-purple", "--ds-text", "--ds-muted", "--ds-line",
"--ds-bg-rgb", "--ds-panel-rgb", "--ds-panel-2-rgb", "--ds-accent-rgb",
"--ds-accent-alt-rgb", "--ds-secondary-rgb", "--ds-highlight-rgb",
"--ds-text-rgb", "--ds-muted-rgb", "--ds-line-rgb", "--ds-surface-opacity",
"--dream-art-focus-x", "--dream-art-focus-y", "--dream-art-position",
"--dream-skin-focus-x", "--dream-skin-focus-y", "--dream-skin-art-position",
"--dream-skin-home-art-position",
"--dream-skin-name", "--dream-skin-tagline", "--dream-skin-project-prefix",
"--dream-skin-project-label",
];
const installToken = {};
const existingAnalysisCache = window[ANALYSIS_CACHE_KEY];
const analysisCache = existingAnalysisCache && typeof existingAnalysisCache.get === "function" &&
typeof existingAnalysisCache.set === "function" ? existingAnalysisCache : new Map();
window[ANALYSIS_CACHE_KEY] = analysisCache;
let artAnalysis = typeof THEME.artKey === "string" ? analysisCache.get(THEME.artKey) ?? null : null;
let analysisTimer = null;
let samplingNativeShell = false;
let rootObserver = null;
const now = () => typeof performance === "object" && typeof performance.now === "function"
? performance.now() : Date.now();
const metrics = {
ensureCalls: 0,
rootPasses: 0,
routePasses: 0,
layoutReads: 0,
attributeWrites: 0,
styleWrites: 0,
textWrites: 0,
analysisRuns: 0,
analysisCacheHits: artAnalysis ? 1 : 0,
firstEnsureMs: null,
analysisMs: null,
};
window[DISABLED_KEY] = false;
const previous = window[STATE_KEY];
const artUrl = (() => {
const comma = artDataUrl.indexOf(",");
const mime = /^data:([^;,]+)/.exec(artDataUrl)?.[1] || "image/png";
const binary = atob(artDataUrl.slice(comma + 1));
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index);
return URL.createObjectURL(new Blob([bytes], { type: mime }));
})();
if (previous?.observer) previous.observer.disconnect();
if (previous?.rootObserver) previous.rootObserver.disconnect();
if (previous?.resizeObserver) previous.resizeObserver.disconnect();
if (previous?.timer) clearInterval(previous.timer);
if (previous?.scheduler?.timeout) clearTimeout(previous.scheduler.timeout);
if (previous?.scheduler?.frame != null && typeof cancelAnimationFrame === "function") {
cancelAnimationFrame(previous.scheduler.frame);
}
if (previous?.analysisTimer) clearTimeout(previous.analysisTimer);
if (previous?.resizeHandler) window.removeEventListener("resize", previous.resizeHandler);
if (previous?.mediaHandler && previous?.mediaQuery) {
try { previous.mediaQuery.removeEventListener("change", previous.mediaHandler); } catch {}
}
const cssString = (value) => JSON.stringify(String(value ?? ""));
const setStyleProperty = (root, name, value) => {
if (root.style.getPropertyValue(name) !== value) {
root.style.setProperty(name, value);
metrics.styleWrites += 1;
}
};
const setAttribute = (root, name, value) => {
const normalized = String(value);
if (root.getAttribute(name) !== normalized) {
root.setAttribute(name, normalized);
metrics.attributeWrites += 1;
}
};
const setTextContent = (node, value) => {
if (node && node.textContent !== value) {
node.textContent = value;
metrics.textWrites += 1;
}
};
const parseRgb = (value) => {
if (!value || value === "transparent") return null;
const hex = String(value).trim().match(/^#([0-9a-f]{6})$/i);
if (hex) {
const number = Number.parseInt(hex[1], 16);
return { r: number >> 16, g: (number >> 8) & 255, b: number & 255 };
}
const m = String(value).match(/rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)/i);
if (!m) return null;
return { r: Number(m[1]), g: Number(m[2]), b: Number(m[3]) };
};
const clamp = (value, min, max) => Math.min(max, Math.max(min, value));
const rgbString = (value) => {
const rgb = parseRgb(value);
return rgb ? `${Math.round(rgb.r)} ${Math.round(rgb.g)} ${Math.round(rgb.b)}` : null;
};
const rgbToHex = ({ r, g, b }) => `#${[r, g, b]
.map((value) => clamp(Math.round(value), 0, 255).toString(16).padStart(2, "0"))
.join("")}`;
const rgbToHsl = ({ r, g, b }) => {
const values = [r, g, b].map((value) => value / 255);
const max = Math.max(...values);
const min = Math.min(...values);
const lightness = (max + min) / 2;
if (max === min) return { h: 0, s: 0, l: lightness };
const delta = max - min;
const saturation = lightness > 0.5 ? delta / (2 - max - min) : delta / (max + min);
let hue;
if (max === values[0]) hue = (values[1] - values[2]) / delta + (values[1] < values[2] ? 6 : 0);
else if (max === values[1]) hue = (values[2] - values[0]) / delta + 2;
else hue = (values[0] - values[1]) / delta + 4;
return { h: hue * 60, s: saturation, l: lightness };
};
const hslToRgb = ({ h, s, l }) => {
const hue = ((h % 360) + 360) % 360 / 360;
if (s === 0) {
const neutral = Math.round(l * 255);
return { r: neutral, g: neutral, b: neutral };
}
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
const p = 2 * l - q;
const channel = (offset) => {
let t = hue + offset;
if (t < 0) t += 1;
if (t > 1) t -= 1;
if (t < 1 / 6) return p + (q - p) * 6 * t;
if (t < 1 / 2) return q;
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
return p;
};
return { r: channel(1 / 3) * 255, g: channel(0) * 255, b: channel(-1 / 3) * 255 };
};
const luminance = ({ r, g, b }) => {
const lin = [r, g, b].map((c) => {
const x = c / 255;
return x <= 0.03928 ? x / 12.92 : ((x + 0.055) / 1.055) ** 2.4;
});
return 0.2126 * lin[0] + 0.7152 * lin[1] + 0.0722 * lin[2];
};
/** Detect Codex app light/dark shell for CSS branching. */
const detectShellMode = () => {
const root = document.documentElement;
const body = document.body;
const cls = `${root.className || ""} ${body?.className || ""}`.toLowerCase();
if (/\b(dark|theme-dark|appearance-dark)\b/.test(cls)) return "dark";
if (/\b(light|theme-light|appearance-light)\b/.test(cls)) return "light";
const dataTheme = (
root.getAttribute("data-theme") ||
root.getAttribute("data-appearance") ||
root.getAttribute("data-color-mode") ||
body?.getAttribute("data-theme") ||
body?.getAttribute("data-appearance") ||
""
).toLowerCase();
if (dataTheme.includes("dark")) return "dark";
if (dataTheme.includes("light")) return "light";
// Radios in profile menu (if present in DOM)
const checked = document.querySelector('input[name="appearance-theme"]:checked');
if (checked) {
const label = (checked.getAttribute("aria-label") || checked.value || "").toLowerCase();
if (label.includes("暗") || label.includes("dark")) return "dark";
if (label.includes("浅") || label.includes("light")) return "light";
if (label.includes("系统") || label.includes("system")) {
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
}
}
// The skin itself declares color-scheme on :root. Once installed,
// reading getComputedStyle(root) directly would therefore keep `auto`
// themes locked to the previous shell mode. Temporarily remove only our
// own root class/attribute, sample the native computed scheme, then restore
// synchronously. Mutation records created by this probe are drained below
// so the root observer does not schedule a redundant ensure pass.
try {
const hadSkin = root.classList.contains("codex-dream-skin");
const savedShell = root.getAttribute(SHELL_ATTR);
samplingNativeShell = true;
if (hadSkin) root.classList.remove("codex-dream-skin");
if (savedShell !== null) root.removeAttribute(SHELL_ATTR);
let colorScheme = "";
try {
colorScheme = getComputedStyle(root).colorScheme || "";
} finally {
if (hadSkin) root.classList.add("codex-dream-skin");
if (savedShell !== null) root.setAttribute(SHELL_ATTR, savedShell);
rootObserver?.takeRecords?.();
samplingNativeShell = false;
}
if (colorScheme.includes("dark") && !colorScheme.includes("light")) return "dark";
if (colorScheme.includes("light") && !colorScheme.includes("dark")) return "light";
} catch {
samplingNativeShell = false;
}
try {
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
} catch {}
// Only use surface luminance before the skin owns those surfaces. Sampling
// our own translucent layers would create route-dependent light/dark flips.
if (!root.classList.contains("codex-dream-skin")) {
const samples = [
body,
document.querySelector("main.main-surface"),
document.querySelector("aside.app-shell-left-panel"),
].filter(Boolean);
let votesLight = 0;
let votesDark = 0;
for (const el of samples) {
try {
const rgb = parseRgb(getComputedStyle(el).backgroundColor);
if (!rgb) continue;
const L = luminance(rgb);
if (L >= 0.55) votesLight += 1;
else if (L <= 0.25) votesDark += 1;
} catch {}
}
if (votesLight > votesDark) return "light";
if (votesDark > votesLight) return "dark";
}
return "light";
};
const makeAdaptivePalette = (sample, shell) => {
const source = sample || { r: 108, g: 126, b: 136 };
const hsl = rgbToHsl(source);
const hue = hsl.s < 0.12 ? 214 : hsl.h;
const saturation = clamp(hsl.s, 0.38, 0.72);
const accent = hslToRgb({ h: hue, s: saturation, l: shell === "light" ? 0.42 : 0.66 });
const accentAlt = hslToRgb({ h: hue + 12, s: saturation * 0.82, l: shell === "light" ? 0.52 : 0.73 });
const secondary = hslToRgb({ h: hue - 24, s: saturation * 0.64, l: shell === "light" ? 0.56 : 0.62 });
const highlight = hslToRgb({ h: hue + 24, s: saturation * 0.76, l: shell === "light" ? 0.36 : 0.58 });
const neutral = (lightness, chroma = 0.08) => rgbToHex(hslToRgb({ h: hue, s: chroma, l: lightness }));
return shell === "light" ? {
background: neutral(0.965, 0.07),
panel: neutral(0.987, 0.035),
panelAlt: neutral(0.945, 0.09),
accent: rgbToHex(accent),
accentAlt: rgbToHex(accentAlt),
secondary: rgbToHex(secondary),
highlight: rgbToHex(highlight),
text: neutral(0.13, 0.10),
muted: neutral(0.42, 0.08),
line: `rgba(${Math.round(accent.r)}, ${Math.round(accent.g)}, ${Math.round(accent.b)}, .24)`,
} : {
background: neutral(0.055, 0.045),
panel: neutral(0.085, 0.04),
panelAlt: neutral(0.125, 0.05),
accent: rgbToHex(accent),
accentAlt: rgbToHex(accentAlt),
secondary: rgbToHex(secondary),
highlight: rgbToHex(highlight),
text: neutral(0.93, 0.025),
muted: neutral(0.69, 0.03),
line: `rgba(${Math.round(accent.r)}, ${Math.round(accent.g)}, ${Math.round(accent.b)}, .28)`,
};
};
const resolvedShell = () => {
if (THEME.appearance === "light" || THEME.appearance === "dark") return THEME.appearance;
// Image luminance may tune accents and scrims, but auto appearance follows
// Codex/ChatGPT (or the OS fallback) so a bright wallpaper cannot flip a
// native dark session back to a light shell after analysis.
return detectShellMode();
};
const applyTheme = (root, shell) => {
const colors = THEME.colors || {};
const explicit = new Set(Array.isArray(THEME.explicitColorKeys) ? THEME.explicitColorKeys : []);
const adaptive = makeAdaptivePalette(artAnalysis?.accentRgb, shell);
const legacyLight = !THEME.appearance && shell === "light";
const structural = new Set(["background", "panel", "panelAlt", "text", "muted"]);
const pick = (name) => {
const allowExplicit = explicit.has(name) && !(legacyLight && structural.has(name));
return allowExplicit && typeof colors[name] === "string" ? colors[name] : adaptive[name];
};
const accent = pick("accent");
const accentAlt = explicit.has("accentAlt") ? pick("accentAlt") : (explicit.has("accent") ? accent : adaptive.accentAlt);
const variables = {
"--ds-bg": pick("background"),
"--ds-panel": pick("panel"),
"--ds-panel-2": pick("panelAlt"),
"--ds-green": accent,
"--ds-lime": accentAlt,
"--ds-cyan": pick("secondary"),
"--ds-purple": pick("highlight"),
"--ds-text": pick("text"),
"--ds-muted": pick("muted"),
"--ds-line": explicit.has("line") && typeof colors.line === "string" ? colors.line : adaptive.line,
};
const surfaceOpacity = Number.isFinite(Number(THEME.surfaceOpacity))
? clamp(Number(THEME.surfaceOpacity), 0.35, 1)
: 1;
setStyleProperty(root, "--ds-surface-opacity", surfaceOpacity.toFixed(2));
for (const [name, value] of Object.entries(variables)) {
if (typeof value === "string" && value) setStyleProperty(root, name, value);
}
const rgbVariables = {
"--ds-bg-rgb": variables["--ds-bg"],
"--ds-panel-rgb": variables["--ds-panel"],
"--ds-panel-2-rgb": variables["--ds-panel-2"],
"--ds-accent-rgb": variables["--ds-green"],
"--ds-accent-alt-rgb": variables["--ds-lime"],
"--ds-secondary-rgb": variables["--ds-cyan"],
"--ds-highlight-rgb": variables["--ds-purple"],
"--ds-text-rgb": variables["--ds-text"],
"--ds-muted-rgb": variables["--ds-muted"],
"--ds-line-rgb": variables["--ds-line"],
};
for (const [name, value] of Object.entries(rgbVariables)) {
const rgb = rgbString(value);
if (rgb) setStyleProperty(root, name, rgb);
}
setStyleProperty(root, "--dream-skin-name", cssString(THEME.name || "Codex Dream Skin"));
setStyleProperty(root, "--dream-skin-tagline", cssString(THEME.tagline || "Make something wonderful."));
setStyleProperty(root, "--dream-skin-project-prefix", cssString(THEME.projectPrefix || "选择项目 · "));
setStyleProperty(root, "--dream-skin-project-label", cssString(THEME.projectLabel || "◉ 选择项目"));
};
const applyArtMetadata = (root) => {
const profile = artAnalysis || ART_METADATA;
const inferredSafe = profile?.safeArea || "center";
const safeArea = ART.safeArea && ART.safeArea !== "auto" ? ART.safeArea : inferredSafe;
const canonicalSafe = ["left", "right", "center", "none"].includes(safeArea)
? safeArea : "center";
const focusX = typeof ART.focusX === "number" ? ART.focusX
: profile?.focusX ?? (safeArea === "left" ? 0.72 : safeArea === "right" ? 0.28 : 0.5);
const focusY = typeof ART.focusY === "number" ? ART.focusY : profile?.focusY ?? 0.5;
const explicitFocusY = typeof ART.focusY === "number";
const taskMode = ART.taskMode && ART.taskMode !== "auto"
? ART.taskMode : profile?.taskMode || "ambient";
const wide = profile?.wide || false;
const aspect = profile?.aspect || "unknown";
const homeFocusY = !explicitFocusY && !wide && ["wide", "landscape"].includes(aspect)
? Math.min(focusY, 0.28) : focusY;
const focusXValue = `${(clamp(focusX, 0, 1) * 100).toFixed(2)}%`;
const focusYValue = `${(clamp(focusY, 0, 1) * 100).toFixed(2)}%`;
const homeFocusYValue = `${(clamp(homeFocusY, 0, 1) * 100).toFixed(2)}%`;
setAttribute(root, "data-dream-art-wide", wide ? "true" : "false");
setAttribute(root, "data-dream-art-safe", canonicalSafe);
setAttribute(root, "data-dream-task-mode", taskMode);
setAttribute(root, "data-dream-art-safe-area", safeArea);
setAttribute(root, "data-dream-art-task-mode", taskMode);
setAttribute(root, "data-dream-art-aspect", aspect);
setAttribute(root, "data-dream-art-ready", artAnalysis ? "true" : "false");
setStyleProperty(root, "--dream-art-focus-x", focusXValue);
setStyleProperty(root, "--dream-art-focus-y", focusYValue);
setStyleProperty(root, "--dream-art-position", `${focusXValue} ${focusYValue}`);
setStyleProperty(root, "--dream-skin-focus-x", focusXValue);
setStyleProperty(root, "--dream-skin-focus-y", focusYValue);
setStyleProperty(root, "--dream-skin-art-position", `${focusXValue} ${focusYValue}`);
setStyleProperty(root, "--dream-skin-home-art-position", `${focusXValue} ${homeFocusYValue}`);
};
const analyzeArt = () => new Promise((resolve) => {
const startedAt = now();
metrics.analysisRuns += 1;
if (typeof window.Image !== "function" || !document?.createElement) {
metrics.analysisMs = Number((now() - startedAt).toFixed(3));
resolve(null);
return;
}
const image = new window.Image();
let settled = false;
const finish = (value) => {
if (settled) return;
settled = true;
if (analysisTimer) clearTimeout(analysisTimer);
analysisTimer = null;
metrics.analysisMs = Number((now() - startedAt).toFixed(3));
resolve(value);
};
analysisTimer = setTimeout(() => finish(null), 6000);
image.onerror = () => finish(null);
image.onload = () => {
try {
const ratio = image.naturalWidth / image.naturalHeight;
if (!Number.isFinite(ratio) || ratio <= 0) throw new Error("Invalid image dimensions");
const maxDimension = 96;
const width = Math.max(16, Math.round(ratio >= 1 ? maxDimension : maxDimension * ratio));
const height = Math.max(16, Math.round(ratio >= 1 ? maxDimension / ratio : maxDimension));
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const context = canvas.getContext?.("2d", { willReadFrequently: true });
if (!context) throw new Error("Canvas is unavailable");
context.drawImage(image, 0, 0, width, height);
const data = context.getImageData(0, 0, width, height).data;
const samples = new Array(width * height);
const bins = Array.from({ length: 24 }, () => ({ weight: 0, r: 0, g: 0, b: 0 }));
let lightTotal = 0;
let count = 0;
for (let y = 0; y < height; y += 1) {
for (let x = 0; x < width; x += 1) {
const offset = (y * width + x) * 4;
if (data[offset + 3] < 32) continue;
const rgb = { r: data[offset], g: data[offset + 1], b: data[offset + 2] };
const light = (0.2126 * rgb.r + 0.7152 * rgb.g + 0.0722 * rgb.b) / 255;
const hsl = rgbToHsl(rgb);
samples[y * width + x] = { light, saturation: hsl.s };
lightTotal += light;
count += 1;
if (hsl.s >= 0.16 && hsl.l >= 0.16 && hsl.l <= 0.86) {
const bin = bins[Math.min(23, Math.floor(hsl.h / 15))];
const weight = hsl.s * (1 - Math.abs(hsl.l - 0.52) * 0.85);
bin.weight += weight;
bin.r += rgb.r * weight;
bin.g += rgb.g * weight;
bin.b += rgb.b * weight;
}
}
}
if (!count) throw new Error("Image has no visible pixels");
const brightness = lightTotal / count;
const information = (start, end) => {
let total = 0;
let totalSquared = 0;
let edges = 0;
let edgeCount = 0;
let pixels = 0;
for (let y = 0; y < height; y += 1) {
for (let x = start; x < end; x += 1) {
const sample = samples[y * width + x];
if (!sample) continue;
total += sample.light;
totalSquared += sample.light * sample.light;
pixels += 1;
const previous = x > start ? samples[y * width + x - 1] : null;
const above = y > 0 ? samples[(y - 1) * width + x] : null;
if (previous) { edges += Math.abs(sample.light - previous.light); edgeCount += 1; }
if (above) { edges += Math.abs(sample.light - above.light); edgeCount += 1; }
}
}
const mean = pixels ? total / pixels : 0;
const variance = pixels ? Math.max(0, totalSquared / pixels - mean * mean) : 1;
return Math.sqrt(variance) * 0.58 + (edgeCount ? edges / edgeCount : 1) * 0.42;
};
const zoneWidth = Math.max(1, Math.floor(width * 0.38));
const leftInformation = information(0, zoneWidth);
const rightInformation = information(width - zoneWidth, width);
let safeArea = "center";
if (leftInformation < rightInformation * 0.86) safeArea = "left";
else if (rightInformation < leftInformation * 0.86) safeArea = "right";
let saliencyTotal = 0;
let saliencyX = 0;
let saliencyY = 0;
for (let y = 0; y < height; y += 1) {
for (let x = 0; x < width; x += 1) {
const sample = samples[y * width + x];
if (!sample) continue;
const previous = x > 0 ? samples[y * width + x - 1] : null;
const above = y > 0 ? samples[(y - 1) * width + x] : null;
const edge = (previous ? Math.abs(sample.light - previous.light) : 0) +
(above ? Math.abs(sample.light - above.light) : 0);
const weight = 0.01 + Math.abs(sample.light - brightness) * 0.48 +
sample.saturation * 0.34 + edge * 0.28;
saliencyTotal += weight;
saliencyX += (x + 0.5) / width * weight;
saliencyY += (y + 0.5) / height * weight;
}
}
let focusX = saliencyTotal ? saliencyX / saliencyTotal : 0.5;
let focusY = saliencyTotal ? saliencyY / saliencyTotal : 0.5;
if (safeArea === "left") focusX = Math.max(0.64, focusX);
if (safeArea === "right") focusX = Math.min(0.36, focusX);
focusX = clamp(focusX, 0.12, 0.88);
focusY = clamp(focusY, 0.18, 0.82);
const accentBin = bins.reduce((best, candidate) => candidate.weight > best.weight ? candidate : best, bins[0]);
const accentRgb = accentBin.weight > 0 ? {
r: accentBin.r / accentBin.weight,
g: accentBin.g / accentBin.weight,
b: accentBin.b / accentBin.weight,
} : null;
const aspect = ratio >= 2.25 ? "ultrawide" : ratio >= 1.45 ? "wide"
: ratio >= 1.08 ? "landscape" : ratio >= 0.9 ? "square" : "portrait";
finish({
width: image.naturalWidth,
height: image.naturalHeight,
ratio,
wide: ratio >= 1.75,
aspect,
brightness,
shell: brightness >= 0.58 ? "light" : "dark",
safeArea,
focusX,
focusY,
taskMode: ratio >= 2.25 ? "banner" : "ambient",
accentRgb,
});
} catch {
finish(null);
}
};
image.src = artUrl;
});
let chromeParts = null;
let observedShellMain = null;
let resizeObserver = null;
const ensureStyle = (root) => {
let style = document.getElementById(STYLE_ID);
if (!style) {
style = document.createElement("style");
style.id = STYLE_ID;
style.textContent = cssText;
style.dataset.dreamSkinVersion = VERSION;
(document.head || root).appendChild(style);
} else if (style.dataset.dreamSkinStyleRevision !== STYLE_REVISION) {
style.textContent = cssText;
}
style.dataset.dreamSkinVersion = VERSION;
style.dataset.dreamSkinStyleRevision = STYLE_REVISION;
return style;
};
const applyRootState = (root) => {
metrics.rootPasses += 1;
ensureStyle(root);
const shell = resolvedShell();
setAttribute(root, SHELL_ATTR, shell);
setStyleProperty(root, "--dream-skin-art", `url("${artUrl}")`);
applyTheme(root, shell);
applyArtMetadata(root);
root.classList.add("codex-dream-skin");
return shell;
};
const syncRouteState = (shell, { layout = false } = {}) => {
metrics.routePasses += 1;
const root = document.documentElement;
if (!root) return;
shell ||= root.getAttribute(SHELL_ATTR) || resolvedShell();
const shellMain = document.querySelector("main.main-surface") || document.querySelector("main");
const homeIndicator = document.querySelector('[data-testid="home-icon"]');
const home = homeIndicator?.closest('[role="main"]') ||
[...document.querySelectorAll('[role="main"]')].find((candidate) =>
candidate.querySelector('[data-feature="game-source"]') &&
candidate.querySelector('[data-codex-composer-root]')) || null;
for (const candidate of document.querySelectorAll('[role="main"].dream-skin-home')) {
if (candidate !== home) candidate.classList.remove("dream-skin-home");
}
if (home) home.classList.add("dream-skin-home");
const homeUtilityBars = new Set(home
? home.querySelectorAll('[class*="_homeUtilityBar_"]')
: []);
for (const candidate of document.querySelectorAll(".dream-skin-home-utility")) {
if (!homeUtilityBars.has(candidate)) candidate.classList.remove("dream-skin-home-utility");
}
for (const candidate of homeUtilityBars) candidate.classList.add("dream-skin-home-utility");
if (!shellMain || !document.body) return;
if (observedShellMain !== shellMain) {
resizeObserver?.disconnect();
resizeObserver?.observe(shellMain);
observedShellMain = shellMain;
layout = true;
}
shellMain.classList.toggle("dream-skin-home-shell", Boolean(home));
let chrome = document.getElementById(CHROME_ID);
let created = false;
if (!chrome || chrome.parentElement !== document.body) {
chrome?.remove();
chrome = document.createElement("div");
chrome.id = CHROME_ID;
chrome.setAttribute("aria-hidden", "true");
chrome.innerHTML = `
<div class="dream-skin-brand">
<span class="dream-skin-portal-mark">◉</span>
<span><b></b><small></small></span>
</div>
<div class="dream-skin-status"><i></i><span></span></div>
<div class="dream-skin-quote"></div>
<div class="dream-skin-particles"><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i></div>
<div class="dream-skin-orbit"></div>`;
document.body.appendChild(chrome);
created = true;
chromeParts = null;
}
if (!chromeParts || chromeParts.chrome !== chrome) {
chromeParts = {
chrome,
name: chrome.querySelector(".dream-skin-brand b"),
subtitle: chrome.querySelector(".dream-skin-brand small"),
status: chrome.querySelector(".dream-skin-status span"),
quote: chrome.querySelector(".dream-skin-quote"),
};
}
setTextContent(chromeParts.name, THEME.name || "Codex Dream Skin");
setTextContent(chromeParts.subtitle, THEME.brandSubtitle || "CODEX DREAM SKIN");
setTextContent(chromeParts.status, THEME.statusText || "DREAM SKIN ONLINE");
setTextContent(chromeParts.quote, THEME.quote || "MAKE SOMETHING WONDERFUL");
if (layout || created) {
metrics.layoutReads += 1;
const shellBox = shellMain.getBoundingClientRect();
setStyleProperty(chrome, "left", `${Math.round(shellBox.left)}px`);
setStyleProperty(chrome, "top", `${Math.round(shellBox.top)}px`);
setStyleProperty(chrome, "width", `${Math.round(shellBox.width)}px`);
setStyleProperty(chrome, "height", `${Math.round(shellBox.height)}px`);
}
chrome.classList.toggle("dream-skin-home-shell", Boolean(home));
if (chrome.dataset.dreamShell !== shell) {
chrome.dataset.dreamShell = shell;
metrics.attributeWrites += 1;
}
};
const ensure = ({ root: rootPass = true, route = true, layout = true } = {}) => {
if (window[DISABLED_KEY]) return;
const root = document.documentElement;
if (!root) return;
metrics.ensureCalls += 1;
const shell = rootPass ? applyRootState(root) : null;
if (route) syncRouteState(shell, { layout });
};
const cleanup = () => {
const state = window[STATE_KEY];
if (state?.installToken !== installToken) return false;
window[DISABLED_KEY] = true;
document.documentElement?.classList.remove("codex-dream-skin");
document.documentElement?.removeAttribute(SHELL_ATTR);
for (const name of ART_ATTRS) document.documentElement?.removeAttribute(name);
document.documentElement?.style.removeProperty("--dream-skin-art");
for (const name of THEME_VARIABLES) document.documentElement?.style.removeProperty(name);
document.querySelectorAll(".dream-skin-home").forEach((node) => node.classList.remove("dream-skin-home"));
document.querySelectorAll(".dream-skin-home-shell").forEach((node) => node.classList.remove("dream-skin-home-shell"));
document.querySelectorAll(".dream-skin-home-utility").forEach((node) => node.classList.remove("dream-skin-home-utility"));
document.getElementById(STYLE_ID)?.remove();
document.getElementById(CHROME_ID)?.remove();
state?.observer?.disconnect();
state?.rootObserver?.disconnect();
state?.resizeObserver?.disconnect();
if (state?.timer) clearInterval(state.timer);
if (state?.scheduler?.timeout) clearTimeout(state.scheduler.timeout);
if (state?.scheduler?.frame != null && typeof cancelAnimationFrame === "function") {
cancelAnimationFrame(state.scheduler.frame);
}
if (analysisTimer) clearTimeout(analysisTimer);
if (state?.resizeHandler) window.removeEventListener("resize", state.resizeHandler);
if (state?.mediaHandler && state?.mediaQuery) {
try { state.mediaQuery.removeEventListener("change", state.mediaHandler); } catch {}
}
if (state?.artUrl) URL.revokeObjectURL(state.artUrl);
delete window[STATE_KEY];
return true;
};
const scheduler = { timeout: null, frame: null, root: false, route: false, layout: false };
const flushScheduledEnsure = () => {
if (scheduler.frame !== null && typeof cancelAnimationFrame === "function") {
cancelAnimationFrame(scheduler.frame);
}
if (scheduler.timeout) clearTimeout(scheduler.timeout);
scheduler.frame = null;
scheduler.timeout = null;
const pending = { root: scheduler.root, route: scheduler.route, layout: scheduler.layout };
scheduler.root = false;
scheduler.route = false;
scheduler.layout = false;
ensure(pending);
};
const scheduleEnsure = ({ root = false, route = true, layout = false } = {}) => {
scheduler.root ||= root;
scheduler.route ||= route;
scheduler.layout ||= layout;
if (scheduler.timeout || scheduler.frame !== null) return;
if (typeof requestAnimationFrame === "function") {
scheduler.frame = requestAnimationFrame(flushScheduledEnsure);
scheduler.timeout = setTimeout(flushScheduledEnsure, 96);
} else {
scheduler.timeout = setTimeout(flushScheduledEnsure, 64);
}
};
const observer = new MutationObserver(() => scheduleEnsure({ route: true }));
rootObserver = new MutationObserver(() => {
if (samplingNativeShell) return;
scheduleEnsure({ root: true, route: true });
});
const resizeHandler = () => scheduleEnsure({ route: true, layout: true });
if (typeof ResizeObserver === "function") {
resizeObserver = new ResizeObserver(() => scheduleEnsure({ route: true, layout: true }));
}
let mediaQuery = null;
let mediaHandler = null;
try {
mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
mediaHandler = () => scheduleEnsure({ root: true, route: true });
} catch {}
window[STATE_KEY] = {
ensure,
cleanup,
observer,
rootObserver,
resizeObserver,
timer: null,
scheduler,
resizeHandler,
mediaQuery,
mediaHandler,
artUrl,
installToken,
analysis: artAnalysis,
artMetadata: ART_METADATA,
metrics,
version: VERSION,
themeId: THEME.id || "custom",
detectShellMode,
};
const firstEnsureStartedAt = now();
ensure({ layout: !previous || !document.getElementById(CHROME_ID) });
metrics.firstEnsureMs = Number((now() - firstEnsureStartedAt).toFixed(3));
if (previous?.artUrl && previous.artUrl !== artUrl) URL.revokeObjectURL(previous.artUrl);
observer.observe(document.documentElement, {
childList: true,
subtree: true,
});
rootObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class", "data-theme", "data-appearance", "data-color-mode", "style"],
});
if (document.body) {
rootObserver.observe(document.body, {
attributes: true,
attributeFilter: ["class", "data-theme", "data-appearance", "data-color-mode", "style"],
});
}
const timer = setInterval(() => ensure(), 4000);
window[STATE_KEY].timer = timer;
window.addEventListener("resize", resizeHandler, { passive: true });
if (mediaHandler && mediaQuery) {
mediaQuery.addEventListener("change", mediaHandler);
}
const analysisPromise = artAnalysis ? Promise.resolve(null) : analyzeArt();
window[STATE_KEY].analysisTimer = analysisTimer;
analysisPromise.then((analysis) => {
const state = window[STATE_KEY];
if (!analysis || state?.installToken !== installToken || window[DISABLED_KEY]) return;
artAnalysis = analysis;
state.analysis = analysis;
if (typeof THEME.artKey === "string") {
analysisCache.set(THEME.artKey, analysis);
while (analysisCache.size > 8) analysisCache.delete(analysisCache.keys().next().value);
}
ensure({ root: true, route: false, layout: false });
}).catch(() => {});
return {
installed: true,
version: VERSION,
themeId: THEME.id || "custom",
shell: resolvedShell(),
analysis: artAnalysis,
};
})(__DREAM_SKIN_CSS_JSON__, __DREAM_SKIN_ART_JSON__, __DREAM_SKIN_THEME_JSON__)
@@ -0,0 +1,131 @@
const SOF_MARKERS = new Set([
0xc0, 0xc1, 0xc2, 0xc3, 0xc5, 0xc6, 0xc7,
0xc9, 0xca, 0xcb, 0xcd, 0xce, 0xcf,
]);
export const MAX_IMAGE_DIMENSION = 16384;
export const MAX_IMAGE_PIXELS = 50_000_000;
function uint16be(bytes, offset) {
return bytes[offset] * 256 + bytes[offset + 1];
}
function uint16le(bytes, offset) {
return bytes[offset] + bytes[offset + 1] * 256;
}
function uint24le(bytes, offset) {
return bytes[offset] + bytes[offset + 1] * 256 + bytes[offset + 2] * 65536;
}
function uint32be(bytes, offset) {
return bytes[offset] * 0x1000000 + bytes[offset + 1] * 0x10000 +
bytes[offset + 2] * 0x100 + bytes[offset + 3];
}
function uint32le(bytes, offset) {
return bytes[offset] + bytes[offset + 1] * 0x100 + bytes[offset + 2] * 0x10000 +
bytes[offset + 3] * 0x1000000;
}
function ascii(bytes, offset, length) {
return String.fromCharCode(...bytes.subarray(offset, offset + length));
}
function pngDimensions(bytes) {
const signature = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
if (bytes.length < 24 || signature.some((value, index) => bytes[index] !== value) ||
uint32be(bytes, 8) !== 13 || ascii(bytes, 12, 4) !== "IHDR") return null;
const width = uint32be(bytes, 16);
const height = uint32be(bytes, 20);
return width > 0 && height > 0 ? { width, height } : null;
}
function jpegDimensions(bytes) {
if (bytes.length < 12 || bytes[0] !== 0xff || bytes[1] !== 0xd8) return null;
let offset = 2;
while (offset + 9 < bytes.length) {
if (bytes[offset] !== 0xff) {
offset += 1;
continue;
}
while (offset < bytes.length && bytes[offset] === 0xff) offset += 1;
const marker = bytes[offset++];
if (marker === 0xd9 || marker === 0xda) break;
if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd8)) continue;
if (offset + 2 > bytes.length) break;
const length = uint16be(bytes, offset);
if (length < 2 || offset + length > bytes.length) break;
if (SOF_MARKERS.has(marker) && length >= 7) {
const height = uint16be(bytes, offset + 3);
const width = uint16be(bytes, offset + 5);
return width > 0 && height > 0 ? { width, height } : null;
}
offset += length;
}
return null;
}
function webpDimensions(bytes) {
if (bytes.length < 20 || ascii(bytes, 0, 4) !== "RIFF" || ascii(bytes, 8, 4) !== "WEBP") {
return null;
}
const riffEnd = Math.min(bytes.length, uint32le(bytes, 4) + 8);
let offset = 12;
while (offset + 8 <= riffEnd) {
const type = ascii(bytes, offset, 4);
const size = bytes[offset + 4] + bytes[offset + 5] * 256 +
bytes[offset + 6] * 65536 + bytes[offset + 7] * 0x1000000;
const data = offset + 8;
if (data + size > riffEnd) break;
if (type === "VP8X" && size >= 10) {
return { width: uint24le(bytes, data + 4) + 1, height: uint24le(bytes, data + 7) + 1 };
}
if (type === "VP8L" && size >= 5 && bytes[data] === 0x2f) {
const width = 1 + bytes[data + 1] + ((bytes[data + 2] & 0x3f) << 8);
const height = 1 + (bytes[data + 2] >> 6) + (bytes[data + 3] << 2) +
((bytes[data + 4] & 0x0f) << 10);
return { width, height };
}
if (type === "VP8 " && size >= 10 && bytes[data + 3] === 0x9d &&
bytes[data + 4] === 0x01 && bytes[data + 5] === 0x2a) {
return {
width: uint16le(bytes, data + 6) & 0x3fff,
height: uint16le(bytes, data + 8) & 0x3fff,
};
}
offset = data + size + (size % 2);
}
return null;
}
export function classifyImageDimensions({ width, height }) {
const ratio = width / height;
if (
!Number.isSafeInteger(width) || !Number.isSafeInteger(height)
|| width < 1 || height < 1
|| width > MAX_IMAGE_DIMENSION || height > MAX_IMAGE_DIMENSION
|| width * height > MAX_IMAGE_PIXELS
|| !Number.isFinite(ratio)
) return null;
const aspect = ratio >= 2.25 ? "ultrawide" : ratio >= 1.45 ? "wide"
: ratio >= 1.08 ? "landscape" : ratio >= 0.9 ? "square" : "portrait";
return {
width,
height,
ratio,
wide: ratio >= 1.75,
aspect,
taskMode: ratio >= 2.25 ? "banner" : "ambient",
};
}
export function readImageMetadata(value, extension = "") {
const bytes = value instanceof Uint8Array ? value : new Uint8Array(value);
const normalized = extension.toLowerCase();
let dimensions = null;
if (normalized === ".png" || bytes[0] === 0x89) dimensions = pngDimensions(bytes);
else if (normalized === ".jpg" || normalized === ".jpeg" ||
(bytes[0] === 0xff && bytes[1] === 0xd8)) dimensions = jpegDimensions(bytes);
else if (normalized === ".webp" || ascii(bytes, 8, 4) === "WEBP") dimensions = webpDimensions(bytes);
return dimensions ? classifyImageDimensions(dimensions) : null;
}
@@ -0,0 +1,942 @@
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);
}
}
}
@@ -0,0 +1,116 @@
import fs from "node:fs/promises";
import { constants as fsConstants } from "node:fs";
import path from "node:path";
const [sourceDirArg, stageDirArg] = process.argv.slice(2);
if (!sourceDirArg || !stageDirArg) {
throw new Error("Usage: stage-theme.mjs <source-theme-dir> <stage-dir>");
}
const MAX_CONFIG_BYTES = 1024 * 1024;
const MAX_IMAGE_BYTES = 16 * 1024 * 1024;
const OPEN_FLAGS = fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0);
function assertContained(rootPath, candidatePath, label) {
const relative = path.relative(rootPath, candidatePath);
if (
relative === ""
|| (!path.isAbsolute(relative) && relative !== ".." && !relative.startsWith(`..${path.sep}`))
) return;
throw new Error(`${label} must stay inside its theme directory`);
}
function sameStat(left, right) {
return left.isFile() && right.isFile()
&& left.dev === right.dev
&& left.ino === right.ino
&& left.size === right.size
&& left.mtimeMs === right.mtimeMs
&& left.ctimeMs === right.ctimeMs;
}
async function readStableFile(filePath, label, maxBytes) {
let handle;
try {
handle = await fs.open(filePath, OPEN_FLAGS);
} catch (error) {
if (error.code === "ELOOP") throw new Error(`${label} must not be a symbolic link`);
throw error;
}
try {
const before = await handle.stat();
if (!before.isFile()) throw new Error(`${label} must be a regular file`);
if (before.size > maxBytes) throw new Error(`${label} is larger than ${maxBytes} bytes`);
const bytes = await handle.readFile();
const after = await handle.stat();
if (!sameStat(before, after)) {
throw new Error(`${label} changed while it was being staged`);
}
if (bytes.length > maxBytes) throw new Error(`${label} is larger than ${maxBytes} bytes`);
return { bytes, stat: after };
} finally {
await handle.close();
}
}
function decodeJson(bytes, label) {
const text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
if (text.includes("\0")) throw new Error(`${label} contains NUL characters`);
try {
return JSON.parse(text);
} catch {
throw new Error(`${label} is not valid JSON`);
}
}
async function writeExclusive(filePath, bytes) {
const temporary = `${filePath}.${process.pid}.tmp`;
try {
await fs.writeFile(temporary, bytes, { flag: "wx", mode: 0o600 });
await fs.rename(temporary, filePath);
} finally {
await fs.rm(temporary, { force: true }).catch(() => {});
}
}
async function main() {
const sourceRoot = await fs.realpath(sourceDirArg);
const sourceStat = await fs.stat(sourceRoot);
if (!sourceStat.isDirectory()) throw new Error("Theme source must be a directory");
const configPath = path.join(sourceRoot, "theme.json");
const config = await readStableFile(configPath, "Theme config", MAX_CONFIG_BYTES);
const theme = decodeJson(config.bytes, "Theme config");
if (theme?.schemaVersion !== 1 || typeof theme.image !== "string" || !theme.image) {
throw new Error("Theme config has an unsupported schema or image field");
}
if (path.basename(theme.image) !== theme.image) {
throw new Error("Theme image must stay inside its theme directory");
}
if (theme.image === "theme.json") {
throw new Error("Theme image must not replace theme.json");
}
if (/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/u.test(theme.image)) {
throw new Error("Theme image contains control characters");
}
const imagePath = path.resolve(sourceRoot, theme.image);
assertContained(sourceRoot, imagePath, "Theme image");
const image = await readStableFile(imagePath, "Theme image", MAX_IMAGE_BYTES);
if (image.bytes.length < 1) throw new Error("Theme image is empty");
const stageRoot = await fs.realpath(stageDirArg);
const stageStat = await fs.stat(stageRoot);
if (!stageStat.isDirectory()) throw new Error("Theme stage must be a directory");
assertContained(stageRoot, path.join(stageRoot, "theme.json"), "Staged theme config");
assertContained(stageRoot, path.join(stageRoot, theme.image), "Staged theme image");
// Write both files from the already-open, stable descriptors. The caller
// publishes the image first and theme.json last, so the watcher only ever
// observes a complete pair; subsequent source edits cannot race the copy.
await writeExclusive(path.join(stageRoot, theme.image), image.bytes);
await writeExclusive(path.join(stageRoot, "theme.json"), config.bytes);
process.stdout.write(theme.image);
}
await main();
Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

@@ -0,0 +1,92 @@
import assert from "node:assert/strict";
import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import {
MAX_IMAGE_DIMENSION,
MAX_IMAGE_PIXELS,
classifyImageDimensions,
readImageMetadata,
} from "../scripts/image-metadata.mjs";
const here = path.dirname(fileURLToPath(import.meta.url));
const macosRoot = path.resolve(here, "..");
const fixture = await fs.readFile(path.join(macosRoot, "tests", "fixtures", "background.jpg"));
assert.deepEqual(readImageMetadata(fixture, ".jpg"), {
width: 1920,
height: 1200,
ratio: 1.6,
wide: false,
aspect: "wide",
taskMode: "ambient",
});
const malformedJpeg = Buffer.from(fixture);
malformedJpeg[0] = 0;
assert.equal(readImageMetadata(malformedJpeg, ".jpg"), null);
assert.deepEqual(classifyImageDimensions({ width: 2400, height: 1350 }), {
width: 2400,
height: 1350,
ratio: 2400 / 1350,
wide: true,
aspect: "wide",
taskMode: "ambient",
});
assert.equal(MAX_IMAGE_DIMENSION, 16384);
assert.equal(MAX_IMAGE_PIXELS, 50_000_000);
assert.equal(classifyImageDimensions({ width: 10000, height: 6000 }), null);
assert.equal(classifyImageDimensions({ width: 20000, height: 1 }), null);
assert.equal(classifyImageDimensions({ width: 2560.5, height: 1440 }), null);
const writeAscii = (bytes, offset, value) => {
for (let index = 0; index < value.length; index += 1) bytes[offset + index] = value.charCodeAt(index);
};
const writeUint32Le = (bytes, offset, value) => {
bytes[offset] = value & 0xff;
bytes[offset + 1] = (value >>> 8) & 0xff;
bytes[offset + 2] = (value >>> 16) & 0xff;
bytes[offset + 3] = (value >>> 24) & 0xff;
};
const writeUint24Le = (bytes, offset, value) => {
bytes[offset] = value & 0xff;
bytes[offset + 1] = (value >>> 8) & 0xff;
bytes[offset + 2] = (value >>> 16) & 0xff;
};
const vp8l = new Uint8Array(26);
writeAscii(vp8l, 0, "RIFF");
writeUint32Le(vp8l, 4, vp8l.length - 8);
writeAscii(vp8l, 8, "WEBP");
writeAscii(vp8l, 12, "VP8L");
writeUint32Le(vp8l, 16, 5);
vp8l.set([0x2f, 0x7f, 0xc2, 0x59, 0x00], 20);
assert.deepEqual(readImageMetadata(vp8l, ".webp"), {
width: 640,
height: 360,
ratio: 640 / 360,
wide: true,
aspect: "wide",
taskMode: "ambient",
});
const vp8x = new Uint8Array(30);
writeAscii(vp8x, 0, "RIFF");
writeUint32Le(vp8x, 4, vp8x.length - 8);
writeAscii(vp8x, 8, "WEBP");
writeAscii(vp8x, 12, "VP8X");
writeUint32Le(vp8x, 16, 10);
writeUint24Le(vp8x, 24, 2559);
writeUint24Le(vp8x, 27, 1439);
assert.deepEqual(readImageMetadata(vp8x, ".webp"), {
width: 2560,
height: 1440,
ratio: 2560 / 1440,
wide: true,
aspect: "wide",
taskMode: "ambient",
});
assert.equal(readImageMetadata(new Uint8Array([0, 1, 2, 3]), ".png"), null);
console.log("PASS: image dimensions strictly classify PNG, JPEG, VP8L, and VP8X profiles.");
@@ -0,0 +1,228 @@
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.");
@@ -0,0 +1,613 @@
import assert from "node:assert/strict";
import fs from "node:fs/promises";
import path from "node:path";
import vm from "node:vm";
import { fileURLToPath } from "node:url";
const here = path.dirname(fileURLToPath(import.meta.url));
const macosRoot = path.resolve(here, "..");
const template = await fs.readFile(path.join(macosRoot, "assets", "renderer-inject.js"), "utf8");
const css = await fs.readFile(path.join(macosRoot, "assets", "dream-skin.css"), "utf8");
assert.doesNotMatch(
css,
/background-image:\s*var\(--dream-skin-art\),\s*var\(--dream-skin-art\)/,
"The home hero must not stack duplicate copies of the selected image.",
);
assert.match(
css,
/data-dream-art-safe="left"[\s\S]{0,140}--ds-art-position:\s*100% var\(--ds-focus-y\);/,
"A left text-safe image must preserve its right-side subject on narrower windows.",
);
assert.match(
css,
/\.dream-skin-home > div:has\(\[data-feature="game-source"\]\) > div:first-child > div:first-child\s*\{[\s\S]{0,700}background-position:\s*var\(--ds-home-art-position\) !important;/,
"The home hero must use its route-specific focal point.",
);
assert.match(
css,
/\.dream-skin-home > div:has\(\[data-feature="game-source"\]\)\s*\{[\s\S]{0,120}min-height:\s*100% !important;/,
"The home layout must target the content container instead of an inserted banner sibling.",
);
assert.doesNotMatch(
css,
/\.dream-skin-home > div:first-child\s*\{[\s\S]{0,120}min-height:\s*100% !important;/,
"An empty home banner container must not push the composer below the viewport.",
);
assert.doesNotMatch(
css,
/background-size:\s*auto 100% !important;/,
"Wide home artwork must not leave an unpainted half-card by fitting only to height.",
);
assert.doesNotMatch(
css,
/background-size:\s*100% 100%,\s*100% 100%,\s*100% auto;/,
"Wide task artwork must cover the full route instead of ending above the composer.",
);
assert.match(
css,
/data-dream-art-task-mode="ambient"[\s\S]{0,500}body\s*\{[\s\S]{0,500}background-image:\s*var\(--dream-skin-art\) !important;[\s\S]{0,200}background-size:\s*cover !important;/,
"Wide ambient task artwork should cover the full application window.",
);
assert.match(
css,
/data-dream-task-mode="banner"[\s\S]{0,900}body\s*\{[\s\S]{0,500}background-image:\s*var\(--dream-skin-art\) !important;[\s\S]{0,200}background-size:\s*cover !important;/,
"Wide banner task artwork should use the same full-window wallpaper contract as ambient routes.",
);
assert.match(
css,
/data-dream-art-wide="true"\]:has\(main\.main-surface\.dream-skin-home-shell\)[\s\S]{0,100}body\s*\{[\s\S]{0,300}background-image:\s*var\(--dream-skin-art\) !important;/,
"Wide home artwork should use the same full-window image as utility routes.",
);
assert.match(
css,
/data-dream-art-wide="true"\]:has\(main\.main-surface\.dream-skin-home-shell\)[\s\S]{0,120}body\s*\{[\s\S]{0,260}background-position:\s*var\(--ds-art-position\) !important;/,
"Wide home artwork must honor the configured focal point instead of forcing a centered crop.",
);
assert.match(
css,
/data-dream-art-task-mode="ambient"[\s\S]{0,260}data-dream-art-wide="true"\]:has\(main\.main-surface:not\(\.dream-skin-home-shell\)\)[\s\S]{0,120}body\s*\{[\s\S]{0,260}background-position:\s*var\(--ds-art-position\) !important;/,
"Wide task artwork must retain the same focal point as the home route.",
);
assert.match(
css,
/data-dream-art-wide="true"\]\s+\.composer-surface-chrome\s*\{[\s\S]{0,500}backdrop-filter:\s*none !important;/,
"Wide artwork should use one uniform composer surface without a split blur layer.",
);
assert.match(
css,
/--ds-immersive-composer-solid:\s*rgb\(var\(--ds-panel-rgb\) \/ \.94\);/,
"The light composer should remain opaque enough to prevent thread text from showing through.",
);
assert.match(
css,
/--ds-task-immersive-edge:\s*rgb\(var\(--ds-panel-rgb\) \/ calc\(\.86 \* var\(--ds-surface-opacity\)\)\);/,
"Wide task surfaces should expose the wallpaper according to the configured opacity.",
);
assert.match(
css,
/--ds-task-fade:\s*linear-gradient\(180deg,[\s\S]{0,400}var\(--ds-surface-opacity\)/,
"Regular landscape artwork should apply the configured opacity to its task scrim.",
);
assert.match(
css,
/main\.main-surface:not\(\.dream-skin-home-shell\)::before\s*\{[\s\S]{0,420}opacity:\s*1;/,
"The wallpaper layer must keep its original colors instead of blending through a white base.",
);
const homeSuggestionRuleStart = css.indexOf(
".dream-skin-home .group\\/home-suggestions button {",
);
assert.notEqual(homeSuggestionRuleStart, -1, "The home suggestion card rule must exist.");
const homeSuggestionRuleEnd = css.indexOf("}", homeSuggestionRuleStart);
const homeSuggestionRule = css.slice(homeSuggestionRuleStart, homeSuggestionRuleEnd);
assert.match(
homeSuggestionRule,
/background:\s*rgb\(var\(--ds-panel-rgb\) \/ calc\(\.56 \* var\(--ds-surface-opacity\)\)\) !important;/,
"Home suggestion cards must use the shared surface opacity instead of a fixed white layer.",
);
assert.match(
css,
/--ds-composer-surface:\s*rgb\(var\(--ds-panel-rgb\) \/ calc\(\.82 \+ \(\.12 \* var\(--ds-surface-opacity\)\)\)\);/,
"The composer must keep a readable minimum opacity while responding to the skin setting.",
);
assert.match(
css,
/html\.codex-dream-skin \.composer-surface-chrome\s*\{[\s\S]{0,100}overflow:\s*hidden !important;/,
"Composer internals must be clipped to the rounded input surface.",
);
assert.match(
css,
/data-dream-shell="light"\]\[data-dream-art-wide="true"\][\s\S]{0,100}\.composer-surface-chrome\s*\{[\s\S]{0,400}backdrop-filter:\s*blur\(8px\) saturate\(102%\) !important;/,
"The translucent light composer should softly separate text from detailed artwork.",
);
assert.match(
template,
/\[class\*="_homeUtilityBar_"\][\s\S]{0,500}dream-skin-home-utility/,
"The renderer should give the current native home utility bar a stable theme class.",
);
assert.match(
template,
/\[data-feature="game-source"\][\s\S]{0,180}\[data-codex-composer-root\]/,
"Home detection should use stable semantic markers instead of optional suggestion cards.",
);
assert.match(
css,
/\.dream-skin-home:has\(\.dream-skin-home-utility\)[\s\S]{0,120}\.composer-surface-chrome\s*\{[\s\S]{0,180}border-radius:\s*0 0 22px 22px !important;/,
"The home utility bar and composer should render as one continuous control.",
);
assert.match(
css,
/\.composer-surface-chrome button:not\(\[class~="bg-token-foreground"\]\)[\s\S]{0,100}color:\s*var\(--ds-muted\) !important;/,
"Composer controls must remain readable when Codex native tokens lag behind a forced dark appearance.",
);
assert.match(
css,
/\.composer-surface-chrome button:not\(\[class~="bg-token-foreground"\]\) \*\s*\{[\s\S]{0,80}color:\s*currentColor !important;/,
"Nested labels inside composer controls must inherit the corrected theme color.",
);
assert.match(
css,
/\.composer-surface-chrome p\.placeholder::after\s*\{[\s\S]{0,120}color:\s*rgb\(var\(--ds-muted-rgb\) \/ \.82\) !important;[\s\S]{0,80}opacity:\s*1 !important;/,
"Composer placeholder text must not inherit a stale native color with double opacity.",
);
assert.match(
css,
/header\.app-header-tint\s*\{[\s\S]{0,180}background:\s*transparent !important;/,
"Wide artwork should not paint a separate opaque header band.",
);
assert.match(
css,
/\.thread-scroll-container \.bg-gradient-to-t\.from-token-main-surface-primary\s*\{[\s\S]{0,100}background:\s*transparent !important;/,
"Wide artwork should remove the native opaque fade behind the sticky composer.",
);
assert.match(
css,
/div\.sticky:has\(input\[type="text"\]\)[\s\S]{0,100}background:\s*transparent !important;/,
"Search routes should not retain the native opaque sticky band.",
);
assert.match(
css,
/\[class~="bg-token-main-surface-primary"\]\[class~="h-full"\]\[class~="w-full"\][\s\S]{0,100}background:\s*transparent !important;/,
"Full-size utility route wrappers should not hide the selected artwork.",
);
function createStyleDeclaration() {
const values = new Map();
return {
values,
getPropertyValue(name) { return values.get(name) ?? ""; },
setProperty(name, value) { values.set(name, value); },
removeProperty(name) { values.delete(name); },
};
}
function createClassList(initial = []) {
const values = new Set(initial);
return {
values,
add(...names) { for (const name of names) values.add(name); },
remove(...names) { for (const name of names) values.delete(name); },
contains(name) { return values.has(name); },
toggle(name, enabled) {
if (enabled) values.add(name);
else values.delete(name);
},
};
}
function createFixture(theme, {
nativeShell = "light",
analysisFixture = null,
analysisCache = null,
} = {}) {
let fixtureShell = nativeShell;
const nodes = new Map();
const attributes = new Map();
const bodyAttributes = new Map();
const observers = [];
const resizeObservers = [];
const timers = new Map();
let nextTimer = 1;
let nextBlob = 1;
const rootStyle = createStyleDeclaration();
const root = {
className: nativeShell === "dark" ? "electron-dark" : "electron-light",
classList: createClassList(),
style: rootStyle,
appendChild(node) {
node.parentElement = root;
if (node.id) nodes.set(node.id, node);
},
getAttribute(name) { return attributes.get(name) ?? null; },
setAttribute(name, value) { attributes.set(name, String(value)); },
removeAttribute(name) { attributes.delete(name); },
};
const body = {
className: "",
appendChild(node) {
node.parentElement = body;
if (node.id) nodes.set(node.id, node);
},
getAttribute(name) { return bodyAttributes.get(name) ?? null; },
setAttribute(name, value) { bodyAttributes.set(name, String(value)); },
};
const shellBox = { left: 280, top: 36, width: 1000, height: 764 };
const shellMain = {
classList: createClassList(),
getBoundingClientRect() {
return { ...shellBox };
},
};
const createElement = (tagName) => {
if (tagName === "canvas" && analysisFixture) {
return {
width: 0,
height: 0,
getContext() {
return {
drawImage() {},
getImageData() { return { data: analysisFixture.pixels }; },
};
},
};
}
const childNodes = new Map();
const element = {
id: "",
dataset: {},
style: createStyleDeclaration(),
classList: createClassList(),
parentElement: null,
textContent: "",
innerHTML: "",
setAttribute() {},
querySelector(selector) {
if (!childNodes.has(selector)) childNodes.set(selector, { textContent: "" });
return childNodes.get(selector);
},
remove() { if (element.id) nodes.delete(element.id); },
};
return element;
};
const document = {
documentElement: root,
head: root,
body,
createElement,
getElementById(id) { return nodes.get(id) ?? null; },
querySelector(selector) {
if (selector === "main.main-surface" || selector === "main") return shellMain;
return null;
},
querySelectorAll() { return []; },
};
const mediaQuery = {
matches: false,
addEventListener() {},
removeEventListener() {},
};
const revokedUrls = [];
const window = {
addEventListener() {},
removeEventListener() {},
matchMedia() {
mediaQuery.matches = fixtureShell === "dark";
return mediaQuery;
},
};
if (analysisCache) window.__CODEX_DREAM_SKIN_ANALYSIS_CACHE__ = analysisCache;
if (analysisFixture) {
window.Image = class {
naturalWidth = analysisFixture.naturalWidth;
naturalHeight = analysisFixture.naturalHeight;
set src(_) { this.onload(); }
};
}
const context = {
window,
document,
MutationObserver: class {
constructor(callback) {
this.callback = callback;
observers.push(this);
}
observe() {}
disconnect() {}
},
ResizeObserver: class {
constructor(callback) {
this.callback = callback;
this.target = null;
resizeObservers.push(this);
}
observe(target) { this.target = target; }
disconnect() { this.target = null; }
},
URL: {
createObjectURL() { return `blob:fixture-${nextBlob++}`; },
revokeObjectURL(value) { revokedUrls.push(value); },
},
Blob,
Uint8Array,
atob,
getComputedStyle() {
const skinShell = root.classList.contains("codex-dream-skin")
? (attributes.get("data-dream-shell") || "dark") : fixtureShell;
return {
colorScheme: skinShell,
backgroundColor: fixtureShell === "dark" ? "rgb(24, 24, 27)" : "rgb(250, 250, 250)",
};
},
setInterval: () => 1,
clearInterval() {},
setTimeout(callback, delay) {
const id = ++nextTimer;
timers.set(id, { callback, delay });
return id;
},
clearTimeout(id) { timers.delete(id); },
cancelAnimationFrame() {},
};
const payloadFor = (nextTheme, cssText = ".fixture { color: blue; }") => template
.replace("__DREAM_SKIN_CSS_JSON__", JSON.stringify(cssText))
.replace("__DREAM_SKIN_ART_JSON__", JSON.stringify("data:image/png;base64,AA=="))
.replace("__DREAM_SKIN_THEME_JSON__", JSON.stringify(nextTheme))
.replace("__DREAM_SKIN_VERSION_JSON__", JSON.stringify("test"))
.replace("__DREAM_SKIN_STYLE_REVISION_JSON__", JSON.stringify(cssText));
const flushTimers = (maximumDelay = Infinity) => {
const pending = [...timers.entries()].filter(([, timer]) => timer.delay <= maximumDelay);
for (const [id, timer] of pending) {
timers.delete(id);
timer.callback();
}
};
return {
attributes,
body,
bodyAttributes,
context,
flushTimers,
nodes,
observers,
payload: payloadFor(theme),
payloadFor,
revokedUrls,
resizeObservers,
root,
rootStyle,
shellBox,
timers,
window,
setNativeShell(value) { fixtureShell = value; },
};
}
const defaults = createFixture({
id: "default-contract",
appearance: "auto",
art: { safeArea: "auto", taskMode: "auto" },
});
const defaultResult = vm.runInNewContext(defaults.payload, defaults.context);
assert.equal(defaultResult.installed, true);
assert.equal(defaults.attributes.get("data-dream-shell"), "light");
assert.equal(defaults.attributes.get("data-dream-art-safe-area"), "center");
assert.equal(defaults.attributes.get("data-dream-art-task-mode"), "ambient");
assert.equal(defaults.attributes.get("data-dream-art-ready"), "false");
assert.equal(defaults.rootStyle.values.get("--dream-art-position"), "50.00% 50.00%");
assert.equal(defaults.rootStyle.values.get("--ds-surface-opacity"), "1.00");
const defaultMetrics = defaults.window.__CODEX_DREAM_SKIN_STATE__.metrics;
assert.equal(defaultMetrics.rootPasses, 1);
assert.equal(defaultMetrics.routePasses, 1);
assert.equal(defaultMetrics.layoutReads, 1);
for (let index = 0; index < 50; index += 1) defaults.observers[0].callback([]);
assert.equal(defaults.timers.size, 1, "Mutation bursts should coalesce into one scheduled ensure.");
defaults.flushTimers(64);
assert.equal(defaultMetrics.rootPasses, 1, "Subtree mutations must not recompute root theme tokens.");
assert.equal(defaultMetrics.routePasses, 2);
assert.equal(defaultMetrics.layoutReads, 1, "Subtree mutations must not force shell layout reads.");
assert.equal(defaults.resizeObservers.length, 1);
assert.ok(defaults.resizeObservers[0].target);
defaults.shellBox.left = 196;
defaults.shellBox.width = 1084;
defaults.resizeObservers[0].callback([]);
defaults.flushTimers(64);
assert.equal(defaultMetrics.layoutReads, 2, "Shell ResizeObserver changes must refresh chrome geometry.");
const defaultChrome = defaults.nodes.get("codex-dream-skin-chrome");
assert.equal(defaultChrome.style.values.get("left"), "196px");
assert.equal(defaultChrome.style.values.get("width"), "1084px");
// Auto appearance must continue following the native shell after the skin is
// already installed. The fixture makes the injected root color-scheme win
// whenever our class remains on <html>, so a temporary native probe is needed
// for each light → dark → light transition.
const shellFollow = createFixture({
id: "shell-follow",
appearance: "auto",
art: { safeArea: "auto", taskMode: "auto" },
});
shellFollow.root.className = "";
vm.runInNewContext(shellFollow.payload, shellFollow.context);
assert.equal(shellFollow.attributes.get("data-dream-shell"), "light");
shellFollow.setNativeShell("dark");
shellFollow.window.__CODEX_DREAM_SKIN_STATE__.ensure();
assert.equal(shellFollow.attributes.get("data-dream-shell"), "dark");
shellFollow.setNativeShell("light");
shellFollow.window.__CODEX_DREAM_SKIN_STATE__.ensure();
assert.equal(shellFollow.attributes.get("data-dream-shell"), "light");
defaults.root.className = "";
defaults.body.setAttribute("data-theme", "dark");
defaults.observers[1].callback([{ type: "attributes", target: defaults.body }]);
defaults.flushTimers(64);
assert.equal(defaults.attributes.get("data-dream-shell"), "dark", "Body theme changes must apply without the fallback interval.");
const synchronousWide = createFixture({
id: "synchronous-wide",
appearance: "auto",
art: { safeArea: "auto", taskMode: "auto" },
artKey: "wide-art",
artMetadata: {
width: 2400,
height: 1350,
ratio: 2400 / 1350,
wide: true,
aspect: "wide",
taskMode: "ambient",
},
});
vm.runInNewContext(synchronousWide.payload, synchronousWide.context);
assert.equal(synchronousWide.attributes.get("data-dream-art-wide"), "true");
assert.equal(synchronousWide.attributes.get("data-dream-art-aspect"), "wide");
assert.equal(synchronousWide.attributes.get("data-dream-art-task-mode"), "ambient");
assert.equal(synchronousWide.attributes.get("data-dream-art-ready"), "false");
const autoLandscape = createFixture({
id: "auto-landscape-focus",
appearance: "auto",
art: { safeArea: "auto", taskMode: "auto" },
artMetadata: {
width: 1586,
height: 992,
ratio: 1586 / 992,
wide: false,
aspect: "wide",
taskMode: "ambient",
},
});
vm.runInNewContext(autoLandscape.payload, autoLandscape.context);
assert.equal(autoLandscape.rootStyle.values.get("--dream-art-position"), "50.00% 50.00%");
assert.equal(
autoLandscape.rootStyle.values.get("--dream-skin-home-art-position"),
"50.00% 28.00%",
"An unconfigured landscape should keep upper subjects visible in the much wider home hero.",
);
const cachedAnalysis = {
width: 2400,
height: 1350,
ratio: 2400 / 1350,
wide: true,
aspect: "wide",
taskMode: "ambient",
safeArea: "left",
focusX: 0.72,
focusY: 0.48,
accentRgb: { r: 180, g: 90, b: 110 },
};
const cached = createFixture({
id: "cached-wide",
appearance: "auto",
art: { safeArea: "auto", taskMode: "auto" },
artKey: "cached-art",
artMetadata: synchronousWide.window.__CODEX_DREAM_SKIN_STATE__.artMetadata,
}, { analysisCache: new Map([["cached-art", cachedAnalysis]]) });
vm.runInNewContext(cached.payload, cached.context);
assert.equal(cached.attributes.get("data-dream-art-ready"), "true");
assert.equal(cached.attributes.get("data-dream-art-safe-area"), "left");
assert.equal(cached.window.__CODEX_DREAM_SKIN_STATE__.metrics.analysisCacheHits, 1);
assert.equal(cached.window.__CODEX_DREAM_SKIN_STATE__.metrics.analysisRuns, 0);
const previousWideState = synchronousWide.window.__CODEX_DREAM_SKIN_STATE__;
const stableStyle = synchronousWide.nodes.get("codex-dream-skin-style");
vm.runInNewContext(synchronousWide.payloadFor({
id: "switched-wide",
appearance: "dark",
art: { safeArea: "right", taskMode: "ambient" },
artKey: "switched-art",
artMetadata: {
width: 2400,
height: 1350,
ratio: 2400 / 1350,
wide: true,
aspect: "wide",
taskMode: "ambient",
},
}, ".fixture { color: red; }"), synchronousWide.context);
assert.equal(synchronousWide.nodes.get("codex-dream-skin-style"), stableStyle);
assert.equal(stableStyle.textContent, ".fixture { color: red; }");
assert.equal(stableStyle.dataset.dreamSkinVersion, "test");
assert.equal(synchronousWide.rootStyle.values.get("--dream-skin-art"), 'url("blob:fixture-2")');
assert.deepEqual(synchronousWide.revokedUrls, ["blob:fixture-1"]);
assert.equal(previousWideState.cleanup(), false, "An old async cleanup must not remove the new theme.");
const brightPixels = new Uint8ClampedArray(96 * 32 * 4);
for (let offset = 0; offset < brightPixels.length; offset += 4) {
brightPixels[offset] = 245;
brightPixels[offset + 1] = 224;
brightPixels[offset + 2] = 224;
brightPixels[offset + 3] = 255;
}
const nativeDark = createFixture({
id: "native-dark-contract",
appearance: "auto",
art: { safeArea: "auto", taskMode: "auto" },
}, {
nativeShell: "dark",
analysisFixture: { naturalWidth: 2400, naturalHeight: 800, pixels: brightPixels },
});
vm.runInNewContext(nativeDark.payload, nativeDark.context);
await Promise.resolve();
await Promise.resolve();
nativeDark.window.__CODEX_DREAM_SKIN_STATE__.ensure();
assert.equal(nativeDark.window.__CODEX_DREAM_SKIN_STATE__.analysis.shell, "light");
assert.equal(nativeDark.attributes.get("data-dream-shell"), "dark");
assert.match(nativeDark.rootStyle.values.get("--ds-bg"), /^#[0-9a-f]{6}$/);
assert.ok(Number.parseInt(nativeDark.rootStyle.values.get("--ds-bg").slice(1), 16) < 0x303030);
const explicit = createFixture({
id: "explicit-contract",
appearance: "dark",
surfaceOpacity: 0.55,
art: { focusX: 0.15, focusY: 0.8, safeArea: "none", taskMode: "off" },
});
const explicitResult = vm.runInNewContext(explicit.payload, explicit.context);
assert.equal(explicitResult.shell, "dark");
assert.equal(explicit.attributes.get("data-dream-shell"), "dark");
assert.equal(explicit.attributes.get("data-dream-art-safe-area"), "none");
assert.equal(explicit.attributes.get("data-dream-art-safe"), "none");
assert.equal(explicit.attributes.get("data-dream-art-task-mode"), "off");
assert.equal(explicit.rootStyle.values.get("--dream-art-position"), "15.00% 80.00%");
assert.equal(explicit.rootStyle.values.get("--dream-skin-home-art-position"), "15.00% 80.00%");
assert.equal(explicit.rootStyle.values.get("--ds-surface-opacity"), "0.55");
assert.equal(explicit.window.__CODEX_DREAM_SKIN_STATE__.analysis, null);
const banner = createFixture({
id: "banner-contract",
appearance: "auto",
art: { safeArea: "left", taskMode: "banner" },
artMetadata: {
width: 2560,
height: 1440,
ratio: 2560 / 1440,
wide: true,
aspect: "ultrawide",
taskMode: "banner",
safeArea: "left",
focusX: 0.72,
focusY: 0.44,
},
});
vm.runInNewContext(banner.payload, banner.context);
assert.equal(banner.attributes.get("data-dream-art-wide"), "true");
assert.equal(banner.attributes.get("data-dream-art-task-mode"), "banner");
assert.equal(banner.attributes.get("data-dream-task-mode"), "banner");
assert.equal(explicit.window.__CODEX_DREAM_SKIN_STATE__.cleanup(), true);
assert.equal(explicit.root.classList.contains("codex-dream-skin"), false);
assert.equal(explicit.attributes.has("data-dream-shell"), false);
assert.equal(explicit.attributes.has("data-dream-art-safe-area"), false);
assert.equal(explicit.attributes.has("data-dream-art-task-mode"), false);
assert.equal(explicit.rootStyle.values.has("--dream-art-position"), false);
assert.equal(explicit.nodes.has("codex-dream-skin-style"), false);
assert.equal(explicit.nodes.has("codex-dream-skin-chrome"), false);
assert.deepEqual(explicit.revokedUrls, ["blob:fixture-1"]);
await Promise.resolve();
await Promise.resolve();
assert.equal(explicit.root.classList.contains("codex-dream-skin"), false);
assert.equal(explicit.nodes.has("codex-dream-skin-style"), false);
assert.equal(explicit.window.__CODEX_DREAM_SKIN_STATE__, undefined);
console.log("PASS: renderer honors adaptive art metadata, fallback, and cleanup behavior.");
@@ -0,0 +1,4 @@
await import("./image-metadata.test.mjs");
await import("./injector-bootstrap.test.mjs");
await import("./renderer-inject.test.mjs");
await import("./theme-stage.test.mjs");
@@ -0,0 +1,85 @@
import assert from "node:assert/strict";
import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { spawn } from "node:child_process";
const here = path.dirname(fileURLToPath(import.meta.url));
const macosRoot = path.resolve(here, "..");
const stageScript = path.join(macosRoot, "scripts", "stage-theme.mjs");
const fixtureAsset = path.join(macosRoot, "tests", "fixtures", "background.jpg");
const tempRoot = await fs.mkdtemp(path.join("/tmp", "codex-dream-skin-stage-"));
function runStage(source, stage) {
return new Promise((resolve, reject) => {
const child = spawn(process.execPath, [stageScript, source, stage], {
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
child.stdout.on("data", (chunk) => { stdout += chunk; });
child.stderr.on("data", (chunk) => { stderr += chunk; });
child.once("error", reject);
child.once("close", (code) => {
if (code === 0) resolve(stdout);
else reject(new Error(stderr || `stage-theme exited with ${code}`));
});
});
}
try {
const source = path.join(tempRoot, "themes", "preset-race");
const stage = path.join(tempRoot, "stage");
await fs.mkdir(source, { recursive: true });
await fs.mkdir(stage);
await fs.copyFile(fixtureAsset, path.join(source, "background-a.png"));
await fs.writeFile(
path.join(source, "theme.json"),
`${JSON.stringify({ schemaVersion: 1, id: "preset-race", name: "A", image: "background-a.png" })}\n`,
);
const imageName = await runStage(source, stage);
assert.equal(imageName, "background-a.png");
const stagedConfig = JSON.parse(await fs.readFile(path.join(stage, "theme.json"), "utf8"));
assert.equal(stagedConfig.image, "background-a.png");
const stagedBeforeMutation = await fs.readFile(path.join(stage, "background-a.png"));
// A source edit after staging must not change the pair that is about to be
// published. This is the regression for switch-theme's old copy-after-
// validation TOCTOU window.
await fs.copyFile(fixtureAsset, path.join(source, "background-b.png"));
await fs.writeFile(
path.join(source, "theme.json"),
`${JSON.stringify({ schemaVersion: 1, id: "preset-race", name: "B", image: "background-b.png" })}\n`,
);
await fs.writeFile(path.join(source, "background-a.png"), Buffer.from("changed-after-stage"));
assert.deepEqual(await fs.readFile(path.join(stage, "background-a.png")), stagedBeforeMutation);
assert.equal(JSON.parse(await fs.readFile(path.join(stage, "theme.json"), "utf8")).name, "A");
const outside = path.join(tempRoot, "outside.png");
await fs.copyFile(fixtureAsset, outside);
const traversal = path.join(tempRoot, "traversal");
await fs.mkdir(traversal);
await fs.writeFile(
path.join(traversal, "theme.json"),
`${JSON.stringify({ schemaVersion: 1, id: "bad", image: "../outside.png" })}\n`,
);
const traversalStage = path.join(tempRoot, "traversal-stage");
await fs.mkdir(traversalStage);
await assert.rejects(runStage(traversal, traversalStage), /inside its theme directory/);
const symlink = path.join(tempRoot, "symlink");
await fs.mkdir(symlink);
await fs.symlink(outside, path.join(symlink, "background.png"));
await fs.writeFile(
path.join(symlink, "theme.json"),
`${JSON.stringify({ schemaVersion: 1, id: "bad-link", image: "background.png" })}\n`,
);
const symlinkStage = path.join(tempRoot, "symlink-stage");
await fs.mkdir(symlinkStage);
await assert.rejects(runStage(symlink, symlinkStage), /symbolic link/);
console.log("PASS: theme staging snapshots a matched, contained config/image pair.");
} finally {
await fs.rm(tempRoot, { recursive: true, force: true });
}
@@ -0,0 +1,186 @@
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[ValidateSet('inspect', 'selectPort', 'launch', 'stopCodex', 'launchNormal', 'verifyPort', 'processInfo', 'injectorStatus', 'stopInjector')]
[string]$Action,
[int]$Port = 9341,
[int]$TargetPid = 0,
[string]$StatePath
)
$ErrorActionPreference = 'Stop'
$ProgressPreference = 'SilentlyContinue'
. (Join-Path $PSScriptRoot 'common-windows.ps1')
function Write-CodexxJson {
param([Parameter(Mandatory = $true)][object]$Value)
$Value | ConvertTo-Json -Depth 8 -Compress
}
function Get-CodexxState {
if (-not $StatePath -or -not (Test-Path -LiteralPath $StatePath -PathType Leaf)) {
throw 'Codex-X skin state file is missing.'
}
return Get-Content -LiteralPath $StatePath -Raw -Encoding UTF8 | ConvertFrom-Json -ErrorAction Stop
}
function Get-CodexxDebugIdentity {
param([Parameter(Mandatory = $true)][object]$Codex)
foreach ($process in @(Get-DreamSkinCodexProcesses -Codex $Codex)) {
$commandLine = "$($process.CommandLine)"
foreach ($match in [regex]::Matches($commandLine, '(?i)(?:^|\s)--remote-debugging-port(?:=|\s+)(?<port>[0-9]{4,5})(?=$|\s)')) {
$candidate = [int]$match.Groups['port'].Value
if ($candidate -lt 1024 -or $candidate -gt 65535) { continue }
$identity = Get-DreamSkinVerifiedCdpIdentity -Port $candidate -Codex $Codex
if ($null -ne $identity) {
return [pscustomobject]@{ Port = $candidate; Identity = $identity }
}
}
}
return $null
}
function Test-CodexxInjectorState {
param([Parameter(Mandatory = $true)][object]$State)
if (-not $State.injectorPid -or -not $State.injectorStartedAt -or
-not $State.injectorPath -or -not $State.nodePath -or -not $State.port) {
return $false
}
$processId = [int]$State.injectorPid
$process = Get-CimInstance Win32_Process -Filter "ProcessId = $processId" -ErrorAction SilentlyContinue
if ($null -eq $process) { return $false }
$processPath = Get-DreamSkinProcessExecutablePath -ProcessInfo $process
$commandLine = "$($process.CommandLine)"
$startedAt = Get-DreamSkinProcessStartedAt -ProcessId $processId
$portPattern = '(?i)(?:^|\s)--port(?:=|\s+)' + [regex]::Escape("$($State.port)") + '(?=$|\s)'
return [bool](
$processPath -and
([System.IO.Path]::GetFileName($processPath) -ieq 'node.exe') -and
(Test-DreamSkinPathEqual -Left $processPath -Right "$($State.nodePath)") -and
(Test-DreamSkinCommandLineToken -CommandLine $commandLine -Token "$($State.injectorPath)") -and
(Test-DreamSkinCommandLineToken -CommandLine $commandLine -Token '--watch') -and
[regex]::IsMatch($commandLine, $portPattern) -and
$startedAt -eq "$($State.injectorStartedAt)"
)
}
$operationLock = Enter-DreamSkinOperationLock
try {
switch ($Action) {
'inspect' {
$codex = Get-DreamSkinCodexInstall
$processes = @(Get-DreamSkinCodexProcesses -Codex $codex)
$debug = Get-CodexxDebugIdentity -Codex $codex
$watcherPids = @(Get-CimInstance Win32_Process -Filter "Name = 'node.exe'" -ErrorAction SilentlyContinue |
Where-Object {
$_.CommandLine -and
[regex]::IsMatch("$($_.CommandLine)", '(?i)(?:^|\s)--watch(?=$|\s)') -and
"$($_.CommandLine)".IndexOf('injector.mjs', [System.StringComparison]::OrdinalIgnoreCase) -ge 0
} | ForEach-Object { [int]$_.ProcessId })
Write-CodexxJson ([pscustomobject]@{
packageRoot = "$($codex.PackageRoot)"
executable = "$($codex.Executable)"
version = "$($codex.Version)"
packageFullName = "$($codex.PackageFullName)"
packageFamilyName = "$($codex.PackageFamilyName)"
appUserModelId = "$($codex.AppUserModelId)"
running = $processes.Count -gt 0
debugPort = if ($null -eq $debug) { $null } else { [int]$debug.Port }
browserId = if ($null -eq $debug) { $null } else { "$($debug.Identity.BrowserId)" }
watcherPids = $watcherPids
})
break
}
'selectPort' {
Assert-DreamSkinPort -Port $Port
Write-CodexxJson ([pscustomobject]@{ port = (Select-DreamSkinPort -PreferredPort $Port) })
break
}
'launch' {
Assert-DreamSkinPort -Port $Port
$codex = Get-DreamSkinCodexInstall
$preserved = @(Get-DreamSkinCodexProcesses -Codex $codex | ForEach-Object { [int]$_.ProcessId })
$arguments = @('--remote-debugging-address=127.0.0.1', "--remote-debugging-port=$Port")
try {
$launch = Start-DreamSkinCodexForDebugging -Codex $codex -Arguments $arguments `
-Port $Port -PreserveProcessIds $preserved
$deadline = (Get-Date).AddSeconds(45)
$identity = Get-DreamSkinVerifiedCdpIdentity -Port $Port -Codex $codex
while ($null -eq $identity -and (Get-Date) -lt $deadline) {
Start-Sleep -Milliseconds 350
$identity = Get-DreamSkinVerifiedCdpIdentity -Port $Port -Codex $codex
}
if ($null -eq $identity) {
throw "Codex did not expose a verified loopback CDP endpoint on port $Port within 45 seconds."
}
Write-CodexxJson ([pscustomobject]@{
port = $Port
browserId = "$($identity.BrowserId)"
strategy = "$($launch.Strategy)"
})
} catch {
try { Stop-DreamSkinCodex -Codex $codex -PreserveProcessIds $preserved -AllowForce } catch {}
if ($preserved.Count -eq 0 -and (Get-DreamSkinCodexProcesses -Codex $codex).Count -eq 0) {
try { $null = Start-DreamSkinCodex -Codex $codex } catch {}
}
throw
}
break
}
'stopCodex' {
$codex = Get-DreamSkinCodexInstall
Stop-DreamSkinCodex -Codex $codex -AllowForce
Write-CodexxJson ([pscustomobject]@{ stopped = $true })
break
}
'launchNormal' {
$codex = Get-DreamSkinCodexInstall
$processId = Start-DreamSkinCodex -Codex $codex
Write-CodexxJson ([pscustomobject]@{ processId = [int]$processId })
break
}
'verifyPort' {
Assert-DreamSkinPort -Port $Port
$codex = Get-DreamSkinCodexInstall
$identity = Get-DreamSkinVerifiedCdpIdentity -Port $Port -Codex $codex
Write-CodexxJson ([pscustomobject]@{
verified = $null -ne $identity
browserId = if ($null -eq $identity) { $null } else { "$($identity.BrowserId)" }
})
break
}
'processInfo' {
if ($TargetPid -le 0) { throw 'Target PID must be positive.' }
$process = Get-CimInstance Win32_Process -Filter "ProcessId = $TargetPid" -ErrorAction SilentlyContinue
if ($null -eq $process) {
Write-CodexxJson ([pscustomobject]@{ alive = $false })
break
}
Write-CodexxJson ([pscustomobject]@{
alive = $true
path = (Get-DreamSkinProcessExecutablePath -ProcessInfo $process)
commandLine = "$($process.CommandLine)"
startedAt = (Get-DreamSkinProcessStartedAt -ProcessId $TargetPid)
})
break
}
'injectorStatus' {
$state = Get-CodexxState
Write-CodexxJson ([pscustomobject]@{ active = (Test-CodexxInjectorState -State $state) })
break
}
'stopInjector' {
$state = Get-CodexxState
if (-not (Test-CodexxInjectorState -State $state)) {
$process = Get-Process -Id ([int]$state.injectorPid) -ErrorAction SilentlyContinue
if ($null -ne $process) { throw 'The recorded skin injector identity does not match; it was not stopped.' }
} else {
$null = Stop-DreamSkinRecordedInjector -State $state
}
Write-CodexxJson ([pscustomobject]@{ stopped = $true })
break
}
}
} finally {
Exit-DreamSkinOperationLock -Mutex $operationLock
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,602 @@
$script:DreamSkinUtf8NoBom = [System.Text.UTF8Encoding]::new($false, $true)
$script:DreamSkinLegacyAppearanceTheme = 'appearanceTheme = "light"'
$script:DreamSkinManagedLightCodeTheme = 'appearanceLightCodeThemeId = "codex"'
$script:DreamSkinManagedLightChromeTheme = 'appearanceLightChromeTheme = { accent = "#B65CFF", contrast = 64, fonts = { code = "Cascadia Code", ui = "Microsoft YaHei UI" }, ink = "#4A235F", opaqueWindows = true, semanticColors = { diffAdded = "#BCE8CF", diffRemoved = "#F7B8CE", skill = "#C47BFF" }, surface = "#FFF4FA" }'
function ConvertFrom-DreamSkinUtf8Bytes {
param(
[Parameter(Mandatory = $true)][AllowEmptyCollection()][byte[]]$Bytes,
[Parameter(Mandatory = $true)][string]$Path
)
try {
$offset = if ($Bytes.Length -ge 3 -and $Bytes[0] -eq 0xEF -and $Bytes[1] -eq 0xBB -and $Bytes[2] -eq 0xBF) { 3 } else { 0 }
$content = $script:DreamSkinUtf8NoBom.GetString($Bytes, $offset, $Bytes.Length - $offset)
if ($content.IndexOf([char]0) -ge 0) {
throw "Refusing to rewrite a config file containing NUL characters (possibly BOM-less UTF-16): $Path"
}
return $content
} catch [System.Text.DecoderFallbackException] {
throw "Refusing to rewrite a config file that is not valid UTF-8: $Path"
}
}
function Test-DreamSkinBytesEqual {
param(
[Parameter(Mandatory = $true)][AllowEmptyCollection()][byte[]]$Left,
[Parameter(Mandatory = $true)][AllowEmptyCollection()][byte[]]$Right
)
if ($Left.Length -ne $Right.Length) { return $false }
for ($index = 0; $index -lt $Left.Length; $index++) {
if ($Left[$index] -ne $Right[$index]) { return $false }
}
return $true
}
function Assert-DreamSkinFileUnchanged {
param(
[Parameter(Mandatory = $true)][string]$Path,
[AllowNull()][byte[]]$ExpectedBytes
)
if ($null -eq $ExpectedBytes) {
if (Test-Path -LiteralPath $Path) { throw "File changed during the operation; retry without other writers: $Path" }
return
}
if (-not (Test-Path -LiteralPath $Path)) { throw "File disappeared during the operation; retry: $Path" }
$currentBytes = [System.IO.File]::ReadAllBytes($Path)
if (-not (Test-DreamSkinBytesEqual -Left $ExpectedBytes -Right $currentBytes)) {
throw "File changed during the operation; retry without other writers: $Path"
}
}
function Get-DreamSkinNewLine {
param([Parameter(Mandatory = $true)][AllowEmptyString()][string]$Content)
if ($Content.Contains("`r`n")) { return "`r`n" }
return "`n"
}
function Read-DreamSkinUtf8File {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$Path
)
$bytes = [System.IO.File]::ReadAllBytes($Path)
return (ConvertFrom-DreamSkinUtf8Bytes -Bytes $bytes -Path $Path)
}
function Write-DreamSkinUtf8FileAtomically {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$Path,
[Parameter(Mandatory = $true)]
[AllowEmptyString()]
[string]$Content,
[AllowNull()]
[byte[]]$ExpectedBytes
)
$bytes = $script:DreamSkinUtf8NoBom.GetBytes($Content)
if ($PSBoundParameters.ContainsKey('ExpectedBytes')) {
Write-DreamSkinBytesAtomically -Path $Path -Bytes $bytes -ExpectedBytes $ExpectedBytes
} else {
Write-DreamSkinBytesAtomically -Path $Path -Bytes $bytes
}
}
function Remove-DreamSkinAtomicArtifact {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$Path
)
if ([System.IO.File]::Exists($Path)) {
[System.IO.File]::Delete($Path)
}
}
function Write-DreamSkinBytesAtomically {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)][string]$Path,
[Parameter(Mandatory = $true)][AllowEmptyCollection()][byte[]]$Bytes,
[AllowNull()][byte[]]$ExpectedBytes
)
$fullPath = [System.IO.Path]::GetFullPath($Path)
$directory = [System.IO.Path]::GetDirectoryName($fullPath)
if (-not [System.IO.Directory]::Exists($directory)) {
[System.IO.Directory]::CreateDirectory($directory) | Out-Null
}
$fileName = [System.IO.Path]::GetFileName($fullPath)
$operationId = "$PID.$([guid]::NewGuid().ToString('N'))"
$temporary = Join-Path $directory ".$fileName.$operationId.tmp"
$replacementBackup = Join-Path $directory ".$fileName.$operationId.replace-backup"
try {
[System.IO.File]::WriteAllBytes($temporary, $Bytes)
if ($PSBoundParameters.ContainsKey('ExpectedBytes')) {
Assert-DreamSkinFileUnchanged -Path $fullPath -ExpectedBytes $ExpectedBytes
}
if ([System.IO.File]::Exists($fullPath)) {
[System.IO.File]::Replace($temporary, $fullPath, $replacementBackup)
} else {
[System.IO.File]::Move($temporary, $fullPath)
}
} finally {
foreach ($artifact in @($temporary, $replacementBackup)) {
try {
Remove-DreamSkinAtomicArtifact -Path $artifact
} catch {
try {
Write-Warning "Could not remove temporary atomic config artifact '$artifact': $($_.Exception.Message)"
} catch {
# Cleanup must never mask the result of the atomic write.
}
}
}
}
}
function Get-DreamSkinTomlKeyTokenPattern {
param([Parameter(Mandatory = $true)][string]$Key)
$bare = [regex]::Escape($Key)
$doubleQuoted = [regex]::Escape('"' + $Key + '"')
$singleQuoted = [regex]::Escape("'" + $Key + "'")
return "(?:$bare|$doubleQuoted|$singleQuoted)"
}
function ConvertTo-DreamSkinTomlAsciiEscapeProbe {
param([Parameter(Mandatory = $true)][AllowEmptyString()][string]$Value)
$result = $Value
$characters = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-'.ToCharArray()
foreach ($character in $characters) {
$code = ([int][char]$character).ToString('x2')
$pattern = '(?i)\\(?:u00' + $code + '|U000000' + $code + ')'
$result = [regex]::Replace($result, $pattern, [string]$character)
}
return $result
}
function Get-DreamSkinTomlArrayBracketBalance {
param([Parameter(Mandatory = $true)][AllowEmptyString()][string]$Line)
$quote = $null
$escaped = $false
$balance = 0
for ($index = 0; $index -lt $Line.Length; $index++) {
$character = $Line[$index]
if ($null -eq $quote) {
if ($character -eq '#') { break }
if ($character -eq '"' -or $character -eq "'") { $quote = $character }
elseif ($character -eq '[') { $balance++ }
elseif ($character -eq ']') { $balance-- }
continue
}
if ($quote -eq '"') {
if ($escaped) { $escaped = $false; continue }
if ($character -eq '\') { $escaped = $true; continue }
}
if ($character -eq $quote) { $quote = $null }
}
return $balance
}
function Assert-DreamSkinTomlLineEditingSafe {
param([Parameter(Mandatory = $true)][AllowEmptyString()][string]$Content)
if ($Content.Contains('"""') -or $Content.Contains("'''")) {
throw 'Refusing to rewrite TOML containing multiline strings; use single-line values before installing Dream Skin.'
}
foreach ($match in [regex]::Matches($Content, '(?m)^[^\r\n]*=[\t ]*\[[^\r\n]*\r?$')) {
if ((Get-DreamSkinTomlArrayBracketBalance -Line $match.Value) -ne 0) {
throw 'Refusing to rewrite TOML containing multiline arrays; use single-line arrays before installing Dream Skin.'
}
}
$probe = ConvertTo-DreamSkinTomlAsciiEscapeProbe -Value $Content
if ($probe -cne $Content) {
$desktopToken = Get-DreamSkinTomlKeyTokenPattern -Key 'desktop'
$desktopShape = "(?m)^[\t ]*(?:\[\[?[\t ]*$desktopToken[\t ]*(?:\]|\.)|$desktopToken[\t ]*(?:\.|=))"
$rawDesktopShapes = [regex]::Matches($Content, $desktopShape).Count
$probedDesktopShapes = [regex]::Matches($probe, $desktopShape).Count
if ($probedDesktopShapes -gt $rawDesktopShapes) {
throw 'Refusing to rewrite an escaped TOML key equivalent to desktop; normalize the key spelling first.'
}
}
}
function Get-DreamSkinDesktopSectionPattern {
$desktopToken = Get-DreamSkinTomlKeyTokenPattern -Key 'desktop'
return "(?ms)^[\t ]*\[[\t ]*$desktopToken[\t ]*\][\t ]*(?:#[^\r\n]*)?(?:\r?\n|(?=\z))(?<body>.*?)(?=^[\t ]*\[\[?|\z)"
}
function Test-DreamSkinDesktopNestedTable {
param(
[Parameter(Mandatory = $true)][AllowEmptyString()][string]$Content,
[Parameter(Mandatory = $true)][string]$Key
)
$desktopToken = Get-DreamSkinTomlKeyTokenPattern -Key 'desktop'
$keyToken = Get-DreamSkinTomlKeyTokenPattern -Key $Key
return [regex]::IsMatch(
$Content,
"(?m)^[\t ]*\[[\t ]*$desktopToken[\t ]*\.[\t ]*$keyToken[\t ]*(?:\]|\.)"
)
}
function Assert-DreamSkinDesktopShapeSupported {
param([Parameter(Mandatory = $true)][AllowEmptyString()][string]$Content)
Assert-DreamSkinTomlLineEditingSafe -Content $Content
$sectionPattern = Get-DreamSkinDesktopSectionPattern
if ([regex]::Matches($Content, $sectionPattern).Count -gt 1) {
throw 'Refusing to rewrite multiple equivalent [desktop] tables.'
}
$desktopToken = Get-DreamSkinTomlKeyTokenPattern -Key 'desktop'
if ([regex]::IsMatch($Content, "(?m)^[\t ]*\[\[[\t ]*$desktopToken[\t ]*(?:\]\]|\.)")) {
throw 'Refusing to rewrite a config that represents desktop as an array of tables.'
}
foreach ($key in @('appearanceTheme', 'appearanceLightCodeThemeId')) {
if (Test-DreamSkinDesktopNestedTable -Content $Content -Key $key) {
throw "Refusing to replace '$key' because it is represented as a nested desktop table."
}
}
$firstTable = [regex]::Match($Content, '(?m)^[\t ]*\[\[?')
$rootContent = if ($firstTable.Success) { $Content.Substring(0, $firstTable.Index) } else { $Content }
if ([regex]::IsMatch($rootContent, "(?m)^[\t ]*$desktopToken[\t ]*(?:\.|=)")) {
throw 'Refusing to rewrite root dotted or inline desktop keys; normalize them to a [desktop] table first.'
}
$desktop = Get-DreamSkinDesktopSection -Content $Content
if ($null -ne $desktop) {
$bodyProbe = ConvertTo-DreamSkinTomlAsciiEscapeProbe -Value $desktop.Body
foreach ($key in @('appearanceTheme', 'appearanceLightCodeThemeId', 'appearanceLightChromeTheme')) {
$keyToken = Get-DreamSkinTomlKeyTokenPattern -Key $key
$settingShape = "(?m)^[\t ]*$keyToken[\t ]*(?:\.|=)"
if ($key -eq 'appearanceLightChromeTheme' -and
(Test-DreamSkinDesktopNestedTable -Content $Content -Key $key) -and
[regex]::IsMatch($desktop.Body, $settingShape)) {
throw "Refusing to rewrite '$key' because both a scalar and nested table are present."
}
if ([regex]::Matches($bodyProbe, $settingShape).Count -gt
[regex]::Matches($desktop.Body, $settingShape).Count) {
throw "Refusing to rewrite an escaped TOML key equivalent to '$key'."
}
if ([regex]::IsMatch($desktop.Body, "(?m)^[\t ]*$keyToken[\t ]*\.")) {
throw "Refusing to replace dotted '$key' keys in the [desktop] table."
}
}
}
}
function Get-DreamSkinDesktopSection {
param([Parameter(Mandatory = $true)][AllowEmptyString()][string]$Content)
$match = [regex]::Match($Content, (Get-DreamSkinDesktopSectionPattern))
if (-not $match.Success) { return $null }
return [pscustomobject]@{
Body = $match.Groups['body'].Value
BodyStart = $match.Groups['body'].Index
BodyLength = $match.Groups['body'].Length
SectionStart = $match.Index
SectionLength = $match.Length
}
}
function Add-DreamSkinDesktopSection {
param(
[Parameter(Mandatory = $true)][AllowEmptyString()][string]$Content,
[Parameter(Mandatory = $true)][string]$NewLine
)
if ($Content.Length -eq 0) { return "[desktop]$NewLine" }
$separator = if ($Content.EndsWith("`n")) { $NewLine } else { $NewLine + $NewLine }
return $Content + $separator + "[desktop]$NewLine"
}
function Set-DreamSkinSectionSetting {
param(
[Parameter(Mandatory = $true)][AllowEmptyString()][string]$Body,
[Parameter(Mandatory = $true)][string]$Key,
[AllowNull()][object]$Line,
[Parameter(Mandatory = $true)][string]$NewLine
)
$keyToken = Get-DreamSkinTomlKeyTokenPattern -Key $Key
$pattern = "(?m)^[\t ]*$keyToken[\t ]*=[^\r\n]*(?:\r?\n|(?=\z))"
$matcher = [regex]::new($pattern)
if ($matcher.Matches($Body).Count -gt 1) {
throw "Refusing to rewrite duplicate '$Key' entries in the [desktop] section."
}
if ($null -eq $Line) { return $matcher.Replace($Body, '', 1) }
$normalizedLine = $Line.TrimEnd("`r", "`n") + $NewLine
if ($matcher.IsMatch($Body)) {
$literalReplacement = $normalizedLine.Replace('$', '$$')
return $matcher.Replace($Body, $literalReplacement, 1)
}
$separator = if ($Body.Length -eq 0 -or $Body.EndsWith("`n")) { '' } else { $NewLine }
return $Body + $separator + $normalizedLine
}
function Get-DreamSkinSectionSettingLine {
param(
[Parameter(Mandatory = $true)][AllowEmptyString()][string]$Body,
[Parameter(Mandatory = $true)][string]$Key
)
$keyToken = Get-DreamSkinTomlKeyTokenPattern -Key $Key
$matches = [regex]::Matches($Body, "(?m)^[\t ]*$keyToken[\t ]*=.*$")
if ($matches.Count -gt 1) { throw "Refusing to inspect duplicate '$Key' entries in the [desktop] section." }
if ($matches.Count -eq 0) { return $null }
return $matches[0].Value.Trim()
}
function Test-DreamSkinLegacyManagedLightTrio {
param([Parameter(Mandatory = $true)][AllowEmptyString()][string]$Content)
$desktop = Get-DreamSkinDesktopSection -Content $Content
if ($null -eq $desktop) { return $false }
return (
(Get-DreamSkinSectionSettingLine -Body $desktop.Body -Key 'appearanceTheme') -ceq
$script:DreamSkinLegacyAppearanceTheme -and
(Get-DreamSkinSectionSettingLine -Body $desktop.Body -Key 'appearanceLightCodeThemeId') -ceq
$script:DreamSkinManagedLightCodeTheme -and
(Get-DreamSkinSectionSettingLine -Body $desktop.Body -Key 'appearanceLightChromeTheme') -ceq
$script:DreamSkinManagedLightChromeTheme
)
}
function Get-DreamSkinAppearanceMarkerPath {
param([Parameter(Mandatory = $true)][string]$BackupPath)
return "$BackupPath.appearance.json"
}
function Read-DreamSkinAppearanceMarker {
param([Parameter(Mandatory = $true)][string]$BackupPath)
$markerPath = Get-DreamSkinAppearanceMarkerPath -BackupPath $BackupPath
if (-not (Test-Path -LiteralPath $markerPath)) { return $null }
try {
$marker = (Read-DreamSkinUtf8File -Path $markerPath) | ConvertFrom-Json -ErrorAction Stop
} catch {
throw "Dream Skin appearance marker is unreadable; config was preserved: $markerPath"
}
if ($null -eq $marker -or $marker -is [string] -or $marker -is [array]) {
throw "Dream Skin appearance marker is invalid; config was preserved: $markerPath"
}
$schemaVersion = 0
try { $schemaVersion = [int]$marker.schemaVersion } catch { $schemaVersion = 0 }
# v1 markers are always unmanaged; v2 markers may pin appearanceTheme.
$validUnmanagedV1 = $schemaVersion -eq 1 -and $marker.appearanceThemeManaged -is [bool] -and
-not [bool]$marker.appearanceThemeManaged
$validV2 = $schemaVersion -eq 2 -and $marker.appearanceThemeManaged -is [bool]
if (-not ($validUnmanagedV1 -or $validV2)) {
throw "Dream Skin appearance marker is invalid; config was preserved: $markerPath"
}
return $marker
}
function Write-DreamSkinAppearanceMarker {
param(
[Parameter(Mandatory = $true)][string]$BackupPath,
[bool]$Managed = $false
)
$markerPath = Get-DreamSkinAppearanceMarkerPath -BackupPath $BackupPath
if (Get-Command Assert-DreamSkinNoReparseComponents -ErrorAction SilentlyContinue) {
Assert-DreamSkinNoReparseComponents -Path $markerPath
}
# Unmanaged markers keep the v1 shape older engines accept; managed pins use
# schemaVersion 2, which older engines conservatively refuse to act on.
$schemaVersion = 1
if ($Managed) { $schemaVersion = 2 }
$marker = [ordered]@{
schemaVersion = $schemaVersion
appearanceThemeManaged = $Managed
} | ConvertTo-Json
Write-DreamSkinUtf8FileAtomically -Path $markerPath -Content ($marker + "`r`n")
}
function Install-DreamSkinBaseTheme {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$ConfigPath,
[Parameter(Mandatory = $true)]
[string]$BackupPath,
[ValidateSet('auto', 'light', 'dark')]
[string]$AppearanceTheme = 'auto'
)
if (-not (Test-Path -LiteralPath $ConfigPath)) { throw "Codex config not found: $ConfigPath" }
if (Get-Command Assert-DreamSkinNoReparseComponents -ErrorAction SilentlyContinue) {
Assert-DreamSkinNoReparseComponents -Path $BackupPath
Assert-DreamSkinNoReparseComponents -Path (Get-DreamSkinAppearanceMarkerPath -BackupPath $BackupPath)
}
$originalBytes = [System.IO.File]::ReadAllBytes($ConfigPath)
$content = ConvertFrom-DreamSkinUtf8Bytes -Bytes $originalBytes -Path $ConfigPath
$appearanceMarker = Read-DreamSkinAppearanceMarker -BackupPath $BackupPath
$appearanceMarkerPath = Get-DreamSkinAppearanceMarkerPath -BackupPath $BackupPath
$appearanceMarkerExisted = Test-Path -LiteralPath $appearanceMarkerPath -PathType Leaf
$backupCreated = $false
if (-not (Test-Path -LiteralPath $BackupPath)) {
Write-DreamSkinBytesAtomically -Path $BackupPath -Bytes $originalBytes -ExpectedBytes $null
$backupCreated = $true
}
$writeCompleted = $false
try {
Assert-DreamSkinDesktopShapeSupported -Content $content
$newLine = Get-DreamSkinNewLine -Content $content
$desktop = Get-DreamSkinDesktopSection -Content $content
if ($null -eq $desktop) {
$content = Add-DreamSkinDesktopSection -Content $content -NewLine $newLine
$desktop = Get-DreamSkinDesktopSection -Content $content
}
$body = $desktop.Body
$backupContent = $null
$pinnedAppearance = $AppearanceTheme -ne 'auto'
$managedByMarker = $null -ne $appearanceMarker -and [bool]$appearanceMarker.appearanceThemeManaged
$legacyMigration = $null -eq $appearanceMarker -and (Test-Path -LiteralPath $BackupPath) -and
(Test-DreamSkinLegacyManagedLightTrio -Content $content)
# Put the pre-install appearanceTheme back whenever we stop managing it:
# either migrating away from the legacy forced-light trio, or un-pinning
# after a fixed-appearance theme is replaced by an auto one.
if (-not $pinnedAppearance -and ($legacyMigration -or $managedByMarker)) {
$backupContent = ConvertFrom-DreamSkinUtf8Bytes -Bytes ([System.IO.File]::ReadAllBytes($BackupPath)) -Path $BackupPath
Assert-DreamSkinDesktopShapeSupported -Content $backupContent
$backupDesktop = Get-DreamSkinDesktopSection -Content $backupContent
$savedAppearance = if ($null -ne $backupDesktop) {
Get-DreamSkinSectionSettingLine -Body $backupDesktop.Body -Key 'appearanceTheme'
} else { $null }
$body = Set-DreamSkinSectionSetting -Body $body -Key 'appearanceTheme' -Line $savedAppearance -NewLine $newLine
}
if ($pinnedAppearance) {
# Native token surfaces (dropdowns/popovers) follow appearanceTheme, so a
# fixed-appearance theme pins it to match; Restore puts the original back.
$body = Set-DreamSkinSectionSetting -Body $body -Key 'appearanceTheme' `
-Line ('appearanceTheme = "{0}"' -f $AppearanceTheme) -NewLine $newLine
}
$settings = [ordered]@{
appearanceLightCodeThemeId = $script:DreamSkinManagedLightCodeTheme
appearanceLightChromeTheme = $script:DreamSkinManagedLightChromeTheme
}
$hasNestedLightChromeTheme = Test-DreamSkinDesktopNestedTable `
-Content $content -Key 'appearanceLightChromeTheme'
foreach ($key in $settings.Keys) {
if ($key -eq 'appearanceLightChromeTheme' -and $hasNestedLightChromeTheme) { continue }
$body = Set-DreamSkinSectionSetting -Body $body -Key $key -Line $settings[$key] -NewLine $newLine
}
$content = $content.Substring(0, $desktop.BodyStart) + $body +
$content.Substring($desktop.BodyStart + $desktop.BodyLength)
# Commit the metadata first. A config commit must never exist without the
# marker that tells restore exactly which appearance keys we own.
Write-DreamSkinAppearanceMarker -BackupPath $BackupPath -Managed $pinnedAppearance
Write-DreamSkinUtf8FileAtomically -Path $ConfigPath -Content $content -ExpectedBytes $originalBytes
$writeCompleted = $true
} catch {
if (-not $writeCompleted) {
$configUnchanged = $false
try {
$configUnchanged = (Test-Path -LiteralPath $ConfigPath -PathType Leaf) -and
(Test-DreamSkinBytesEqual -Left $originalBytes -Right ([System.IO.File]::ReadAllBytes($ConfigPath)))
} catch {
$configUnchanged = $false
}
if ($configUnchanged) {
$markerCleanupSucceeded = $true
if (-not $appearanceMarkerExisted -and (Test-Path -LiteralPath $appearanceMarkerPath)) {
try {
Remove-Item -LiteralPath $appearanceMarkerPath -Force -ErrorAction Stop
} catch {
$markerCleanupSucceeded = $false
}
}
if ($markerCleanupSucceeded -and $backupCreated) {
Remove-Item -LiteralPath $BackupPath -Force -ErrorAction SilentlyContinue
}
}
}
throw
}
}
function Restore-DreamSkinBaseTheme {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$ConfigPath,
[Parameter(Mandatory = $true)]
[string]$BackupPath
)
if (-not (Test-Path -LiteralPath $BackupPath)) { throw 'No pre-install config backup is available.' }
if (Get-Command Assert-DreamSkinNoReparseComponents -ErrorAction SilentlyContinue) {
Assert-DreamSkinNoReparseComponents -Path $BackupPath
Assert-DreamSkinNoReparseComponents -Path (Get-DreamSkinAppearanceMarkerPath -BackupPath $BackupPath)
}
$backupBytes = [System.IO.File]::ReadAllBytes($BackupPath)
$backupContent = ConvertFrom-DreamSkinUtf8Bytes -Bytes $backupBytes -Path $BackupPath
$currentBytes = [System.IO.File]::ReadAllBytes($ConfigPath)
$currentContent = ConvertFrom-DreamSkinUtf8Bytes -Bytes $currentBytes -Path $ConfigPath
Assert-DreamSkinDesktopShapeSupported -Content $backupContent
Assert-DreamSkinDesktopShapeSupported -Content $currentContent
$newLine = Get-DreamSkinNewLine -Content $currentContent
$backupDesktop = Get-DreamSkinDesktopSection -Content $backupContent
$currentDesktop = Get-DreamSkinDesktopSection -Content $currentContent
if ($null -eq $currentDesktop) {
$currentContent = Add-DreamSkinDesktopSection -Content $currentContent -NewLine $newLine
$currentDesktop = Get-DreamSkinDesktopSection -Content $currentContent
}
$body = $currentDesktop.Body
$appearanceMarker = Read-DreamSkinAppearanceMarker -BackupPath $BackupPath
$restoreLegacyAppearance = $null -eq $appearanceMarker -and
(Test-DreamSkinLegacyManagedLightTrio -Content $currentContent)
$restoreManagedAppearance = $null -ne $appearanceMarker -and
[bool]$appearanceMarker.appearanceThemeManaged
$restoreKeys = @('appearanceLightCodeThemeId', 'appearanceLightChromeTheme')
if ($restoreLegacyAppearance -or $restoreManagedAppearance) {
$restoreKeys = @('appearanceTheme') + $restoreKeys
}
$hasNestedLightChromeTheme = Test-DreamSkinDesktopNestedTable `
-Content $currentContent -Key 'appearanceLightChromeTheme'
foreach ($key in $restoreKeys) {
if ($key -eq 'appearanceLightChromeTheme' -and $hasNestedLightChromeTheme) { continue }
$keyToken = Get-DreamSkinTomlKeyTokenPattern -Key $key
$pattern = "(?m)^[\t ]*$keyToken[\t ]*=[^\r\n]*(?:\r?\n|(?=\z))"
$saved = if ($null -ne $backupDesktop) { [regex]::Match($backupDesktop.Body, $pattern) } else { $null }
$line = if ($null -ne $saved -and $saved.Success) { $saved.Value } else { $null }
$body = Set-DreamSkinSectionSetting -Body $body -Key $key -Line $line -NewLine $newLine
}
if ($null -eq $backupDesktop -and [string]::IsNullOrWhiteSpace($body)) {
$currentContent = $currentContent.Remove($currentDesktop.SectionStart, $currentDesktop.SectionLength)
} else {
$currentContent = $currentContent.Substring(0, $currentDesktop.BodyStart) + $body +
$currentContent.Substring($currentDesktop.BodyStart + $currentDesktop.BodyLength)
}
Write-DreamSkinUtf8FileAtomically -Path $ConfigPath -Content $currentContent -ExpectedBytes $currentBytes
}
function Restore-DreamSkinConfigBackup {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)][string]$ConfigPath,
[Parameter(Mandatory = $true)][string]$BackupPath,
[Parameter(Mandatory = $true)][string]$RecoveryBackupPath
)
if (-not (Test-Path -LiteralPath $BackupPath)) { throw 'No pre-install config backup is available.' }
$backupBytes = [System.IO.File]::ReadAllBytes($BackupPath)
$null = ConvertFrom-DreamSkinUtf8Bytes -Bytes $backupBytes -Path $BackupPath
$currentBytes = $null
if (Test-Path -LiteralPath $ConfigPath) {
$currentBytes = [System.IO.File]::ReadAllBytes($ConfigPath)
Write-DreamSkinBytesAtomically -Path $RecoveryBackupPath -Bytes $currentBytes -ExpectedBytes $null
}
Write-DreamSkinBytesAtomically -Path $ConfigPath -Bytes $backupBytes -ExpectedBytes $currentBytes
}
function Archive-DreamSkinConfigBackup {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)][string]$BackupPath,
[Parameter(Mandatory = $true)][string]$ArchivePath
)
if (-not (Test-Path -LiteralPath $BackupPath)) { return }
if (Test-Path -LiteralPath $ArchivePath) { throw "Config backup archive already exists: $ArchivePath" }
Move-Item -LiteralPath $BackupPath -Destination $ArchivePath -ErrorAction Stop
Remove-Item -LiteralPath (Get-DreamSkinAppearanceMarkerPath -BackupPath $BackupPath) -Force -ErrorAction SilentlyContinue
}
@@ -0,0 +1,8 @@
The official Windows release workflow places the pinned Node.js runtime here.
Version: 22.23.1
Archive: node-v22.23.1-win-x64.zip
SHA-256: 7df0bc9375723f4a86b3aa1b7cc73342423d9677a8df4538aca31a049e309c29
Source: https://nodejs.org/dist/v22.23.1/node-v22.23.1-win-x64.zip
Local source builds may use Node.js 22 or newer from PATH instead.
+39
View File
@@ -4,6 +4,17 @@ use crate::paths::app_home;
use crate::sqlite_utils::table_column_set;
use rusqlite::Connection;
use std::path::PathBuf;
use std::time::Duration;
#[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"))
@@ -40,6 +51,8 @@ pub(crate) fn open() -> Result<Connection> {
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()))?;
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS providers (
id TEXT PRIMARY KEY,
@@ -50,6 +63,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 +86,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,
@@ -96,6 +117,24 @@ pub(crate) fn open() -> Result<Connection> {
"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-%'
+80 -2
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"))
}
+25 -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;
use crate::live_config::{
acquire_live_config_lock, atomic_write_if_unchanged, read_file_snapshot, text_from_snapshot,
};
use std::path::Path;
use toml_edit::{DocumentMut, Item, Table};
@@ -29,13 +32,16 @@ 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.
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 +52,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 +79,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,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);
}
+183 -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,29 @@ pub(crate) fn read_to_string_if_exists(path: &Path) -> Result<String> {
fs::read_to_string(path).map_err(|e| io_err(path, e))
}
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 +141,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 +201,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 +235,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 +275,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");
+456 -120
View File
@@ -18,8 +18,10 @@ mod backups;
mod ccswitch;
mod config_migration;
mod constants;
mod desktop_lifecycle;
mod error;
mod file_io;
mod live_config;
mod paths;
mod platform;
mod prompts;
@@ -27,55 +29,70 @@ mod providers;
mod remote;
mod sessions;
mod skills_mcp;
mod skin_presets;
mod skin_runtime;
mod skins;
mod sqlite_utils;
mod state;
mod toml_utils;
mod updates;
use backups::{action_backup_root, backups, create_backup, BackupEntry, BackupMeta};
use backups::{
action_backup_root, backups, create_backup, validate_backup_codex_dir, BackupEntry, BackupMeta,
};
use config_migration::{migrate_legacy_prompt_config_locked, migrated_legacy_prompt_config_text};
use constants::*;
use error::{CodexxError, Result};
#[cfg(target_os = "windows")]
use file_io::io_err;
use file_io::{directory_exists, ensure_directory, parse_toml_document};
#[cfg(test)]
use file_io::write_json;
use file_io::{
atomic_write, directory_exists, ensure_directory, io_err, parse_toml_document,
read_to_string_if_exists, write_text,
use file_io::{write_json, write_text};
use live_config::{
acquire_live_config_lock, apply_file_change, fail_with_file_rollback, read_file_snapshot,
text_from_snapshot, AppliedFileChange,
};
#[cfg(test)]
use paths::app_home;
use paths::home_dir;
use prompts::{
agents_path, builtin_prompt_content, builtin_prompt_status_inner, bundled_prompt_meta,
delete_prompt_inner, get_saved_prompt_inner, install_managed_agents_block,
list_saved_prompts_inner, managed_agents_bounds, normalize_prompt_filename,
prompt_template_key_for_instruction, refresh_builtin_prompts_with_active,
remember_current_instruction_prompt, resolve_instruction_path, save_prompt_inner,
uninstall_managed_agents_block, BuiltinPromptStatus, SavedPrompt,
agents_path, builtin_prompt_content, builtin_prompt_detail_inner, builtin_prompt_status_inner,
bundled_prompt_meta, delete_prompt_inner, get_saved_prompt_inner,
install_managed_agents_block_in_content, list_saved_prompts_inner, managed_agents_bounds,
normalize_prompt_filename, prompt_template_key_for_instruction,
refresh_builtin_prompts_with_active, remember_current_instruction_prompt,
remove_managed_agents_block_from_content, resolve_instruction_path,
save_builtin_prompt_override_inner, save_prompt_inner, BuiltinPromptDetail,
BuiltinPromptStatus, SavedPrompt,
};
#[cfg(test)]
use prompts::{
bundled_prompt_metas, cached_prompt_fallback_statuses, delete_cached_prompt_ids,
github_prompt_catalog_from_entries, jsdelivr_prompt_catalog_from_entries,
managed_agents_template_key_from_content, prompt_content_source_urls, stable_remote_prompt_id,
stale_cached_prompt_ids, CachedBuiltinPrompt, GithubContentEntry,
github_prompt_catalog_from_entries, install_managed_agents_block,
jsdelivr_prompt_catalog_from_entries, managed_agents_template_key_from_content,
prompt_content_source_urls, stable_remote_prompt_id, stale_cached_prompt_ids,
uninstall_managed_agents_block, CachedBuiltinPrompt, GithubContentEntry,
};
#[cfg(test)]
use providers::{
build_ccswitch_codex_provider, canonical_provider_base_url, codex_sections_from_config,
detected_live_custom_provider, is_official_ccswitch_row, list_saved_providers_on_connection,
merge_duplicate_provider_identities, normalize_saved_provider, provider_by_id_on_connection,
provider_identity, provider_status_result, read_ccswitch_codex_rows,
save_manual_provider_on_connection, save_provider_toml_config_with_pre_persist,
switch_official_provider_with_pre_persist, switch_provider_with_pre_persist,
consolidate_legacy_provider_duplicates_on_connection, detected_live_custom_provider,
is_official_ccswitch_row, list_saved_providers_on_connection, normalize_saved_provider,
official_snapshot_path_for_test, provider_by_id_on_connection, provider_identity,
provider_status_result, read_ccswitch_codex_rows, save_manual_provider_on_connection,
save_provider_toml_config_with_pre_persist, switch_official_provider_with_pre_persist,
switch_provider_with_pre_persist, upsert_ccswitch_provider_on_connection,
upsert_provider_on_connection, CcSwitchCodexRow, ProviderUpsertKind, ProviderUpsertMode,
};
use providers::{
delete_provider_inner, fetch_provider_models_inner, import_ccswitch_codex_providers_inner,
list_saved_providers_inner, read_ccswitch_official_auth_inner, save_official_config_inner,
capture_live_chatgpt_config, delete_saved_provider_inner, fetch_provider_models_inner,
get_official_config_draft_inner, import_ccswitch_codex_providers_inner,
list_saved_providers_inner, read_ccswitch_official_auth_inner, reset_official_provider_inner,
restore_official_provider_inner, save_active_provider_inner, save_official_config_inner,
save_provider_inner, save_provider_toml_config_inner, switch_official_provider_inner,
switch_provider_inner, test_provider_connection_inner, ImportResult, OfficialAuthCandidate,
OfficialConfigInput, ProviderConnectionResult, ProviderInput, ProviderModelsResult,
ProviderTomlInput, SavedProvider,
OfficialConfigDraft, OfficialConfigInput, ProviderConnectionResult, ProviderInput,
ProviderModelsResult, ProviderTomlInput, SavedProvider,
};
#[cfg(test)]
use sessions::{
@@ -99,9 +116,17 @@ use skills_mcp::{
normalize_legacy_zip_skill_dirs, read_skill_metadata, sort_managed_mcp_servers,
sort_managed_skills, ManagedMcpServer, ManagedSkill,
};
use skins::{
create_skin_theme_from_image_inner, enable_skin_theme_inner, export_skin_theme_inner,
get_skin_center_state_inner, import_skin_theme_zip_inner, pause_skin_theme_inner,
restore_skin_theme_inner, update_skin_theme_settings_inner, SkinActionResult, SkinCenterState,
SkinExportResult,
};
#[cfg(test)]
use state::active_saved_provider_id_from_config;
use state::{auth_has_material, build_state, ActionResult, CodexState};
use state::{
auth_has_material, build_state, build_state_after_migration, ActionResult, CodexState,
};
use toml_edit::{value, DocumentMut};
pub(crate) use toml_utils::string_value;
use updates::check_app_update;
@@ -470,6 +495,74 @@ async fn install_skill_zip(
.map_err(|e| CodexxError::Config(format!("ZIP 安装 Skill 失败: {e}")))?
}
#[tauri::command]
async fn get_skin_center_state() -> Result<SkinCenterState> {
tauri::async_runtime::spawn_blocking(get_skin_center_state_inner)
.await
.map_err(|e| CodexxError::Config(format!("读取皮肤中心失败: {e}")))?
}
#[tauri::command]
async fn enable_skin_theme(id: String, restart_existing: bool) -> Result<SkinActionResult> {
tauri::async_runtime::spawn_blocking(move || enable_skin_theme_inner(id, restart_existing))
.await
.map_err(|e| CodexxError::Config(format!("启用皮肤失败: {e}")))?
}
#[tauri::command]
async fn import_skin_theme_zip(file_name: String, bytes: Vec<u8>) -> Result<SkinActionResult> {
tauri::async_runtime::spawn_blocking(move || import_skin_theme_zip_inner(file_name, bytes))
.await
.map_err(|e| CodexxError::Config(format!("导入皮肤失败: {e}")))?
}
#[tauri::command]
async fn create_skin_theme_from_image(
file_name: String,
bytes: Vec<u8>,
) -> Result<SkinActionResult> {
tauri::async_runtime::spawn_blocking(move || {
create_skin_theme_from_image_inner(file_name, bytes)
})
.await
.map_err(|e| CodexxError::Config(format!("从图片创建皮肤失败: {e}")))?
}
#[tauri::command]
async fn update_skin_theme_settings(
id: String,
name: String,
tagline: String,
surface_opacity: f64,
) -> Result<SkinActionResult> {
tauri::async_runtime::spawn_blocking(move || {
update_skin_theme_settings_inner(id, name, tagline, surface_opacity)
})
.await
.map_err(|e| CodexxError::Config(format!("保存皮肤设置失败: {e}")))?
}
#[tauri::command]
async fn export_skin_theme(id: String, destination_path: String) -> Result<SkinExportResult> {
tauri::async_runtime::spawn_blocking(move || export_skin_theme_inner(id, destination_path))
.await
.map_err(|e| CodexxError::Config(format!("导出皮肤失败: {e}")))?
}
#[tauri::command]
async fn pause_skin_theme() -> Result<SkinActionResult> {
tauri::async_runtime::spawn_blocking(pause_skin_theme_inner)
.await
.map_err(|e| CodexxError::Config(format!("暂停皮肤失败: {e}")))?
}
#[tauri::command]
async fn restore_skin_theme(restart_existing: bool) -> Result<SkinActionResult> {
tauri::async_runtime::spawn_blocking(move || restore_skin_theme_inner(restart_existing))
.await
.map_err(|e| CodexxError::Config(format!("恢复官方外观失败: {e}")))?
}
#[tauri::command]
async fn check_skill_updates(config_dir: Option<String>) -> Result<SkillsMcpState> {
tauri::async_runtime::spawn_blocking(move || check_skill_updates_inner(config_dir))
@@ -576,6 +669,33 @@ async fn get_builtin_prompt_status() -> Result<Vec<BuiltinPromptStatus>> {
.map_err(|e| CodexxError::Config(format!("读取内置提示词状态失败: {e}")))?
}
#[tauri::command]
async fn get_builtin_prompt_detail(template_id: String) -> Result<BuiltinPromptDetail> {
tauri::async_runtime::spawn_blocking(move || builtin_prompt_detail_inner(&template_id))
.await
.map_err(|e| CodexxError::Config(format!("读取内置提示词失败: {e}")))?
}
#[tauri::command]
async fn save_builtin_prompt_override(
template_id: String,
content: String,
) -> Result<BuiltinPromptDetail> {
tauri::async_runtime::spawn_blocking(move || {
let id = template_id.trim();
// Resolve first so stale or unknown IDs cannot create detached local records.
let current = builtin_prompt_detail_inner(id)?;
let content = content.trim();
if !current.customized && current.content.trim() == content {
return Ok(current);
}
save_builtin_prompt_override_inner(id, content)?;
builtin_prompt_detail_inner(id)
})
.await
.map_err(|e| CodexxError::Config(format!("保存内置提示词修改失败: {e}")))?
}
#[tauri::command]
async fn refresh_builtin_prompts(config_dir: Option<String>) -> Result<Vec<BuiltinPromptStatus>> {
tauri::async_runtime::spawn_blocking(move || refresh_builtin_prompts_inner(config_dir))
@@ -640,6 +760,23 @@ fn managed_model_instruction_path(codex_dir: &Path, doc: &DocumentMut) -> Result
Ok(Some(resolve_instruction_path(codex_dir, &current)))
}
fn finish_file_action(
codex_dir: PathBuf,
changes: &[AppliedFileChange],
message: String,
backup_id: Option<String>,
) -> Result<ActionResult> {
match build_state_after_migration(codex_dir) {
Ok(state) => Ok(ActionResult {
ok: true,
message,
backup_id,
state,
}),
Err(error) => fail_with_file_rollback(error, changes),
}
}
#[allow(clippy::too_many_arguments)]
fn enable_prompt_content_inner(
config_dir: Option<String>,
@@ -664,49 +801,84 @@ fn enable_prompt_content_inner(
let codex_dir = resolve_codex_dir(config_dir)?;
ensure_directory(&codex_dir)?;
let _live_lock = acquire_live_config_lock(&codex_dir)?;
migrate_legacy_prompt_config_locked(&codex_dir)?;
let cfg = config_path(&codex_dir);
let agents = agents_path(&codex_dir);
let text = read_to_string_if_exists(&cfg)?;
let config_before = read_file_snapshot(&cfg)?;
let text = text_from_snapshot(&cfg, config_before.as_deref())?;
let mut doc = parse_toml_document(&cfg, &text)?;
let agents_text = read_to_string_if_exists(&agents)?;
let agents_before = read_file_snapshot(&agents)?;
let agents_text = text_from_snapshot(&agents, agents_before.as_deref())?;
managed_agents_bounds(&agents_text)?;
let previous_managed_file = managed_model_instruction_path(&codex_dir, &doc)?;
if injection_mode == PromptInjectionMode::Replace {
let _ = remember_current_instruction_prompt(&codex_dir);
remember_current_instruction_prompt(&codex_dir)?;
}
let backup_id = create_backup(&codex_dir, action)?;
let mut changes = Vec::new();
match injection_mode {
PromptInjectionMode::Replace => {
if doc.get("model").is_none() {
doc["model"] = value("gpt-5.5");
let mutation = (|| -> Result<()> {
match injection_mode {
PromptInjectionMode::Replace => {
if doc.get("model").is_none() {
doc["model"] = value("gpt-5.5");
}
doc["model_instructions_file"] = value(format!("./{filename}"));
let prompt_path = codex_dir.join(filename);
let prompt_before = read_file_snapshot(&prompt_path)?;
changes.push(apply_file_change(
&prompt_path,
prompt_before,
Some(content.as_bytes().to_vec()),
)?);
changes.push(apply_file_change(
&cfg,
config_before,
Some(doc.to_string().into_bytes()),
)?);
let (next_agents, _) = remove_managed_agents_block_from_content(&agents_text)?;
let next_agents =
(!next_agents.trim().is_empty()).then(|| next_agents.into_bytes());
changes.push(apply_file_change(&agents, agents_before, next_agents)?);
}
doc["model_instructions_file"] = value(format!("./{filename}"));
write_text(&codex_dir.join(filename), content)?;
write_text(&cfg, &doc.to_string())?;
uninstall_managed_agents_block(&codex_dir)?;
}
PromptInjectionMode::Append => {
install_managed_agents_block(&codex_dir, template_key, content)?;
if previous_managed_file.is_some() {
doc.as_table_mut().remove("model_instructions_file");
write_text(&cfg, &doc.to_string())?;
PromptInjectionMode::Append => {
let next_agents =
install_managed_agents_block_in_content(&agents_text, template_key, content)?;
changes.push(apply_file_change(
&agents,
agents_before,
Some(next_agents.into_bytes()),
)?);
if previous_managed_file.is_some() {
doc.as_table_mut().remove("model_instructions_file");
changes.push(apply_file_change(
&cfg,
config_before,
Some(doc.to_string().into_bytes()),
)?);
}
}
}
}
if let Some(previous) = previous_managed_file {
let next = codex_dir.join(filename);
let should_remove = injection_mode == PromptInjectionMode::Append || previous != next;
if should_remove && previous.parent() == Some(codex_dir.as_path()) && previous.exists() {
fs::remove_file(&previous).map_err(|e| io_err(&previous, e))?;
if let Some(previous) = previous_managed_file {
let next = codex_dir.join(filename);
let should_remove = injection_mode == PromptInjectionMode::Append || previous != next;
if should_remove && previous.parent() == Some(codex_dir.as_path()) {
let previous_before = read_file_snapshot(&previous)?;
changes.push(apply_file_change(&previous, previous_before, None)?);
}
}
Ok(())
})();
if let Err(error) = mutation {
return fail_with_file_rollback(error, &changes);
}
let state = build_state(codex_dir)?;
Ok(ActionResult {
ok: true,
message: format!(
finish_file_action(
codex_dir,
&changes,
format!(
"已用{}模式启用 {title}(来源:{content_source})",
if injection_mode == PromptInjectionMode::Append {
"追加"
@@ -715,8 +887,7 @@ fn enable_prompt_content_inner(
}
),
backup_id,
state,
})
)
}
fn enable_saved_prompt_inner(
@@ -770,20 +941,38 @@ async fn save_provider(provider: SavedProvider) -> Result<SavedProvider> {
}
#[tauri::command]
async fn delete_saved_provider(id: String) -> Result<()> {
tauri::async_runtime::spawn_blocking(move || delete_provider_inner(id.trim()))
async fn save_active_provider(
provider: SavedProvider,
config_dir: Option<String>,
) -> Result<ActionResult> {
tauri::async_runtime::spawn_blocking(move || save_active_provider_inner(provider, config_dir))
.await
.map_err(|e| CodexxError::Config(format!("保存活动供应商失败: {e}")))?
}
#[tauri::command]
async fn delete_saved_provider(id: String, config_dir: Option<String>) -> Result<()> {
tauri::async_runtime::spawn_blocking(move || delete_saved_provider_inner(id.trim(), config_dir))
.await
.map_err(|e| CodexxError::Config(format!("删除供应商失败: {e}")))?
}
#[tauri::command]
async fn get_codex_state(config_dir: Option<String>) -> Result<CodexState> {
tauri::async_runtime::spawn_blocking(move || {
let codex_dir = resolve_codex_dir(config_dir)?;
build_state(codex_dir)
})
.await
.map_err(|e| CodexxError::Config(format!("读取 Codex 状态失败: {e}")))?
tauri::async_runtime::spawn_blocking(move || get_codex_state_inner(config_dir))
.await
.map_err(|e| CodexxError::Config(format!("读取 Codex 状态失败: {e}")))?
}
fn get_codex_state_inner(config_dir: Option<String>) -> Result<CodexState> {
let codex_dir = resolve_codex_dir(config_dir)?;
ensure_directory(&codex_dir)?;
let _live_lock = acquire_live_config_lock(&codex_dir)?;
migrate_legacy_prompt_config_locked(&codex_dir)?;
// Read-only refreshes only capture high-confidence ChatGPT credentials.
// Explicit provider switches also preserve official API-key configs.
capture_live_chatgpt_config(&codex_dir)?;
build_state_after_migration(codex_dir)
}
#[tauri::command]
@@ -793,6 +982,31 @@ async fn switch_official_provider(config_dir: Option<String>) -> Result<ActionRe
.map_err(|e| CodexxError::Config(format!("切换官方配置失败: {e}")))?
}
#[tauri::command]
async fn get_official_config_draft(
config_dir: Option<String>,
) -> Result<Option<OfficialConfigDraft>> {
tauri::async_runtime::spawn_blocking(move || get_official_config_draft_inner(config_dir))
.await
.map_err(|e| CodexxError::Config(format!("读取官方配置快照失败: {e}")))?
}
#[tauri::command]
async fn restore_official_provider(config_dir: Option<String>) -> Result<ActionResult> {
tauri::async_runtime::spawn_blocking(move || restore_official_provider_inner(config_dir))
.await
.map_err(|e| CodexxError::Config(format!("还原官方配置失败: {e}")))?
}
#[tauri::command]
async fn reset_official_provider(input: OfficialConfigInput) -> Result<ActionResult> {
tauri::async_runtime::spawn_blocking(move || {
reset_official_provider_inner(input.config_dir, input.model)
})
.await
.map_err(|e| CodexxError::Config(format!("新建官方配置失败: {e}")))?
}
#[tauri::command]
async fn save_official_config(input: OfficialConfigInput) -> Result<ActionResult> {
tauri::async_runtime::spawn_blocking(move || {
@@ -856,34 +1070,59 @@ fn disable_instruction_inner(
delete_file: Option<bool>,
) -> Result<ActionResult> {
let codex_dir = resolve_codex_dir(config_dir)?;
ensure_directory(&codex_dir)?;
let _live_lock = acquire_live_config_lock(&codex_dir)?;
migrate_legacy_prompt_config_locked(&codex_dir)?;
let cfg = config_path(&codex_dir);
let agents_text = read_to_string_if_exists(&agents_path(&codex_dir))?;
let agents = agents_path(&codex_dir);
let agents_before = read_file_snapshot(&agents)?;
let agents_text = text_from_snapshot(&agents, agents_before.as_deref())?;
managed_agents_bounds(&agents_text)?;
let backup_id = create_backup(&codex_dir, "disable-instruct")?;
let text = read_to_string_if_exists(&cfg)?;
let config_before = read_file_snapshot(&cfg)?;
let text = text_from_snapshot(&cfg, config_before.as_deref())?;
let mut doc = parse_toml_document(&cfg, &text)?;
let current = string_value(&doc, "model_instructions_file");
let managed_model_path = managed_model_instruction_path(&codex_dir, &doc)?;
let removed_model = managed_model_path.is_some();
let mut changes = Vec::new();
if removed_model {
doc.as_table_mut().remove("model_instructions_file");
write_text(&cfg, &doc.to_string())?;
changes.push(apply_file_change(
&cfg,
config_before,
Some(doc.to_string().into_bytes()),
)?);
}
let (next_agents, removed_agents) = remove_managed_agents_block_from_content(&agents_text)?;
if removed_agents {
let next_agents = (!next_agents.trim().is_empty()).then(|| next_agents.into_bytes());
match apply_file_change(&agents, agents_before, next_agents) {
Ok(change) => changes.push(change),
Err(error) => return fail_with_file_rollback(error, &changes),
}
}
let removed_agents = uninstall_managed_agents_block(&codex_dir)?;
if delete_file.unwrap_or(true) {
if let Some(md) = managed_model_path {
if md.parent() == Some(codex_dir.as_path()) && md.exists() {
fs::remove_file(&md).map_err(|e| io_err(&md, e))?;
if md.parent() == Some(codex_dir.as_path()) {
let before = match read_file_snapshot(&md) {
Ok(before) => before,
Err(error) => return fail_with_file_rollback(error, &changes),
};
match apply_file_change(&md, before, None) {
Ok(change) => changes.push(change),
Err(error) => return fail_with_file_rollback(error, &changes),
}
}
}
}
let state = build_state(codex_dir)?;
let removed = removed_model || removed_agents;
Ok(ActionResult {
ok: true,
message: if removed {
finish_file_action(
codex_dir,
&changes,
if removed {
"已禁用指令提示词".to_string()
} else if current.is_some() {
"当前使用的是用户自己的提示词,Codex-X 未做修改".to_string()
@@ -891,8 +1130,7 @@ fn disable_instruction_inner(
"当前没有启用 Codex-X 提示词".to_string()
},
backup_id,
state,
})
)
}
#[tauri::command]
@@ -907,8 +1145,12 @@ async fn disable_instruction(
fn disable_external_instruction_inner(config_dir: Option<String>) -> Result<ActionResult> {
let codex_dir = resolve_codex_dir(config_dir)?;
ensure_directory(&codex_dir)?;
let _live_lock = acquire_live_config_lock(&codex_dir)?;
migrate_legacy_prompt_config_locked(&codex_dir)?;
let cfg = config_path(&codex_dir);
let text = read_to_string_if_exists(&cfg)?;
let config_before = read_file_snapshot(&cfg)?;
let text = text_from_snapshot(&cfg, config_before.as_deref())?;
let mut doc = parse_toml_document(&cfg, &text)?;
let current = string_value(&doc, "model_instructions_file");
if let Some(value) = current.as_deref() {
@@ -919,21 +1161,25 @@ fn disable_external_instruction_inner(config_dir: Option<String>) -> Result<Acti
}
}
let backup_id = create_backup(&codex_dir, "disable-external-instruct")?;
let mut changes = Vec::new();
if current.is_some() {
doc.as_table_mut().remove("model_instructions_file");
write_text(&cfg, &doc.to_string())?;
changes.push(apply_file_change(
&cfg,
config_before,
Some(doc.to_string().into_bytes()),
)?);
}
let state = build_state(codex_dir)?;
Ok(ActionResult {
ok: true,
message: if current.is_some() {
finish_file_action(
codex_dir,
&changes,
if current.is_some() {
"已禁用用户外部提示词,原 md 文件已保留".to_string()
} else {
"当前没有外部提示词".to_string()
},
backup_id,
state,
})
)
}
#[tauri::command]
@@ -984,56 +1230,119 @@ async fn list_backups() -> Result<Vec<BackupEntry>> {
.map_err(|e| CodexxError::Config(format!("读取备份列表失败: {e}")))?
}
fn read_backup_file_snapshot(path: &Path) -> Result<Option<Vec<u8>>> {
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(file_io::io_err(path, error)),
};
if metadata.file_type().is_symlink() || !metadata.is_file() {
return Err(CodexxError::Config(format!(
"备份文件不是普通文件: {}",
path.display()
)));
}
read_file_snapshot(path)
}
fn declared_backup_file_snapshot(path: &Path, declared_present: bool) -> Result<Option<Vec<u8>>> {
match (declared_present, read_backup_file_snapshot(path)?) {
(true, Some(bytes)) => Ok(Some(bytes)),
(true, None) => Err(CodexxError::Config(format!(
"备份元数据声明文件存在,但备份文件缺失: {}",
path.display()
))),
(false, None) => Ok(None),
(false, Some(_)) => Err(CodexxError::Config(format!(
"备份元数据声明文件不存在,但目录中出现了多余文件: {}",
path.display()
))),
}
}
fn restore_backup_inner(config_dir: Option<String>, backup_id: String) -> Result<ActionResult> {
let codex_dir = resolve_codex_dir(config_dir)?;
let backup_id = backup_id.trim().to_string();
let mut backup_components = Path::new(&backup_id).components();
if !matches!(
backup_components.next(),
Some(std::path::Component::Normal(_))
) || backup_components.next().is_some()
{
return Err(CodexxError::Config("备份 ID 无效".to_string()));
}
let dir = action_backup_root(&codex_dir)?.join(&backup_id);
if !dir.exists() {
let dir_metadata = fs::symlink_metadata(&dir).ok();
if !dir_metadata
.as_ref()
.is_some_and(|metadata| metadata.is_dir() && !metadata.file_type().is_symlink())
{
return Err(CodexxError::Config(format!("备份不存在: {backup_id}")));
}
let restore_marker = create_backup(&codex_dir, "before-restore")?;
let backup_meta_path = dir.join("meta.json");
let backup_meta_bytes = read_backup_file_snapshot(&backup_meta_path)?.ok_or_else(|| {
CodexxError::Config(format!(
"备份缺少元数据文件: {}",
backup_meta_path.display()
))
})?;
let backup_meta = serde_json::from_slice::<BackupMeta>(&backup_meta_bytes)
.map_err(|error| file_io::json_err(&backup_meta_path, error))?;
if backup_meta.id != backup_id {
return Err(CodexxError::Config(format!(
"备份元数据 ID 与请求不一致:元数据为 {},请求为 {backup_id}",
backup_meta.id
)));
}
validate_backup_codex_dir(&backup_meta, &codex_dir)?;
let cfg = config_path(&codex_dir);
let auth = auth_path(&codex_dir);
let agents = agents_path(&codex_dir);
ensure_directory(&codex_dir)?;
let backup_meta = fs::read_to_string(dir.join("meta.json"))
.ok()
.and_then(|text| serde_json::from_str::<BackupMeta>(&text).ok());
let backup_cfg = dir.join("config.toml");
if backup_cfg.exists() {
let bytes = fs::read(&backup_cfg).map_err(|e| io_err(&backup_cfg, e))?;
atomic_write(&cfg, &bytes)?;
} else if cfg.exists() {
fs::remove_file(&cfg).map_err(|e| io_err(&cfg, e))?;
}
let backup_auth = dir.join("auth.json");
if backup_auth.exists() {
let bytes = fs::read(&backup_auth).map_err(|e| io_err(&backup_auth, e))?;
atomic_write(&auth, &bytes)?;
} else if auth.exists() {
fs::remove_file(&auth).map_err(|e| io_err(&auth, e))?;
}
if backup_meta.as_ref().is_some_and(|meta| meta.tracks_agents) {
let backup_agents = dir.join(AGENTS_FILENAME);
if backup_agents.exists() {
let bytes = fs::read(&backup_agents).map_err(|e| io_err(&backup_agents, e))?;
atomic_write(&agents, &bytes)?;
} else if agents.exists() {
fs::remove_file(&agents).map_err(|e| io_err(&agents, e))?;
let backup_agents = dir.join(AGENTS_FILENAME);
let backup_config = declared_backup_file_snapshot(&backup_cfg, backup_meta.had_config)?;
let backup_config = match backup_config {
Some(bytes) => {
let text = text_from_snapshot(&backup_cfg, Some(&bytes))?;
Some(
migrated_legacy_prompt_config_text(&cfg, &text)?
.unwrap_or(text)
.into_bytes(),
)
}
None => None,
};
let backup_auth = declared_backup_file_snapshot(&backup_auth, backup_meta.had_auth)?;
let backup_agents = declared_backup_file_snapshot(&backup_agents, backup_meta.had_agents)?;
ensure_directory(&codex_dir)?;
let _live_lock = acquire_live_config_lock(&codex_dir)?;
migrate_legacy_prompt_config_locked(&codex_dir)?;
let restore_marker = create_backup(&codex_dir, "before-restore")?;
let config_before = read_file_snapshot(&cfg)?;
let auth_before = read_file_snapshot(&auth)?;
let agents_before = read_file_snapshot(&agents)?;
let mut changes = Vec::new();
let mutation = (|| -> Result<()> {
changes.push(apply_file_change(&cfg, config_before, backup_config)?);
changes.push(apply_file_change(&auth, auth_before, backup_auth)?);
changes.push(apply_file_change(&agents, agents_before, backup_agents)?);
Ok(())
})();
if let Err(error) = mutation {
return fail_with_file_rollback(error, &changes);
}
let state = build_state(codex_dir)?;
Ok(ActionResult {
ok: true,
message: format!("已恢复备份 {backup_id}"),
backup_id: restore_marker,
state,
})
finish_file_action(
codex_dir,
&changes,
format!("已恢复备份 {backup_id}"),
restore_marker,
)
}
#[tauri::command]
@@ -1079,9 +1388,20 @@ fn open_url(url: String) -> std::result::Result<(), String> {
}
pub fn run() {
tauri::Builder::default()
let app = tauri::Builder::default()
// This must remain the first plugin so a second launch cannot initialize
// another tray or start concurrent configuration work.
.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
desktop_lifecycle::restore_main_window(app);
}))
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_process::init())
.plugin(tauri_plugin_updater::Builder::new().build())
.setup(|app| {
desktop_lifecycle::setup_system_tray(app)?;
Ok(())
})
.on_window_event(desktop_lifecycle::handle_window_event)
.invoke_handler(tauri::generate_handler![
get_about_info,
check_app_update,
@@ -1091,6 +1411,14 @@ pub fn run() {
toggle_codex_skill,
toggle_codex_mcp,
install_skill_zip,
get_skin_center_state,
enable_skin_theme,
import_skin_theme_zip,
create_skin_theme_from_image,
update_skin_theme_settings,
export_skin_theme,
pause_skin_theme,
restore_skin_theme,
check_skill_updates,
get_startup_diagnostics,
get_session_sync_status,
@@ -1100,6 +1428,8 @@ pub fn run() {
import_ccswitch_codex_providers,
list_saved_prompts,
get_builtin_prompt_status,
get_builtin_prompt_detail,
save_builtin_prompt_override,
refresh_builtin_prompts,
remember_current_instruction,
save_prompt,
@@ -1107,9 +1437,13 @@ pub fn run() {
enable_saved_prompt,
list_saved_providers,
save_provider,
save_active_provider,
delete_saved_provider,
get_codex_state,
switch_official_provider,
get_official_config_draft,
restore_official_provider,
reset_official_provider,
save_official_config,
enable_instruction,
enable_instruction_template,
@@ -1123,8 +1457,10 @@ pub fn run() {
restore_backup,
open_url,
])
.run(tauri::generate_context!())
.expect("error while running Codex-X");
.build(tauri::generate_context!())
.expect("error while building Codex-X");
app.run(desktop_lifecycle::handle_run_event);
}
#[cfg(test)]
+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");
}
}
+195 -20
View File
@@ -6,6 +6,12 @@ use std::process::{Command, Output};
#[cfg(target_os = "windows")]
use std::env;
#[cfg(any(target_os = "windows", test))]
const WINDOWS_CODEX_PACKAGE_IDENTITIES: &[&str] =
&["OpenAI.Codex", "OpenAI.CodexBeta", "OpenAI.ChatGPT-Desktop"];
#[cfg(target_os = "windows")]
const WINDOWS_CODEX_EXECUTABLES: &[&str] = &["ChatGPT.exe", "Codex.exe", "codex.exe"];
fn version_line(stdout: &str, stderr: &str, success: bool) -> Option<String> {
let lines = stdout.lines().chain(stderr.lines()).map(str::trim);
let preferred = lines.clone().find(|line| {
@@ -98,6 +104,62 @@ fn push_candidate(candidates: &mut Vec<PathBuf>, seen: &mut HashSet<String>, pat
}
}
#[cfg(any(target_os = "windows", test))]
fn numeric_version(value: &str) -> Option<Vec<u32>> {
let parts = value
.split('.')
.map(str::parse::<u32>)
.collect::<std::result::Result<Vec<_>, _>>()
.ok()?;
(parts.len() >= 2).then_some(parts)
}
#[cfg(any(target_os = "windows", test))]
fn windows_package_version(package_name: &str) -> Option<(Vec<u32>, String)> {
for identity in WINDOWS_CODEX_PACKAGE_IDENTITIES {
let prefix_len = identity.len();
if !package_name
.get(..prefix_len)
.is_some_and(|prefix| prefix.eq_ignore_ascii_case(identity))
|| package_name.as_bytes().get(prefix_len) != Some(&b'_')
{
continue;
}
let version = package_name.get(prefix_len + 1..)?.split('_').next()?;
return Some((numeric_version(version)?, version.to_string()));
}
None
}
#[cfg(any(target_os = "windows", test))]
fn latest_windows_package_version<'a>(
package_names: impl IntoIterator<Item = &'a str>,
) -> Option<String> {
package_names
.into_iter()
.filter_map(windows_package_version)
.max_by(|left, right| left.0.cmp(&right.0))
.map(|(_, version)| version)
}
#[cfg(target_os = "windows")]
fn windows_store_app_version_from_roots(roots: &[PathBuf]) -> Option<String> {
let mut package_names = Vec::new();
for root in roots {
let Ok(entries) = fs::read_dir(root) else {
continue;
};
package_names.extend(entries.flatten().filter_map(|entry| {
entry
.path()
.is_dir()
.then(|| entry.file_name().to_string_lossy().to_string())
}));
}
latest_windows_package_version(package_names.iter().map(String::as_str))
.map(|version| format!("Codex app {version}"))
}
fn collect_named_files(root: &Path, names: &[&str], depth: usize, output: &mut Vec<PathBuf>) {
if depth == 0 || !root.is_dir() {
return;
@@ -170,8 +232,13 @@ fn platform_candidates(home: &Path) -> Vec<PathBuf> {
PathBuf::from("/Applications/ChatGPT.app/Contents/Resources/codex"),
home.join("Applications/ChatGPT.app/Contents/Resources/codex"),
PathBuf::from("/Applications/Codex.app/Contents/Resources/codex"),
home.join("Applications/Codex.app/Contents/Resources/codex"),
PathBuf::from("/Applications/OpenAI Codex.app/Contents/Resources/codex"),
home.join("Applications/OpenAI Codex.app/Contents/Resources/codex"),
PathBuf::from("/Applications/OpenAI.Codex.app/Contents/Resources/codex"),
home.join("Applications/OpenAI.Codex.app/Contents/Resources/codex"),
PathBuf::from("/Applications/ChatGPT Codex.app/Contents/Resources/codex"),
home.join("Applications/ChatGPT Codex.app/Contents/Resources/codex"),
PathBuf::from("/opt/homebrew/bin/codex"),
PathBuf::from("/usr/local/bin/codex"),
home.join(".local/bin/codex"),
@@ -205,7 +272,9 @@ fn platform_candidates(home: &Path) -> Vec<PathBuf> {
for root in [
localappdata.join("Programs/ChatGPT"),
localappdata.join("Programs/Codex"),
localappdata.join("Programs/OpenAI/Codex"),
localappdata.join("OpenAI/ChatGPT"),
localappdata.join("OpenAI/Codex"),
] {
collect_named_files(&root, &["codex.exe", "codex.cmd"], 7, &mut candidates);
}
@@ -271,38 +340,120 @@ fn windows_where_candidates() -> Vec<PathBuf> {
#[cfg(target_os = "macos")]
fn macos_app_version() -> Option<String> {
for app in [
"/Applications/ChatGPT.app",
"/Applications/Codex.app",
"/Applications/OpenAI Codex.app",
"/Applications/ChatGPT Codex.app",
] {
let Some(output) =
run_program(Path::new("mdls"), &["-name", "kMDItemVersion", "-raw", app])
else {
continue;
};
if !output.status.success() {
continue;
}
let version = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !version.is_empty() && version != "(null)" {
let app_name = if app.ends_with("ChatGPT.app") {
let home = dirs::home_dir().unwrap_or_default();
for root in [PathBuf::from("/Applications"), home.join("Applications")] {
for name in [
"Codex.app",
"OpenAI Codex.app",
"OpenAI.Codex.app",
"ChatGPT Codex.app",
"ChatGPT.app",
] {
let app = root.join(name);
if !app.is_dir() {
continue;
}
let app_name = if name == "ChatGPT.app" {
"ChatGPT app"
} else {
"Codex app"
};
return Some(format!("{app_name} {version}"));
if let Some(version) = macos_info_plist_version(&app).or_else(|| {
let app = app.to_str()?;
let output =
run_program(Path::new("mdls"), &["-name", "kMDItemVersion", "-raw", app])?;
output
.status
.success()
.then(|| String::from_utf8_lossy(&output.stdout).trim().to_string())
}) {
if !version.is_empty() && version != "(null)" {
return Some(format!("{app_name} {version}"));
}
}
return Some(format!("{app_name} installed"));
}
}
None
}
#[cfg(target_os = "macos")]
fn macos_info_plist_version(app: &Path) -> Option<String> {
let plist = fs::read_to_string(app.join("Contents/Info.plist")).ok()?;
plist_string_value(&plist, "CFBundleShortVersionString")
.or_else(|| plist_string_value(&plist, "CFBundleVersion"))
}
#[cfg(any(target_os = "macos", test))]
fn plist_string_value(plist: &str, key: &str) -> Option<String> {
let (_, after_key) = plist.split_once(&format!("<key>{key}</key>"))?;
let (_, after_open) = after_key.split_once("<string>")?;
let (value, _) = after_open.split_once("</string>")?;
let value = value.trim();
(!value.is_empty()).then(|| value.to_string())
}
#[cfg(not(target_os = "macos"))]
fn macos_app_version() -> Option<String> {
None
}
#[cfg(target_os = "windows")]
fn windows_app_version() -> Option<String> {
let mut roots = Vec::new();
for variable in ["ProgramFiles", "ProgramW6432"] {
if let Ok(program_files) = env::var(variable) {
roots.push(PathBuf::from(program_files).join("WindowsApps"));
}
}
roots.push(PathBuf::from(r"C:\Program Files\WindowsApps"));
roots.sort();
roots.dedup();
if let Some(version) = windows_store_app_version_from_roots(&roots) {
return Some(version);
}
let script = "Get-AppxPackage | Where-Object { $_.Name -in @('OpenAI.Codex','OpenAI.CodexBeta','OpenAI.ChatGPT-Desktop') } | ForEach-Object { $_.Version.ToString() }";
if let Some(output) = run_program(
Path::new("powershell.exe"),
&["-NoProfile", "-NonInteractive", "-Command", script],
) {
if output.status.success() {
let versions = String::from_utf8_lossy(&output.stdout)
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.filter_map(|version| {
numeric_version(version).map(|parsed| (parsed, version.to_string()))
})
.max_by(|left, right| left.0.cmp(&right.0));
if let Some((_, version)) = versions {
return Some(format!("Codex app {version}"));
}
}
}
let local_appdata = env::var("LOCALAPPDATA").ok().map(PathBuf::from)?;
for directory in [
local_appdata.join("OpenAI/Codex/bin"),
local_appdata.join("OpenAI/Codex"),
local_appdata.join("Programs/OpenAI/Codex"),
local_appdata.join("Programs/Codex"),
] {
if WINDOWS_CODEX_EXECUTABLES.iter().any(|name| {
directory.join(name).is_file() || directory.join("app").join(name).is_file()
}) {
return Some("Codex app installed".to_string());
}
}
None
}
#[cfg(not(target_os = "windows"))]
fn windows_app_version() -> Option<String> {
None
}
pub fn codex_executable_candidates() -> Vec<PathBuf> {
let home = dirs::home_dir().unwrap_or_default();
let mut candidates = ["codex", "codex.exe", "codex.cmd"]
@@ -328,12 +479,12 @@ pub fn detect_codex_version() -> Option<String> {
}
}
}
macos_app_version()
macos_app_version().or_else(windows_app_version)
}
#[cfg(test)]
mod tests {
use super::version_line;
use super::{latest_windows_package_version, plist_string_value, version_line};
#[test]
fn version_parser_prefers_codex_line_over_warning() {
@@ -363,4 +514,28 @@ mod tests {
None
);
}
#[test]
fn windows_package_detection_accepts_supported_codex_packages() {
assert_eq!(
latest_windows_package_version([
"OpenAI.Codex_1.2.3.4_x64__publisher",
"OpenAI.CodexBeta_1.3.0.0_x64__publisher",
"Other.App_99.0.0.0_x64__publisher",
]),
Some("1.3.0.0".to_string())
);
}
#[test]
fn plist_parser_reads_codex_bundle_version() {
let plist = r#"<plist><dict>
<key>CFBundleShortVersionString</key>
<string>1.2026.204</string>
</dict></plist>"#;
assert_eq!(
plist_string_value(plist, "CFBundleShortVersionString").as_deref(),
Some("1.2026.204")
);
}
}
+278 -37
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,
@@ -140,6 +143,42 @@ pub(crate) fn stable_remote_prompt_id(filename: &str) -> String {
}
fn prompt_display_meta(filename: &str) -> (String, String, String) {
let curated = match filename.to_ascii_lowercase().as_str() {
"software-development-maintainer.md" => Some((
"长期维护工程师",
"正式项目的最小改动、复用、测试与安全规范",
"软件开发",
)),
"software-development-debugging.md" => Some((
"系统化调试与根因修复",
"从稳定复现、证据采集到最小修复与回归验证",
"软件开发",
)),
"software-development-code-review.md" => Some((
"严格代码审查",
"按严重级别发现缺陷、回归、安全风险与测试缺口",
"软件开发",
)),
"writing-clarity-editor.md" => Some((
"清晰表达与润色",
"保留原意与事实,优化中文或英文的结构、语气和可读性",
"写作辅助",
)),
"writing-technical-docs.md" => Some((
"技术文档写作",
"基于代码和事实编写 README、指南、API 与变更文档",
"写作辅助",
)),
"writing-structured-draft.md" => Some((
"结构化长文起草",
"把零散材料整理为提纲清晰、论证连贯的完整初稿",
"写作辅助",
)),
_ => None,
};
if let Some((title, subtitle, badge)) = curated {
return (title.to_string(), subtitle.to_string(), badge.to_string());
}
if let Some(meta) = bundled_prompt_metas()
.into_iter()
.find(|item| item.filename.eq_ignore_ascii_case(filename))
@@ -455,6 +494,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 {
@@ -470,6 +525,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,
}
}
@@ -526,6 +582,7 @@ fn refresh_builtin_prompt_from_source(
"已是最新在线模板"
}
.to_string(),
customized: false,
})
}
Err(_) => {
@@ -557,6 +614,7 @@ fn refresh_builtin_prompt_from_source(
"在线模板暂时不可用,且没有本地副本"
}
.to_string(),
customized: false,
})
}
}
@@ -576,9 +634,54 @@ 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 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> {
@@ -621,7 +724,10 @@ 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(
@@ -630,38 +736,26 @@ pub(crate) fn refresh_builtin_prompts_with_active(
let _cache_guard = BUILTIN_PROMPT_CACHE_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()
@@ -674,29 +768,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);
}
@@ -717,6 +828,7 @@ pub(crate) fn refresh_builtin_prompts_with_active(
if catalog_confirmation_failed {
mark_catalog_confirmation_failed(&mut statuses);
}
mark_customized_prompt_statuses(&mut statuses, &customized_ids);
let order = bundled_prompt_metas()
.into_iter()
.enumerate()
@@ -752,6 +864,14 @@ 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}")))?;
if let Some(content) = builtin_prompt_override_inner(id)? {
return Ok((
filename.clone(),
format!("./{filename}"),
content,
"本地已修改".to_string(),
));
}
let trust = PromptContentTrust {
cached: cached.as_ref().map(|item| item.content.as_str()),
bundled: bundled.map(|item| item.content),
@@ -784,6 +904,23 @@ 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::*;
@@ -853,6 +990,110 @@ mod tests {
std::fs::remove_file(database_path).expect("remove prompt cache database");
}
#[test]
fn curated_remote_prompts_have_user_facing_metadata() {
assert_eq!(
prompt_display_meta("software-development-maintainer.md"),
(
"长期维护工程师".to_string(),
"正式项目的最小改动、复用、测试与安全规范".to_string(),
"软件开发".to_string(),
)
);
assert_eq!(
prompt_display_meta("writing-technical-docs.md").2,
"写作辅助"
);
}
#[test]
fn curated_remote_prompt_ids_retain_category_prefixes() {
assert!(stable_remote_prompt_id("software-development-debugging.md")
.starts_with("github-software-development-debugging-"));
assert!(stable_remote_prompt_id("writing-clarity-editor.md")
.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)]
@@ -1,7 +1,8 @@
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, upsert_ccswitch_provider_on_connection,
ProviderUpsertKind, SavedProvider,
};
use crate::ccswitch::{ccswitch_db_candidates, default_ccswitch_db_path};
use crate::error::{CodexxError, Result};
@@ -314,6 +315,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,11 +329,8 @@ 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,
@@ -348,6 +347,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 +385,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
File diff suppressed because it is too large Load Diff
+25 -14
View File
@@ -1,6 +1,7 @@
mod ccswitch;
mod connection;
mod live;
mod official_auth;
mod store;
use crate::error::Result;
@@ -21,29 +22,39 @@ pub(crate) use connection::{
fetch_provider_models_inner, test_provider_connection_inner, ProviderConnectionResult,
ProviderModelsResult,
};
pub(crate) use live::detected_live_custom_provider;
pub(crate) use live::{
delete_saved_provider_inner, reset_official_provider_inner, restore_official_provider_inner,
save_active_provider_inner, save_official_config_inner, save_provider_toml_config_inner,
switch_official_provider_inner, switch_provider_inner, OfficialConfigInput, ProviderInput,
ProviderTomlInput,
};
#[cfg(test)]
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,
save_provider_toml_config_with_pre_persist, switch_official_provider_with_pre_persist,
switch_provider_with_pre_persist,
};
pub(crate) use live::{
save_official_config_inner, save_provider_toml_config_inner, switch_official_provider_inner,
switch_provider_inner, OfficialConfigInput, ProviderInput, ProviderTomlInput,
#[cfg(test)]
pub(crate) use official_auth::official_snapshot_path_for_test;
pub(crate) use official_auth::{
capture_live_chatgpt_config, document_is_official, get_official_config_draft_inner,
official_auth_available, OfficialConfigDraft,
};
#[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, experimental_bearer_token_from_doc, list_saved_providers_inner,
list_saved_providers_on_connection, matching_saved_provider_ids_for_live,
normalize_saved_provider, provider_template_from_document, reserved_codex_provider_id,
rollback_provider_store_inner, save_provider_inner, save_provider_with_rollback_inner,
unique_saved_provider_id_for_live, upsert_ccswitch_provider_on_connection, 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()
}
@@ -0,0 +1,419 @@
use crate::backups::{action_backup_root, BackupMeta};
use crate::error::{CodexxError, Result};
use crate::file_io::{ensure_directory, io_err, json_err, parse_toml_document, write_private_json};
use crate::paths::app_home;
use crate::{auth_path, config_path, string_value};
use chrono::Local;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use sha2::{Digest, Sha256};
use std::fs;
use std::path::{Path, PathBuf};
const SNAPSHOT_VERSION: u32 = 2;
const LEGACY_SNAPSHOT_VERSION: u32 = 1;
#[derive(Debug, Clone)]
pub(crate) struct OfficialConfigCandidate {
pub(crate) auth: Value,
pub(crate) model: Option<String>,
pub(crate) source: String,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct OfficialConfigDraft {
auth_json: String,
model: Option<String>,
source: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct OfficialConfigSnapshot {
version: u32,
codex_dir: String,
captured_at: String,
model: Option<String>,
#[serde(default)]
auth: Option<Value>,
}
enum SnapshotState {
Missing,
Reset,
Ready(OfficialConfigCandidate),
}
fn canonical_identity(path: &Path) -> String {
fs::canonicalize(path)
.unwrap_or_else(|_| path.to_path_buf())
.to_string_lossy()
.to_string()
}
pub(crate) fn official_snapshot_path(codex_dir: &Path) -> Result<PathBuf> {
let identity = canonical_identity(codex_dir);
let digest = Sha256::digest(identity.as_bytes());
Ok(app_home()?
.join("official-configs")
.join(format!("{digest:x}.json")))
}
fn value_has_material(value: &Value) -> bool {
match value {
Value::Null => false,
Value::String(value) => !value.trim().is_empty(),
Value::Array(values) => values.iter().any(value_has_material),
Value::Object(values) => values.values().any(value_has_material),
Value::Bool(_) | Value::Number(_) => true,
}
}
pub(crate) fn auth_value_has_material(value: &Value) -> bool {
value.as_object().is_some_and(|auth| {
auth.iter()
.filter(|(key, _)| key.as_str() != "auth_mode")
.any(|(_, value)| value_has_material(value))
})
}
fn is_chatgpt_auth(value: &Value) -> bool {
let chatgpt_mode = value
.get("auth_mode")
.and_then(Value::as_str)
.is_some_and(|mode| mode.eq_ignore_ascii_case("chatgpt"));
let has_api_key = value
.get("OPENAI_API_KEY")
.and_then(Value::as_str)
.is_some_and(|key| !key.trim().is_empty());
chatgpt_mode
&& !has_api_key
&& value
.get("tokens")
.and_then(Value::as_object)
.is_some_and(|tokens| {
["access_token", "refresh_token", "id_token"]
.iter()
.any(|key| {
tokens
.get(*key)
.and_then(Value::as_str)
.is_some_and(|value| !value.trim().is_empty())
})
})
}
fn has_openai_api_key(value: &Value) -> bool {
value
.get("OPENAI_API_KEY")
.and_then(Value::as_str)
.is_some_and(|key| !key.trim().is_empty())
}
fn read_auth_value(path: &Path) -> Result<Option<Value>> {
if !path.is_file() {
return Ok(None);
}
let text = fs::read_to_string(path).map_err(|error| io_err(path, error))?;
let value: Value = serde_json::from_str(&text).map_err(|error| json_err(path, error))?;
if !value.is_object() || !auth_value_has_material(&value) {
return Ok(None);
}
Ok(Some(value))
}
fn official_model(codex_dir: &Path) -> Result<Option<String>> {
let path = config_path(codex_dir);
if !path.is_file() {
return Ok(None);
}
let text = fs::read_to_string(&path).map_err(|error| io_err(&path, error))?;
let doc = parse_toml_document(&path, &text)?;
Ok(string_value(&doc, "model"))
}
pub(crate) fn live_config_is_official(codex_dir: &Path) -> Result<bool> {
let path = config_path(codex_dir);
if !path.is_file() {
return Ok(true);
}
let text = fs::read_to_string(&path).map_err(|error| io_err(&path, error))?;
let doc = parse_toml_document(&path, &text)?;
Ok(document_is_official(&doc))
}
pub(crate) fn document_is_official(doc: &toml_edit::DocumentMut) -> bool {
let Some(provider) = string_value(doc, "model_provider") else {
return true;
};
if provider.eq_ignore_ascii_case("openai") {
return true;
}
if provider != "custom" {
return false;
}
doc.get("model_providers")
.and_then(|item| item.as_table())
.and_then(|providers| providers.get("custom"))
.and_then(|item| item.as_table())
.is_some_and(|table| {
let has_no_endpoint = table
.get("base_url")
.and_then(|item| item.as_str())
.is_none_or(|value| value.trim().is_empty());
let is_openai = table
.get("name")
.and_then(|item| item.as_str())
.is_some_and(|value| value.trim().eq_ignore_ascii_case("openai"));
has_no_endpoint
&& is_openai
&& table
.get("requires_openai_auth")
.and_then(|item| item.as_bool())
== Some(true)
})
}
fn write_snapshot(codex_dir: &Path, model: Option<String>, auth: Option<Value>) -> Result<()> {
let path = official_snapshot_path(codex_dir)?;
if let Some(parent) = path.parent() {
ensure_directory(parent)?;
}
let snapshot = OfficialConfigSnapshot {
version: SNAPSHOT_VERSION,
codex_dir: canonical_identity(codex_dir),
captured_at: Local::now().to_rfc3339(),
model,
auth,
};
let value = serde_json::to_value(snapshot)
.map_err(|error| CodexxError::Config(format!("序列化官方配置快照失败: {error}")))?;
write_private_json(&path, &value)
}
pub(crate) fn save_official_config_snapshot(
codex_dir: &Path,
model: Option<String>,
auth: &Value,
) -> Result<()> {
if !auth.is_object() || !auth_value_has_material(auth) {
return Err(CodexxError::Config(
"官方 auth.json 没有可用认证信息,请先完成官方登录".to_string(),
));
}
write_snapshot(codex_dir, model, Some(auth.clone()))
}
pub(crate) fn mark_official_config_reset(codex_dir: &Path, model: Option<String>) -> Result<()> {
write_snapshot(codex_dir, model, None)
}
pub(crate) fn capture_live_official_config_before_provider_switch(
codex_dir: &Path,
) -> Result<bool> {
capture_live_official_auth(codex_dir, |auth| {
is_chatgpt_auth(auth) || has_openai_api_key(auth)
})
}
pub(crate) fn capture_live_chatgpt_config(codex_dir: &Path) -> Result<bool> {
capture_live_official_auth(codex_dir, is_chatgpt_auth)
}
fn capture_live_official_auth(
codex_dir: &Path,
is_trusted: impl FnOnce(&Value) -> bool,
) -> Result<bool> {
if !live_config_is_official(codex_dir)? {
return Ok(false);
}
let Some(auth) = read_auth_value(&auth_path(codex_dir))? else {
return Ok(false);
};
if !is_trusted(&auth) {
return Ok(false);
}
save_official_config_snapshot(codex_dir, official_model(codex_dir)?, &auth)?;
Ok(true)
}
fn load_snapshot(codex_dir: &Path) -> Result<SnapshotState> {
let path = official_snapshot_path(codex_dir)?;
if !path.is_file() {
return Ok(SnapshotState::Missing);
}
let text = fs::read_to_string(&path).map_err(|error| io_err(&path, error))?;
let snapshot: OfficialConfigSnapshot =
serde_json::from_str(&text).map_err(|error| json_err(&path, error))?;
if !matches!(snapshot.version, SNAPSHOT_VERSION | LEGACY_SNAPSHOT_VERSION)
|| snapshot.codex_dir != canonical_identity(codex_dir)
{
return Err(CodexxError::Config(format!(
"官方配置快照与当前 CODEX_HOME 不匹配: {}",
path.display()
)));
}
let Some(auth) = snapshot.auth else {
return Ok(SnapshotState::Reset);
};
if !auth.is_object() || !auth_value_has_material(&auth) {
return Err(CodexxError::Config(format!(
"官方配置快照不包含可用认证: {}",
path.display()
)));
}
// Version 1 could be populated automatically from a proxy API key. Its
// API-key-only snapshots are ambiguous, so never restore or promote them.
if snapshot.version == LEGACY_SNAPSHOT_VERSION && !is_chatgpt_auth(&auth) {
return Ok(SnapshotState::Missing);
}
Ok(SnapshotState::Ready(OfficialConfigCandidate {
auth,
model: snapshot.model,
source: "Codex-X 官方配置快照".to_string(),
}))
}
fn backup_config_is_official(dir: &Path, meta: &BackupMeta) -> bool {
if !meta.had_config {
return true;
}
let path = dir.join("config.toml");
let Ok(text) = fs::read_to_string(&path) else {
return false;
};
let Ok(doc) = parse_toml_document(&path, &text) else {
return false;
};
document_is_official(&doc)
}
fn backup_model(dir: &Path, meta: &BackupMeta) -> Option<String> {
if !meta.had_config {
return None;
}
let path = dir.join("config.toml");
let text = fs::read_to_string(&path).ok()?;
let doc = parse_toml_document(&path, &text).ok()?;
string_value(&doc, "model")
}
fn latest_official_backup(codex_dir: &Path) -> Result<Option<OfficialConfigCandidate>> {
let root = action_backup_root(codex_dir)?;
if !root.is_dir() {
return Ok(None);
}
let identity = canonical_identity(codex_dir);
let mut candidates = Vec::new();
for entry in fs::read_dir(&root).map_err(|error| io_err(&root, error))? {
let entry = entry.map_err(|error| io_err(&root, error))?;
let dir = entry.path();
if !dir.is_dir() {
continue;
}
let meta_path = dir.join("meta.json");
let Ok(meta_text) = fs::read_to_string(&meta_path) else {
continue;
};
let Ok(meta) = serde_json::from_str::<BackupMeta>(&meta_text) else {
continue;
};
if !meta.had_auth
|| canonical_identity(Path::new(&meta.codex_dir)) != identity
|| !backup_config_is_official(&dir, &meta)
{
continue;
}
let Ok(Some(auth)) = read_auth_value(&dir.join("auth.json")) else {
continue;
};
// Old Codex-X versions could mark config.toml as official while leaving
// a proxy API key in auth.json. Historical auto-recovery therefore only
// trusts unambiguous ChatGPT login backups. Official API keys remain
// supported through an explicit Codex-X snapshot/save.
if !is_chatgpt_auth(&auth) {
continue;
}
candidates.push((meta.created_at.clone(), dir, meta, auth));
}
candidates.sort_by(|left, right| right.0.cmp(&left.0));
Ok(candidates
.into_iter()
.next()
.map(|(_, dir, meta, auth)| OfficialConfigCandidate {
auth,
model: backup_model(&dir, &meta),
source: format!("Codex-X 历史备份 {}", meta.created_at),
}))
}
fn live_chatgpt_candidate(codex_dir: &Path) -> Result<Option<OfficialConfigCandidate>> {
let Some(auth) = read_auth_value(&auth_path(codex_dir))? else {
return Ok(None);
};
if !is_chatgpt_auth(&auth) {
return Ok(None);
}
let model = if live_config_is_official(codex_dir)? {
official_model(codex_dir)?
} else {
None
};
Ok(Some(OfficialConfigCandidate {
auth,
model,
source: "当前 ChatGPT 官方登录".to_string(),
}))
}
pub(crate) fn official_config_candidate(
codex_dir: &Path,
include_history_after_reset: bool,
) -> Result<Option<OfficialConfigCandidate>> {
match load_snapshot(codex_dir)? {
SnapshotState::Ready(candidate) => return Ok(Some(candidate)),
SnapshotState::Reset if !include_history_after_reset => return Ok(None),
SnapshotState::Missing | SnapshotState::Reset => {}
}
if let Some(candidate) = live_chatgpt_candidate(codex_dir)? {
return Ok(Some(candidate));
}
latest_official_backup(codex_dir)
}
pub(crate) fn official_auth_available(codex_dir: &Path) -> Result<bool> {
match load_snapshot(codex_dir)? {
SnapshotState::Ready(_) => return Ok(true),
SnapshotState::Reset => return Ok(false),
SnapshotState::Missing => {}
}
if let Some(auth) = read_auth_value(&auth_path(codex_dir))? {
if is_chatgpt_auth(&auth) {
return Ok(true);
}
}
Ok(latest_official_backup(codex_dir)?.is_some())
}
pub(crate) fn get_official_config_draft_inner(
config_dir: Option<String>,
) -> Result<Option<OfficialConfigDraft>> {
let codex_dir = crate::resolve_codex_dir(config_dir)?;
let Some(candidate) = official_config_candidate(&codex_dir, true)? else {
return Ok(None);
};
let auth_json = serde_json::to_string_pretty(&candidate.auth)
.map_err(|error| CodexxError::Config(format!("格式化官方配置快照失败: {error}")))?;
Ok(Some(OfficialConfigDraft {
auth_json,
model: candidate.model,
source: candidate.source,
}))
}
#[cfg(test)]
pub(crate) fn official_snapshot_path_for_test(codex_dir: &Path) -> Result<PathBuf> {
official_snapshot_path(codex_dir)
}
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)?);
}
+12 -27
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;
@@ -387,30 +388,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 +909,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 +926,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");
}
}
+445 -75
View File
@@ -1,4 +1,4 @@
use super::global_state::{normalize_workspace_path, projectless_thread_ids};
use super::global_state::normalize_workspace_path;
use super::types::{RolloutScan, SessionFileChange, SessionPreview, SqliteScan};
use crate::error::{CodexxError, Result};
use crate::file_io::{
@@ -9,7 +9,7 @@ use crate::sqlite_utils::{sql_select_column, sqlite_has_table, table_column_set}
use crate::{config_path, string_value};
use rusqlite::{Connection, OpenFlags};
use serde_json::Value;
use std::collections::HashSet;
use std::collections::{HashMap, HashSet};
use std::fs;
use std::io::Read;
use std::path::{Path, PathBuf};
@@ -35,26 +35,130 @@ fn is_rollout_file(path: &Path) -> bool {
.is_some_and(|name| name.starts_with("rollout-") && name.ends_with(".jsonl"))
}
fn collect_rollout_paths(root: &Path, out: &mut Vec<PathBuf>, warnings: &mut Vec<String>) {
let Ok(entries) = fs::read_dir(root) else {
if root.exists() {
warnings.push(format!("无法读取目录: {}", root.display()));
pub(super) 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"))
})
}
pub(super) 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()
}
pub(super) 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 referenced_rollout_paths(
codex_dir: &Path,
rollout_paths_by_thread_id: &HashMap<String, String>,
failures: &mut Vec<String>,
) -> HashMap<PathBuf, HashSet<String>> {
let mut referenced = HashMap::<PathBuf, HashSet<String>>::new();
for (thread_id, value) in rollout_paths_by_thread_id {
let raw = PathBuf::from(value.trim());
let path = if raw.is_absolute() {
raw
} else {
codex_dir.join(raw)
};
let metadata = match fs::symlink_metadata(&path) {
Ok(metadata) => metadata,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
failures.push(format!(
"活动 SQLite 引用的会话文件不存在: {}",
path.display()
));
continue;
}
Err(error) => {
failures.push(format!(
"无法检查活动会话文件: {} ({error})",
path.display()
));
continue;
}
};
if metadata.file_type().is_symlink() || !metadata.is_file() || !is_rollout_file(&path) {
failures.push(format!(
"活动 SQLite 引用了不受支持的会话文件: {}",
path.display()
));
continue;
}
let canonical = match path.canonicalize() {
Ok(canonical) => canonical,
Err(error) => {
failures.push(format!(
"无法解析活动会话文件路径: {} ({error})",
path.display()
));
continue;
}
};
if !is_canonical_rollout_storage_path(codex_dir, &canonical) {
failures.push(format!(
"活动 SQLite 引用的会话文件超出 Codex 会话目录: {}",
path.display()
));
continue;
}
let expected_thread_ids = referenced.entry(canonical.clone()).or_default();
expected_thread_ids.insert(thread_id.clone());
if expected_thread_ids.len() > 1 {
failures.push(format!(
"活动 SQLite 的多个线程引用了同一个会话文件: {}",
canonical.display()
));
}
}
referenced
}
fn collect_rollout_paths(root: &Path, out: &mut Vec<PathBuf>, failures: &mut Vec<String>) {
let entries = match fs::read_dir(root) {
Ok(entries) => entries,
Err(error) => {
if error.kind() != std::io::ErrorKind::NotFound {
failures.push(format!("无法读取会话目录: {} ({error})", root.display()));
}
return;
}
return;
};
for entry in entries {
let Ok(entry) = entry else {
continue;
let entry = match entry {
Ok(entry) => entry,
Err(error) => {
failures.push(format!("无法读取会话目录项: {} ({error})", root.display()));
continue;
}
};
let path = entry.path();
let Ok(file_type) = entry.file_type() else {
continue;
let file_type = match entry.file_type() {
Ok(file_type) => file_type,
Err(error) => {
failures.push(format!(
"无法读取会话文件类型: {} ({error})",
path.display()
));
continue;
}
};
if file_type.is_symlink() {
continue;
}
if file_type.is_dir() {
collect_rollout_paths(&path, out, warnings);
collect_rollout_paths(&path, out, failures);
} else if file_type.is_file() && is_rollout_file(&path) {
out.push(path);
}
@@ -77,27 +181,87 @@ fn is_locked_io_error(error: &std::io::Error) -> bool {
}
pub(crate) fn scan_rollouts(codex_dir: &Path, target_provider: &str) -> Result<RolloutScan> {
scan_rollouts_with_thread_filter(codex_dir, target_provider, None, None)
}
pub(super) fn scan_rollouts_for_thread_ids(
codex_dir: &Path,
target_provider: &str,
thread_ids: &HashSet<String>,
rollout_paths_by_thread_id: &HashMap<String, String>,
) -> Result<RolloutScan> {
scan_rollouts_with_thread_filter(
codex_dir,
target_provider,
Some(thread_ids),
Some(rollout_paths_by_thread_id),
)
}
fn scan_rollouts_with_thread_filter(
codex_dir: &Path,
target_provider: &str,
allowed_thread_ids: Option<&HashSet<String>>,
rollout_paths_by_thread_id: Option<&HashMap<String, String>>,
) -> Result<RolloutScan> {
let mut paths = Vec::new();
let mut scan = RolloutScan::default();
let mut referenced = HashMap::new();
for dir in ["sessions", "archived_sessions"] {
collect_rollout_paths(&codex_dir.join(dir), &mut paths, &mut scan.warnings);
collect_rollout_paths(&codex_dir.join(dir), &mut paths, &mut scan.scan_failures);
}
paths.sort();
scan.discovered_rollout_files = paths.len();
if let Some(allowed_thread_ids) = allowed_thread_ids {
let empty_rollout_paths = HashMap::new();
let rollout_paths_by_thread_id = rollout_paths_by_thread_id.unwrap_or(&empty_rollout_paths);
let explicitly_referenced_thread_ids = rollout_paths_by_thread_id
.keys()
.filter(|id| allowed_thread_ids.contains(*id))
.cloned()
.collect::<HashSet<_>>();
referenced = referenced_rollout_paths(
codex_dir,
rollout_paths_by_thread_id,
&mut scan.scan_failures,
);
paths.retain(|path| {
let is_unreferenced_thread_rollout = allowed_thread_ids
.iter()
.filter(|id| !explicitly_referenced_thread_ids.contains(*id))
.any(|id| rollout_filename_matches_id(path, id));
is_unreferenced_thread_rollout
|| path
.canonicalize()
.is_ok_and(|canonical| referenced.contains_key(&canonical))
});
}
scan.rollout_files = paths.len();
for path in paths {
let expected_thread_ids = path
.canonicalize()
.ok()
.and_then(|canonical| referenced.get(&canonical));
let text = match fs::read_to_string(&path) {
Ok(text) => text,
Err(error) if is_locked_io_error(&error) => {
scan.warnings
.push(format!("跳过被占用/无权限会话文件: {}", path.display()));
Err(error) => {
let reason = if is_locked_io_error(&error) {
"会话文件被占用或无权限读取"
} else {
"无法读取会话文件"
};
scan.scan_failures
.push(format!("{reason}: {} ({error})", path.display()));
continue;
}
Err(error) => return Err(io_err(&path, error)),
};
let mut next_text = String::with_capacity(text.len());
let mut rewrite_needed = false;
let mut file_session_meta_count = 0usize;
let mut file_mismatched_session_meta = 0usize;
let mut invalid_json_lines = 0usize;
let mut invalid_session_meta = 0usize;
let mut thread_id = None;
let mut cwd = None;
@@ -111,7 +275,6 @@ pub(crate) fn scan_rollouts(codex_dir: &Path, target_provider: &str) -> Result<R
record.get_mut("payload").and_then(Value::as_object_mut)
{
file_session_meta_count += 1;
scan.session_meta_count += 1;
if thread_id.is_none() {
thread_id = payload
.get("id")
@@ -134,26 +297,69 @@ pub(crate) fn scan_rollouts(codex_dir: &Path, target_provider: &str) -> Result<R
next_line = serde_json::to_string(&record)
.map_err(|error| json_err(&path, error))?;
rewrite_needed = true;
scan.mismatched_session_meta += 1;
file_mismatched_session_meta += 1;
}
} else {
invalid_session_meta += 1;
}
}
} else {
invalid_json_lines += 1;
}
}
next_text.push_str(&next_line);
next_text.push_str(line_ending);
}
if invalid_json_lines > 0 {
scan.scan_failures.push(format!(
"会话文件包含 {invalid_json_lines} 行无法解析的 JSON: {}",
path.display()
));
}
if invalid_session_meta > 0 {
scan.scan_failures.push(format!(
"会话文件包含 {invalid_session_meta} 条无法读取的 session_meta: {}",
path.display()
));
}
if file_session_meta_count == 0 {
if expected_thread_ids.is_some() {
scan.scan_failures.push(format!(
"活动 SQLite 引用的会话文件缺少 session_meta: {}",
path.display()
));
}
continue;
}
if let Some(thread_id) = thread_id {
if let Some(cwd) = cwd {
scan.cwd_by_thread_id.insert(thread_id, cwd);
let Some(thread_id) = thread_id else {
scan.scan_failures.push(format!(
"会话文件的 session_meta 缺少 id: {}",
path.display()
));
continue;
};
if let Some(expected_thread_ids) = expected_thread_ids {
if expected_thread_ids.len() != 1 || !expected_thread_ids.contains(&thread_id) {
scan.scan_failures.push(format!(
"活动 SQLite 引用的会话文件与线程 ID 不一致: {}",
path.display()
));
continue;
}
}
if allowed_thread_ids.is_some_and(|allowed| !allowed.contains(&thread_id)) {
continue;
}
scan.session_meta_count += file_session_meta_count;
if let Some(cwd) = cwd {
scan.cwd_by_thread_id.insert(thread_id.clone(), cwd);
}
if rewrite_needed {
scan.mismatched_rollouts += 1;
scan.mismatched_session_meta += file_mismatched_session_meta;
scan.mismatched_thread_ids.insert(thread_id);
scan.changes.push(SessionFileChange {
original_mtime: fs::metadata(&path)
.and_then(|metadata| metadata.modified())
@@ -164,9 +370,6 @@ pub(crate) fn scan_rollouts(codex_dir: &Path, target_provider: &str) -> Result<R
});
}
}
let projectless = projectless_thread_ids(&codex_dir.join(".codex-global-state.json"))?;
scan.cwd_by_thread_id
.retain(|thread_id, _| !projectless.contains(thread_id));
Ok(scan)
}
@@ -307,6 +510,7 @@ fn configured_sqlite_home(codex_dir: &Path) -> Option<PathBuf> {
#[derive(Debug)]
struct SqliteStorageRoot {
path: PathBuf,
is_active: bool,
active_priority: usize,
session_priority: usize,
allow_custom_names: bool,
@@ -314,9 +518,11 @@ struct SqliteStorageRoot {
fn sqlite_storage_roots(codex_dir: &Path) -> Vec<SqliteStorageRoot> {
let mut roots = Vec::new();
if let Some(configured) = configured_sqlite_home(codex_dir) {
let configured = configured_sqlite_home(codex_dir);
if let Some(configured) = configured.as_ref() {
roots.push(SqliteStorageRoot {
path: configured,
path: configured.clone(),
is_active: true,
active_priority: 0,
session_priority: 0,
allow_custom_names: true,
@@ -324,12 +530,14 @@ fn sqlite_storage_roots(codex_dir: &Path) -> Vec<SqliteStorageRoot> {
}
roots.push(SqliteStorageRoot {
path: codex_dir.to_path_buf(),
is_active: configured.is_none(),
active_priority: 1,
session_priority: 2,
allow_custom_names: false,
});
roots.push(SqliteStorageRoot {
path: codex_dir.join("sqlite"),
is_active: false,
active_priority: 2,
session_priority: 1,
allow_custom_names: true,
@@ -364,6 +572,7 @@ pub(super) struct SqliteDiscovery {
pub(super) session_paths: Vec<PathBuf>,
pub(super) related_paths: Vec<PathBuf>,
pub(super) unreadable_paths: Vec<PathBuf>,
pub(super) active_scan_failures: Vec<String>,
}
impl SqliteDiscovery {
@@ -379,6 +588,7 @@ impl SqliteDiscovery {
#[derive(Debug)]
struct DiscoveredSqlite {
path: PathBuf,
is_active: bool,
active_priority: usize,
session_priority: usize,
state_version: Option<u64>,
@@ -462,6 +672,17 @@ fn primary_paths_first(primary: &[PathBuf], paths: &[PathBuf]) -> Vec<PathBuf> {
ordered
}
fn compare_sqlite_filenames(left: &Path, right: &Path) -> std::cmp::Ordering {
left.file_name()
.is_none_or(|name| name != std::ffi::OsStr::new("codex-dev.db"))
.cmp(
&right
.file_name()
.is_none_or(|name| name != std::ffi::OsStr::new("codex-dev.db")),
)
.then_with(|| left.file_name().cmp(&right.file_name()))
}
pub(super) fn ensure_sqlite_discovery_writable(discovery: &SqliteDiscovery) -> Result<()> {
if discovery.unreadable_paths.is_empty() {
Ok(())
@@ -484,18 +705,7 @@ fn ordered_database_paths(
matches.sort_by(|left, right| {
priority(left)
.cmp(&priority(right))
.then_with(|| {
left.path
.file_name()
.is_none_or(|name| name != std::ffi::OsStr::new("codex-dev.db"))
.cmp(
&right
.path
.file_name()
.is_none_or(|name| name != std::ffi::OsStr::new("codex-dev.db")),
)
})
.then_with(|| left.path.file_name().cmp(&right.path.file_name()))
.then_with(|| compare_sqlite_filenames(&left.path, &right.path))
});
matches
.into_iter()
@@ -507,20 +717,56 @@ pub(super) fn discover_sqlite_databases(codex_dir: &Path) -> SqliteDiscovery {
let mut databases = Vec::new();
let mut seen_paths = HashSet::new();
let mut unreadable_paths = Vec::new();
let mut active_scan_failures = Vec::new();
for root in sqlite_storage_roots(codex_dir) {
let Ok(entries) = fs::read_dir(&root.path) else {
continue;
let entries = match fs::read_dir(&root.path) {
Ok(entries) => entries,
Err(error) => {
if root.is_active && error.kind() != std::io::ErrorKind::NotFound {
active_scan_failures.push(format!(
"无法读取当前会话数据库目录: {} ({error})",
root.path.display()
));
}
continue;
}
};
let mut paths = entries
.flatten()
.filter_map(|entry| {
let file_type = entry.file_type().ok()?;
(file_type.is_file() && !file_type.is_symlink()).then(|| entry.path())
})
.filter(|path| is_sqlite_file(path))
.filter(|path| root.allow_custom_names || is_root_codex_sqlite_file(path))
.collect::<Vec<_>>();
let mut paths = Vec::new();
for entry in entries {
let entry = match entry {
Ok(entry) => entry,
Err(error) => {
if root.is_active {
active_scan_failures.push(format!(
"无法读取当前会话数据库目录项: {} ({error})",
root.path.display()
));
}
continue;
}
};
let path = entry.path();
let file_type = match entry.file_type() {
Ok(file_type) => file_type,
Err(error) => {
if root.is_active {
active_scan_failures.push(format!(
"无法读取当前会话数据库文件类型: {} ({error})",
path.display()
));
}
continue;
}
};
if file_type.is_file()
&& !file_type.is_symlink()
&& is_sqlite_file(&path)
&& (root.allow_custom_names || is_root_codex_sqlite_file(&path))
{
paths.push(path);
}
}
paths.sort();
for path in paths {
@@ -529,7 +775,18 @@ pub(super) fn discover_sqlite_databases(codex_dir: &Path) -> SqliteDiscovery {
}
let codex_named = is_root_codex_sqlite_file(&path);
let Some(tables) = sqlite_table_names(&path) else {
if has_sqlite_header(&path) {
let header_is_sqlite = has_sqlite_header(&path);
let read_error = fs::File::open(&path).err();
if header_is_sqlite || read_error.is_some() {
if root.is_active {
let detail = read_error
.map(|error| format!(" ({error})"))
.unwrap_or_default();
active_scan_failures.push(format!(
"无法读取当前活动会话数据库: {}{detail}",
path.display()
));
}
unreadable_paths.push(path);
}
continue;
@@ -544,6 +801,7 @@ pub(super) fn discover_sqlite_databases(codex_dir: &Path) -> SqliteDiscovery {
databases.push(DiscoveredSqlite {
state_version: sqlite_state_version(&path),
path,
is_active: root.is_active,
active_priority: root.active_priority,
session_priority: root.session_priority,
has_threads,
@@ -555,12 +813,12 @@ pub(super) fn discover_sqlite_databases(codex_dir: &Path) -> SqliteDiscovery {
let active_path = databases
.iter()
.filter(|database| database.has_threads && database.state_version.is_some())
.filter(|database| database.is_active && database.has_threads)
.min_by(|left, right| {
left.active_priority
.cmp(&right.active_priority)
.then_with(|| right.state_version.cmp(&left.state_version))
.then_with(|| right.path.file_name().cmp(&left.path.file_name()))
.then_with(|| compare_sqlite_filenames(&left.path, &right.path))
})
.map(|database| database.path.clone());
@@ -582,6 +840,7 @@ pub(super) fn discover_sqlite_databases(codex_dir: &Path) -> SqliteDiscovery {
|database| database.active_priority,
),
unreadable_paths,
active_scan_failures,
}
}
@@ -720,6 +979,7 @@ pub(super) fn scan_sqlite_with_paths(
) -> Result<SqliteScan> {
let mut scan = SqliteScan::default();
let mut thread_ids = HashSet::new();
let mut rollout_paths_by_thread_id = HashMap::new();
let mut subagent_ids = HashSet::new();
let mut mismatched_ids = HashSet::new();
for path in sqlite_paths {
@@ -729,8 +989,8 @@ pub(super) fn scan_sqlite_with_paths(
) {
Ok(conn) => conn,
Err(e) => {
scan.warnings
.push(format!("无法读取 SQLite: {} ({e})", path.display()));
scan.scan_failures
.push(format!("无法读取当前活动 SQLite: {} ({e})", path.display()));
continue;
}
};
@@ -739,15 +999,17 @@ pub(super) fn scan_sqlite_with_paths(
}
let cols = table_column_set(&conn, "threads")?;
if !cols.contains("id") || !cols.contains("model_provider") {
scan.warnings.push(format!(
"SQLite threads 缺少 id 或 model_provider 字段: {}",
scan.scan_failures.push(format!(
"当前活动 SQLite 的 threads 表缺少 id 或 model_provider 字段: {}",
path.display()
));
continue;
}
scan.sqlite_dbs += 1;
let cwd_col = sql_select_column(&cols, "cwd", "NULL");
let query = format!("SELECT \"id\", \"model_provider\", {cwd_col} FROM threads");
let rollout_col = sql_select_column(&cols, "rollout_path", "NULL");
let query =
format!("SELECT \"id\", \"model_provider\", {cwd_col}, {rollout_col} FROM threads");
let mut stmt = conn
.prepare(&query)
.map_err(|e| CodexxError::Database(e.to_string()))?;
@@ -757,12 +1019,20 @@ pub(super) fn scan_sqlite_with_paths(
row.get::<_, String>(0)?,
row.get::<_, Option<String>>(1)?,
row.get::<_, Option<String>>(2)?,
row.get::<_, Option<String>>(3)?,
))
})
.map_err(|e| CodexxError::Database(e.to_string()))?;
for row in rows {
let (id, provider, cwd) = row.map_err(|e| CodexxError::Database(e.to_string()))?;
let (id, provider, cwd, rollout_path) =
row.map_err(|e| CodexxError::Database(e.to_string()))?;
thread_ids.insert(id.clone());
if let Some(rollout_path) = rollout_path
.map(|path| path.trim().to_string())
.filter(|path| !path.is_empty())
{
rollout_paths_by_thread_id.insert(id.clone(), rollout_path);
}
if sqlite_thread_needs_alignment(
rollouts,
target_provider,
@@ -783,6 +1053,9 @@ pub(super) fn scan_sqlite_with_paths(
scan.subagent_threads = subagent_ids.len();
scan.top_level_threads = thread_ids.len().saturating_sub(subagent_ids.len());
scan.mismatched_threads = mismatched_ids.len();
scan.thread_ids = thread_ids;
scan.rollout_paths_by_thread_id = rollout_paths_by_thread_id;
scan.mismatched_thread_ids = mismatched_ids;
Ok(scan)
}
@@ -887,16 +1160,17 @@ pub(super) fn list_session_previews_with_paths(
.as_ref()
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty());
let needs_sync = sqlite_thread_needs_alignment(
rollouts,
target_provider,
&SqliteThreadIndexState {
thread_id: &id,
provider: normalized_provider.as_deref(),
cwd: normalized_cwd.as_deref(),
cwd_column: cols.contains("cwd"),
},
);
let needs_sync = rollouts.mismatched_thread_ids.contains(&id)
|| sqlite_thread_needs_alignment(
rollouts,
target_provider,
&SqliteThreadIndexState {
thread_id: &id,
provider: normalized_provider.as_deref(),
cwd: normalized_cwd.as_deref(),
cwd_column: cols.contains("cwd"),
},
);
let is_subagent = subagent_thread_ids.contains(&id);
Ok(SessionPreview {
id,
@@ -979,10 +1253,15 @@ mod tests {
}
#[test]
fn root_state_10_is_listed_and_synchronized() {
fn root_state_10_uses_shared_bucket_even_when_openai_is_requested() {
let codex_dir = temp_codex_dir("root-state-10");
let database = codex_dir.join("state_10.sqlite");
let id = "019f6000-0000-7000-8000-000000000301";
fs::write(
codex_dir.join("config.toml"),
"model_provider = \"custom\"\n",
)
.expect("write shared provider config");
create_thread_database(&database, id, "openai");
let discovery = discover_sqlite_databases(&codex_dir);
@@ -999,11 +1278,20 @@ mod tests {
assert_eq!(sessions.len(), 1);
assert_eq!(sessions[0].id, id);
let status = crate::sessions::sync::session_sync_status_inner(
Some(codex_dir.display().to_string()),
Some("openai".to_string()),
)
.expect("read shared bucket status");
assert_eq!(status.target_provider, "custom");
assert!(status.needs_sync);
let result = crate::sessions::sync::sync_sessions_provider_inner(
Some(codex_dir.display().to_string()),
Some("custom".to_string()),
Some("openai".to_string()),
)
.expect("sync root state_10");
assert_eq!(result.status.target_provider, "custom");
assert_eq!(result.updated_threads, 1);
let provider: String = Connection::open(&database)
.expect("reopen state_10")
@@ -1018,6 +1306,91 @@ mod tests {
let _ = fs::remove_dir_all(codex_dir);
}
#[test]
fn root_codex_dev_database_is_active_without_state_database() {
let codex_dir = temp_codex_dir("root-codex-dev");
let database = codex_dir.join("codex-dev.db");
create_thread_database(&database, "019f6000-0000-7000-8000-000000000302", "openai");
let discovery = discover_sqlite_databases(&codex_dir);
assert_eq!(discovery.active_paths, vec![database.clone()]);
assert_eq!(discovery.thread_paths, vec![database]);
let _ = fs::remove_dir_all(codex_dir);
}
#[test]
fn configured_sqlite_home_custom_database_is_active() {
let codex_dir = temp_codex_dir("configured-custom-active");
fs::write(
codex_dir.join("config.toml"),
"sqlite_home = \"session-store\"\n",
)
.expect("write sqlite_home config");
let database = codex_dir.join("session-store/custom-name.db");
let later_database = codex_dir.join("session-store/later-name.sqlite3");
create_thread_database(&database, "019f6000-0000-7000-8000-000000000303", "openai");
create_thread_database(
&later_database,
"019f6000-0000-7000-8000-000000000304",
"openai",
);
let discovery = discover_sqlite_databases(&codex_dir);
assert_eq!(discovery.active_paths, vec![database.clone()]);
assert_eq!(discovery.thread_paths, vec![database, later_database]);
let _ = fs::remove_dir_all(codex_dir);
}
#[test]
fn configured_sqlite_home_prefers_codex_dev_before_custom_database() {
let codex_dir = temp_codex_dir("configured-codex-dev-precedence");
fs::write(
codex_dir.join("config.toml"),
"sqlite_home = \"session-store\"\n",
)
.expect("write sqlite_home config");
let storage = codex_dir.join("session-store");
let codex_dev = storage.join("codex-dev.db");
let custom = storage.join("custom-name.db");
create_thread_database(&codex_dev, "019f6000-0000-7000-8000-000000000305", "openai");
create_thread_database(&custom, "019f6000-0000-7000-8000-000000000306", "openai");
let discovery = discover_sqlite_databases(&codex_dir);
assert_eq!(discovery.active_paths, vec![codex_dev]);
let _ = fs::remove_dir_all(codex_dir);
}
#[test]
fn configured_sqlite_home_prefers_latest_state_database() {
let codex_dir = temp_codex_dir("configured-state-precedence");
fs::write(
codex_dir.join("config.toml"),
"sqlite_home = \"session-store\"\n",
)
.expect("write sqlite_home config");
let storage = codex_dir.join("session-store");
let custom = storage.join("custom-name.db");
let codex_dev = storage.join("codex-dev.db");
let state_5 = storage.join("state_5.sqlite");
let state_10 = storage.join("state_10.sqlite");
for (database, id) in [
(&custom, "019f6000-0000-7000-8000-000000000307"),
(&codex_dev, "019f6000-0000-7000-8000-000000000308"),
(&state_5, "019f6000-0000-7000-8000-000000000309"),
(&state_10, "019f6000-0000-7000-8000-000000000310"),
] {
create_thread_database(database, id, "openai");
}
let discovery = discover_sqlite_databases(&codex_dir);
assert_eq!(discovery.active_paths, vec![state_10]);
let _ = fs::remove_dir_all(codex_dir);
}
#[test]
fn custom_db_and_sqlite3_share_the_same_discovery() {
let codex_dir = temp_codex_dir("custom-extensions");
@@ -1088,10 +1461,7 @@ mod tests {
fn unrelated_root_sqlite_is_not_classified_as_codex_storage() {
let codex_dir = temp_codex_dir("unrelated-root");
let unrelated = codex_dir.join("unrelated.sqlite");
let conn = Connection::open(&unrelated).expect("create unrelated sqlite");
conn.execute("CREATE TABLE logs (thread_id TEXT)", [])
.expect("create unrelated logs table");
drop(conn);
create_thread_database(&unrelated, "019f6000-0000-7000-8000-000000000341", "openai");
let discovery = discover_sqlite_databases(&codex_dir);
assert!(!discovery.related_paths.contains(&unrelated));
+718 -31
View File
@@ -1,47 +1,124 @@
use super::backup::{create_provider_sync_backup, prune_provider_sync_backups};
use super::global_state::count_global_state_updates;
use super::storage::{
current_model_provider, discover_sqlite_databases, ensure_sqlite_discovery_writable,
list_session_previews_with_paths, scan_rollouts, scan_sqlite_with_paths, SqliteDiscovery,
current_model_provider, discover_sqlite_databases, list_session_previews_with_paths,
scan_rollouts_for_thread_ids, scan_sqlite_with_paths, SqliteDiscovery,
};
use super::transaction::{
execute_provider_sync_mutation, mutation_error, prepare_sqlite_updates, rollback_mutation,
rollback_open_transactions, MutationJournal, MutationPoint,
};
use super::types::{SessionSyncResult, SessionSyncStatus};
use super::types::{RolloutScan, SessionSyncResult, SessionSyncStatus, SqliteScan};
use crate::error::{CodexxError, Result};
use crate::file_io::{ensure_directory, io_err};
use crate::resolve_codex_dir;
use std::collections::HashSet;
use std::fs;
use std::io::Write;
use std::path::Path;
const SHARED_SESSION_PROVIDER: &str = "custom";
fn live_route_failure(codex_dir: &Path) -> Option<String> {
match current_model_provider(codex_dir, None) {
Ok(provider) if provider.trim() == SHARED_SESSION_PROVIDER => None,
Ok(provider) => Some(format!(
"当前 Codex 配置的 model_provider 为 {provider:?},未路由到共享 custom 会话;已停止同步,请先重新启用官方配置或供应商。"
)),
Err(error) => Some(format!("无法验证当前 Codex 配置的 model_provider: {error}")),
}
}
fn scan_failure_error(failures: &[String]) -> CodexxError {
CodexxError::Config(
failures
.first()
.cloned()
.unwrap_or_else(|| "无法确认当前会话同步状态。".to_string()),
)
}
fn scan_provider_buckets(
codex_dir: &Path,
target_provider: &str,
sqlite: &SqliteScan,
) -> Result<RolloutScan> {
let mut rollouts = scan_rollouts_for_thread_ids(
codex_dir,
target_provider,
&sqlite.thread_ids,
&sqlite.rollout_paths_by_thread_id,
)?;
// Provider synchronization must not repair or rewrite the independent cwd index.
rollouts.cwd_by_thread_id.clear();
Ok(rollouts)
}
pub(crate) fn session_sync_status_inner(
config_dir: Option<String>,
target_provider: Option<String>,
_target_provider: Option<String>,
) -> Result<SessionSyncStatus> {
let codex_dir = resolve_codex_dir(config_dir)?;
let target = current_model_provider(&codex_dir, target_provider)?;
let target = SHARED_SESSION_PROVIDER.to_string();
let discovery = discover_sqlite_databases(&codex_dir);
session_sync_status_with_discovery(&codex_dir, target, &discovery)
}
pub(super) fn session_sync_status_with_discovery(
codex_dir: &Path,
target: String,
_target: String,
discovery: &SqliteDiscovery,
) -> Result<SessionSyncStatus> {
let rollouts = scan_rollouts(codex_dir, &target)?;
let sqlite = scan_sqlite_with_paths(&discovery.session_paths, &rollouts, &target)?;
let global_state_updates =
count_global_state_updates(&codex_dir.join(".codex-global-state.json"))?;
let target = SHARED_SESSION_PROVIDER.to_string();
let mut scan_failures = Vec::new();
if let Some(failure) = live_route_failure(codex_dir) {
scan_failures.push(failure);
}
scan_failures.extend(discovery.active_scan_failures.iter().cloned());
let sqlite =
match scan_sqlite_with_paths(&discovery.active_paths, &RolloutScan::default(), &target) {
Ok(sqlite) => sqlite,
Err(error) => {
scan_failures.push(format!("无法扫描当前活动会话数据库: {error}"));
Default::default()
}
};
scan_failures.extend(sqlite.scan_failures.iter().cloned());
if !discovery.active_paths.is_empty() && sqlite.sqlite_dbs != discovery.active_paths.len() {
scan_failures.push("当前活动会话数据库未被完整扫描。".to_string());
}
let rollouts = scan_provider_buckets(codex_dir, &target, &sqlite)?;
scan_failures.extend(rollouts.scan_failures.iter().cloned());
if discovery.active_paths.is_empty()
&& (!discovery.thread_paths.is_empty() || rollouts.discovered_rollout_files > 0)
{
scan_failures.push(
"未找到当前活动会话数据库;旧数据库和单独的 JSONL 不会被当作客户端活动会话。"
.to_string(),
);
}
let session_limit = sqlite.sqlite_threads.clamp(50, 1000);
let preview_paths = discovery.active_first_session_paths();
let (sessions, session_warnings) =
list_session_previews_with_paths(&preview_paths, &rollouts, &target, session_limit)?;
let (sessions, session_warnings) = match list_session_previews_with_paths(
&discovery.active_paths,
&rollouts,
&target,
session_limit,
) {
Ok(result) => result,
Err(error) => {
scan_failures.push(format!("无法读取当前活动会话列表: {error}"));
(Vec::new(), Vec::new())
}
};
scan_failures.extend(session_warnings);
let mut seen_failures = HashSet::new();
scan_failures.retain(|failure| seen_failures.insert(failure.clone()));
let mut warnings = rollouts.warnings;
warnings.extend(sqlite.warnings);
warnings.extend(session_warnings);
let scan_complete = scan_failures.is_empty();
let mismatched_sessions = sqlite
.mismatched_thread_ids
.union(&rollouts.mismatched_thread_ids)
.count();
Ok(SessionSyncStatus {
codex_dir: codex_dir.display().to_string(),
target_provider: target,
@@ -54,9 +131,10 @@ pub(super) fn session_sync_status_with_discovery(
top_level_threads: sqlite.top_level_threads,
subagent_threads: sqlite.subagent_threads,
mismatched_threads: sqlite.mismatched_threads,
needs_sync: rollouts.mismatched_rollouts > 0
|| sqlite.mismatched_threads > 0
|| global_state_updates > 0,
mismatched_sessions,
needs_sync: mismatched_sessions > 0,
scan_complete,
scan_failures,
backup_dir: None,
warnings,
sessions,
@@ -114,7 +192,7 @@ pub(crate) fn sync_sessions_provider_inner(
pub(super) fn sync_sessions_provider_with_hook<F>(
config_dir: Option<String>,
target_provider: Option<String>,
_target_provider: Option<String>,
mut hook: F,
) -> Result<SessionSyncResult>
where
@@ -122,19 +200,32 @@ where
{
let codex_dir = resolve_codex_dir(config_dir)?;
ensure_directory(&codex_dir)?;
let target_provider = current_model_provider(&codex_dir, target_provider)?;
let target_provider = SHARED_SESSION_PROVIDER.to_string();
let _maintenance_lock = acquire_session_maintenance_lock(&codex_dir)?;
let discovery = discover_sqlite_databases(&codex_dir);
ensure_sqlite_discovery_writable(&discovery)?;
let rollouts = scan_rollouts(&codex_dir, &target_provider)?;
let sqlite = scan_sqlite_with_paths(&discovery.session_paths, &rollouts, &target_provider)?;
let global_state_path = codex_dir.join(".codex-global-state.json");
let global_state_updates = count_global_state_updates(&global_state_path)?;
if rollouts.changes.is_empty() && sqlite.mismatched_threads == 0 && global_state_updates == 0 {
let status = session_sync_status_with_discovery(&codex_dir, target_provider, &discovery)?;
let initial_status =
session_sync_status_with_discovery(&codex_dir, target_provider.clone(), &discovery)?;
if !initial_status.scan_complete {
return Err(scan_failure_error(&initial_status.scan_failures));
}
let sqlite = scan_sqlite_with_paths(
&discovery.active_paths,
&RolloutScan::default(),
&target_provider,
)?;
if !sqlite.scan_failures.is_empty() {
return Err(scan_failure_error(&sqlite.scan_failures));
}
let rollouts = scan_provider_buckets(&codex_dir, &target_provider, &sqlite)?;
if !rollouts.scan_failures.is_empty() {
return Err(scan_failure_error(&rollouts.scan_failures));
}
if let Some(failure) = live_route_failure(&codex_dir) {
return Err(CodexxError::Config(failure));
}
if rollouts.changes.is_empty() && sqlite.mismatched_threads == 0 {
return Ok(SessionSyncResult {
status,
status: initial_status,
updated_rollouts: 0,
updated_threads: 0,
backup_dir: String::new(),
@@ -146,7 +237,7 @@ where
.iter()
.map(|change| change.path.clone())
.collect::<Vec<_>>();
let mut pending_sqlite = prepare_sqlite_updates(&discovery.session_paths)?;
let mut pending_sqlite = prepare_sqlite_updates(&discovery.active_paths)?;
let sqlite_snapshot_paths = pending_sqlite
.iter()
.map(|update| update.path().to_path_buf())
@@ -168,7 +259,6 @@ where
&rollouts,
&mut pending_sqlite,
&target_provider,
&global_state_path,
&mut journal,
&mut hook,
);
@@ -206,3 +296,600 @@ where
backup_dir: backup.dir.display().to_string(),
})
}
#[cfg(test)]
mod tests {
use super::*;
use rusqlite::Connection;
use std::sync::atomic::{AtomicU64, Ordering};
fn temp_codex_dir(label: &str) -> std::path::PathBuf {
static COUNTER: AtomicU64 = AtomicU64::new(0);
let path = std::env::temp_dir().join(format!(
"codex-x-session-sync-{label}-{}-{}",
std::process::id(),
COUNTER.fetch_add(1, Ordering::Relaxed),
));
let _ = fs::remove_dir_all(&path);
fs::create_dir_all(&path).expect("create test Codex directory");
path
}
fn write_config(codex_dir: &Path, provider: &str) {
fs::write(
codex_dir.join("config.toml"),
format!("model_provider = {provider:?}\n"),
)
.expect("write Codex config");
}
fn create_thread_database(path: &Path, id: &str, provider: &str) {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).expect("create SQLite parent");
}
let conn = Connection::open(path).expect("create session database");
conn.execute_batch(
"CREATE TABLE threads (
id TEXT PRIMARY KEY,
model_provider TEXT NOT NULL,
title TEXT
);",
)
.expect("create threads table");
conn.execute(
"INSERT INTO threads (id, model_provider, title) VALUES (?1, ?2, 'test')",
(id, provider),
)
.expect("insert thread");
}
fn create_thread_database_with_rollout(path: &Path, id: &str, provider: &str, rollout: &Path) {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).expect("create SQLite parent");
}
let conn = Connection::open(path).expect("create session database");
conn.execute_batch(
"CREATE TABLE threads (
id TEXT PRIMARY KEY,
model_provider TEXT NOT NULL,
title TEXT,
rollout_path TEXT
);",
)
.expect("create threads table with rollout path");
conn.execute(
"INSERT INTO threads (id, model_provider, title, rollout_path)
VALUES (?1, ?2, 'test', ?3)",
(id, provider, rollout.display().to_string()),
)
.expect("insert thread with rollout path");
}
fn thread_provider(path: &Path, id: &str) -> String {
Connection::open(path)
.expect("open session database")
.query_row(
"SELECT model_provider FROM threads WHERE id = ?1",
[id],
|row| row.get(0),
)
.expect("read thread provider")
}
fn write_rollout(codex_dir: &Path, id: &str, provider: &str) -> std::path::PathBuf {
let path = codex_dir.join(format!("sessions/rollout-test-{id}.jsonl"));
write_rollout_at(&path, id, provider);
path
}
fn write_rollout_at(path: &Path, id: &str, provider: &str) {
fs::create_dir_all(path.parent().expect("rollout parent")).expect("create rollout parent");
fs::write(
path,
format!(
"{{\"type\":\"session_meta\",\"payload\":{{\"id\":\"{id}\",\"model_provider\":\"{provider}\"}}}}\n"
),
)
.expect("write rollout");
}
#[test]
fn live_provider_must_route_to_custom_before_status_can_be_complete() {
let codex_dir = temp_codex_dir("live-provider-gate");
write_config(&codex_dir, "openai");
let status = session_sync_status_inner(Some(codex_dir.display().to_string()), None)
.expect("read blocked status");
assert!(!status.scan_complete);
assert!(!status.needs_sync);
assert!(status
.scan_failures
.iter()
.any(|failure| failure.contains("model_provider") && failure.contains("openai")));
let error = sync_sessions_provider_inner(Some(codex_dir.display().to_string()), None)
.expect_err("non-custom live route must block synchronization");
assert!(error.to_string().contains("未路由到共享 custom 会话"));
fs::remove_dir_all(codex_dir).expect("remove test directory");
}
#[test]
fn unreadable_jsonl_cannot_report_all_sessions_synced() {
let codex_dir = temp_codex_dir("unreadable-jsonl");
write_config(&codex_dir, SHARED_SESSION_PROVIDER);
let id = "019f6000-0000-7000-8000-000000000501";
create_thread_database(&codex_dir.join("state_5.sqlite"), id, "custom");
let rollout = codex_dir.join(format!("sessions/rollout-test-{id}.jsonl"));
fs::create_dir_all(rollout.parent().expect("rollout parent"))
.expect("create rollout parent");
fs::write(&rollout, "not-json\n").expect("write malformed rollout");
let status = session_sync_status_inner(Some(codex_dir.display().to_string()), None)
.expect("read incomplete status");
assert!(!status.scan_complete);
assert!(!status.needs_sync);
assert!(status
.scan_failures
.iter()
.any(|failure| failure.contains("无法解析的 JSON")));
sync_sessions_provider_inner(Some(codex_dir.display().to_string()), None)
.expect_err("incomplete JSONL scan must block synchronization");
fs::remove_dir_all(codex_dir).expect("remove test directory");
}
#[test]
fn malformed_standard_orphan_rollout_does_not_block_active_sessions() {
let codex_dir = temp_codex_dir("malformed-standard-orphan");
write_config(&codex_dir, SHARED_SESSION_PROVIDER);
let active_id = "019f6000-0000-7000-8000-000000000502";
let orphan_id = "019f6000-0000-7000-8000-000000000503";
create_thread_database(
&codex_dir.join("state_5.sqlite"),
active_id,
SHARED_SESSION_PROVIDER,
);
write_rollout(&codex_dir, active_id, SHARED_SESSION_PROVIDER);
fs::write(
codex_dir.join(format!("sessions/rollout-test-{orphan_id}.jsonl")),
b"\xff",
)
.expect("write invalid orphan rollout");
let status = session_sync_status_inner(Some(codex_dir.display().to_string()), None)
.expect("scan active sessions only");
assert!(status.scan_complete, "{:?}", status.scan_failures);
assert_eq!(status.rollout_files, 1);
assert_eq!(status.session_meta_count, 1);
assert!(!status.needs_sync);
fs::remove_dir_all(codex_dir).expect("remove test directory");
}
#[test]
fn malformed_unreferenced_nonstandard_rollout_does_not_block_active_sessions() {
let codex_dir = temp_codex_dir("malformed-unreferenced-rollout");
write_config(&codex_dir, SHARED_SESSION_PROVIDER);
let active_id = "019f6000-0000-7000-8000-000000000504";
create_thread_database(
&codex_dir.join("state_5.sqlite"),
active_id,
SHARED_SESSION_PROVIDER,
);
write_rollout(&codex_dir, active_id, SHARED_SESSION_PROVIDER);
fs::write(
codex_dir.join("sessions/rollout-imported-orphan.jsonl"),
b"\xff",
)
.expect("write invalid unreferenced rollout");
let status = session_sync_status_inner(Some(codex_dir.display().to_string()), None)
.expect("scan referenced sessions only");
assert!(status.scan_complete, "{:?}", status.scan_failures);
assert_eq!(status.rollout_files, 1);
assert_eq!(status.session_meta_count, 1);
fs::remove_dir_all(codex_dir).expect("remove test directory");
}
#[test]
fn sqlite_referenced_nonstandard_rollout_is_synchronized() {
let codex_dir = temp_codex_dir("referenced-nonstandard-rollout");
write_config(&codex_dir, SHARED_SESSION_PROVIDER);
let id = "019f6000-0000-7000-8000-000000000505";
let rollout = codex_dir.join("sessions/rollout-imported-name.jsonl");
write_rollout_at(&rollout, id, "openai");
let database = codex_dir.join("state_5.sqlite");
create_thread_database_with_rollout(&database, id, "openai", &rollout);
let status = session_sync_status_inner(Some(codex_dir.display().to_string()), None)
.expect("scan referenced imported rollout");
assert!(status.scan_complete, "{:?}", status.scan_failures);
assert!(status.needs_sync);
assert_eq!(status.rollout_files, 1);
assert_eq!(status.mismatched_sessions, 1);
let result = sync_sessions_provider_inner(
Some(codex_dir.display().to_string()),
Some(SHARED_SESSION_PROVIDER.to_string()),
)
.expect("synchronize referenced imported rollout");
assert_eq!(result.updated_rollouts, 1);
assert_eq!(result.updated_threads, 1);
assert_eq!(thread_provider(&database, id), SHARED_SESSION_PROVIDER);
assert!(fs::read_to_string(&rollout)
.expect("read synchronized imported rollout")
.contains("\"model_provider\":\"custom\""));
fs::remove_dir_all(codex_dir).expect("remove test directory");
}
#[test]
fn sqlite_referenced_rollout_with_a_different_session_id_blocks_sync() {
let codex_dir = temp_codex_dir("referenced-rollout-id-mismatch");
write_config(&codex_dir, SHARED_SESSION_PROVIDER);
let sqlite_id = "019f6000-0000-7000-8000-000000000506";
let rollout_id = "019f6000-0000-7000-8000-000000000507";
let rollout = codex_dir.join("sessions/rollout-imported-name.jsonl");
write_rollout_at(&rollout, rollout_id, "openai");
let database = codex_dir.join("state_5.sqlite");
create_thread_database_with_rollout(&database, sqlite_id, "openai", &rollout);
let status = session_sync_status_inner(Some(codex_dir.display().to_string()), None)
.expect("scan mismatched referenced rollout");
assert!(!status.scan_complete);
assert!(status
.scan_failures
.iter()
.any(|failure| failure.contains("线程 ID 不一致")));
let error = sync_sessions_provider_inner(
Some(codex_dir.display().to_string()),
Some(SHARED_SESSION_PROVIDER.to_string()),
)
.expect_err("mismatched referenced rollout must block synchronization");
assert!(error.to_string().contains("线程 ID 不一致"));
assert_eq!(thread_provider(&database, sqlite_id), "openai");
assert!(fs::read_to_string(&rollout)
.expect("read unchanged rollout")
.contains("\"model_provider\":\"openai\""));
fs::remove_dir_all(codex_dir).expect("remove test directory");
}
#[test]
fn missing_sqlite_referenced_rollout_blocks_sync_without_using_uuid_fallback() {
let codex_dir = temp_codex_dir("missing-referenced-rollout");
write_config(&codex_dir, SHARED_SESSION_PROVIDER);
let id = "019f6000-0000-7000-8000-000000000506";
let missing = codex_dir.join("sessions/missing/rollout-selected.jsonl");
let duplicate = write_rollout(&codex_dir, id, "openai");
create_thread_database_with_rollout(
&codex_dir.join("state_5.sqlite"),
id,
SHARED_SESSION_PROVIDER,
&missing,
);
let status = session_sync_status_inner(Some(codex_dir.display().to_string()), None)
.expect("scan missing referenced rollout");
assert!(!status.scan_complete);
assert!(!status.needs_sync);
assert_eq!(status.rollout_files, 0);
assert!(status
.scan_failures
.iter()
.any(|failure| failure.contains("会话文件不存在")));
sync_sessions_provider_inner(Some(codex_dir.display().to_string()), None)
.expect_err("missing referenced rollout must block synchronization");
assert!(fs::read_to_string(duplicate)
.expect("read untouched UUID fallback")
.contains("\"model_provider\":\"openai\""));
fs::remove_dir_all(codex_dir).expect("remove test directory");
}
#[test]
fn unreadable_sqlite_referenced_rollout_cannot_report_synced() {
let codex_dir = temp_codex_dir("unreadable-referenced-rollout");
write_config(&codex_dir, SHARED_SESSION_PROVIDER);
let id = "019f6000-0000-7000-8000-000000000507";
let rollout = codex_dir.join("sessions/rollout-unreadable.jsonl");
fs::create_dir_all(rollout.parent().expect("rollout parent"))
.expect("create rollout parent");
fs::write(&rollout, b"\xff").expect("write invalid UTF-8 rollout");
create_thread_database_with_rollout(
&codex_dir.join("state_5.sqlite"),
id,
SHARED_SESSION_PROVIDER,
&rollout,
);
let status = session_sync_status_inner(Some(codex_dir.display().to_string()), None)
.expect("scan unreadable referenced rollout");
assert!(!status.scan_complete);
assert!(!status.needs_sync);
assert_eq!(status.rollout_files, 1);
assert!(status
.scan_failures
.iter()
.any(|failure| failure.contains("无法读取会话文件")));
sync_sessions_provider_inner(Some(codex_dir.display().to_string()), None)
.expect_err("unreadable referenced rollout must block synchronization");
fs::remove_dir_all(codex_dir).expect("remove test directory");
}
#[test]
fn sqlite_rollout_path_excludes_other_files_with_the_same_thread_id() {
let codex_dir = temp_codex_dir("referenced-rollout-excludes-duplicates");
write_config(&codex_dir, SHARED_SESSION_PROVIDER);
let id = "019f6000-0000-7000-8000-000000000508";
let selected = codex_dir.join("sessions/rollout-selected-name.jsonl");
write_rollout_at(&selected, id, "openai");
let duplicate = write_rollout(&codex_dir, id, "openai");
let database = codex_dir.join("state_5.sqlite");
create_thread_database_with_rollout(&database, id, "openai", &selected);
let status = session_sync_status_inner(Some(codex_dir.display().to_string()), None)
.expect("scan only the SQLite-selected rollout");
assert!(status.scan_complete, "{:?}", status.scan_failures);
assert_eq!(status.rollout_files, 1);
assert_eq!(status.mismatched_rollouts, 1);
let result = sync_sessions_provider_inner(Some(codex_dir.display().to_string()), None)
.expect("synchronize only the SQLite-selected rollout");
assert_eq!(result.updated_rollouts, 1);
assert_eq!(result.updated_threads, 1);
assert_eq!(thread_provider(&database, id), SHARED_SESSION_PROVIDER);
assert!(fs::read_to_string(&selected)
.expect("read selected rollout")
.contains("\"model_provider\":\"custom\""));
assert!(fs::read_to_string(&duplicate)
.expect("read duplicate rollout")
.contains("\"model_provider\":\"openai\""));
fs::remove_dir_all(codex_dir).expect("remove test directory");
}
#[test]
fn sqlite_referenced_rollout_outside_session_storage_is_rejected() {
let codex_dir = temp_codex_dir("outside-referenced-rollout");
let outside_dir = temp_codex_dir("outside-referenced-rollout-target");
write_config(&codex_dir, SHARED_SESSION_PROVIDER);
let id = "019f6000-0000-7000-8000-000000000509";
let rollout = outside_dir.join("rollout-external.jsonl");
write_rollout_at(&rollout, id, "openai");
create_thread_database_with_rollout(
&codex_dir.join("state_5.sqlite"),
id,
SHARED_SESSION_PROVIDER,
&rollout,
);
let status = session_sync_status_inner(Some(codex_dir.display().to_string()), None)
.expect("reject external referenced rollout");
assert!(!status.scan_complete);
assert!(!status.needs_sync);
assert_eq!(status.rollout_files, 0);
assert!(status
.scan_failures
.iter()
.any(|failure| failure.contains("超出 Codex 会话目录")));
sync_sessions_provider_inner(Some(codex_dir.display().to_string()), None)
.expect_err("external referenced rollout must block synchronization");
assert!(fs::read_to_string(&rollout)
.expect("read untouched external rollout")
.contains("\"model_provider\":\"openai\""));
fs::remove_dir_all(codex_dir).expect("remove test directory");
fs::remove_dir_all(outside_dir).expect("remove external test directory");
}
#[test]
fn unreadable_active_sqlite_cannot_report_all_sessions_synced() {
let codex_dir = temp_codex_dir("unreadable-active-sqlite");
write_config(&codex_dir, SHARED_SESSION_PROVIDER);
fs::write(codex_dir.join("state_5.sqlite"), b"SQLite format 3\0")
.expect("write truncated SQLite");
let status = session_sync_status_inner(Some(codex_dir.display().to_string()), None)
.expect("read incomplete status");
assert!(!status.scan_complete);
assert_eq!(status.sqlite_threads, 0);
assert!(status
.scan_failures
.iter()
.any(|failure| failure.contains("活动会话数据库")));
sync_sessions_provider_inner(Some(codex_dir.display().to_string()), None)
.expect_err("unreadable active SQLite must block synchronization");
fs::remove_dir_all(codex_dir).expect("remove test directory");
}
#[test]
fn legacy_sqlite_is_not_counted_or_modified() {
let codex_dir = temp_codex_dir("active-only");
write_config(&codex_dir, SHARED_SESSION_PROVIDER);
let active = codex_dir.join("state_5.sqlite");
let legacy = codex_dir.join("sqlite/state_5.sqlite");
let active_id = "019f6000-0000-7000-8000-000000000511";
let legacy_id = "019f6000-0000-7000-8000-000000000512";
create_thread_database(&active, active_id, "openai");
create_thread_database(&legacy, legacy_id, "openai");
let status = session_sync_status_inner(Some(codex_dir.display().to_string()), None)
.expect("read active status");
assert!(status.scan_complete);
assert_eq!(status.sqlite_dbs, 1);
assert_eq!(status.sqlite_threads, 1);
assert_eq!(status.mismatched_threads, 1);
assert_eq!(status.sessions.len(), 1);
assert_eq!(status.sessions[0].id, active_id);
let result = sync_sessions_provider_inner(Some(codex_dir.display().to_string()), None)
.expect("sync active database");
assert_eq!(result.updated_threads, 1);
assert_eq!(thread_provider(&active, active_id), "custom");
assert_eq!(thread_provider(&legacy, legacy_id), "openai");
fs::remove_dir_all(codex_dir).expect("remove test directory");
}
#[test]
fn configured_sqlite_home_is_the_only_active_database_root() {
let codex_dir = temp_codex_dir("configured-sqlite-home");
fs::write(
codex_dir.join("config.toml"),
"model_provider = \"custom\"\nsqlite_home = \"active-sqlite\"\n",
)
.expect("write configured SQLite home");
let configured = codex_dir.join("active-sqlite/state_5.sqlite");
let root_copy = codex_dir.join("state_10.sqlite");
let configured_id = "019f6000-0000-7000-8000-000000000515";
let root_id = "019f6000-0000-7000-8000-000000000516";
create_thread_database(&configured, configured_id, "openai");
create_thread_database(&root_copy, root_id, "openai");
let status = session_sync_status_inner(Some(codex_dir.display().to_string()), None)
.expect("read configured active status");
assert!(status.scan_complete);
assert_eq!(status.sqlite_threads, 1);
assert_eq!(status.sessions[0].id, configured_id);
let result = sync_sessions_provider_inner(Some(codex_dir.display().to_string()), None)
.expect("sync configured active database");
assert_eq!(result.updated_threads, 1);
assert_eq!(thread_provider(&configured, configured_id), "custom");
assert_eq!(thread_provider(&root_copy, root_id), "openai");
fs::remove_dir_all(codex_dir).expect("remove test directory");
}
#[test]
fn legacy_only_database_is_not_treated_as_active() {
let codex_dir = temp_codex_dir("legacy-only");
write_config(&codex_dir, SHARED_SESSION_PROVIDER);
let legacy = codex_dir.join("sqlite/state_5.sqlite");
let id = "019f6000-0000-7000-8000-000000000521";
create_thread_database(&legacy, id, "openai");
let status = session_sync_status_inner(Some(codex_dir.display().to_string()), None)
.expect("read legacy-only status");
assert!(!status.scan_complete);
assert_eq!(status.sqlite_threads, 0);
assert!(status.sessions.is_empty());
sync_sessions_provider_inner(Some(codex_dir.display().to_string()), None)
.expect_err("legacy-only database must not be synchronized");
assert_eq!(thread_provider(&legacy, id), "openai");
fs::remove_dir_all(codex_dir).expect("remove test directory");
}
#[test]
fn orphan_rollout_is_not_counted_or_modified() {
let codex_dir = temp_codex_dir("orphan-rollout");
write_config(&codex_dir, SHARED_SESSION_PROVIDER);
let active_id = "019f6000-0000-7000-8000-000000000531";
let orphan_id = "019f6000-0000-7000-8000-000000000532";
create_thread_database(
&codex_dir.join("state_5.sqlite"),
active_id,
SHARED_SESSION_PROVIDER,
);
let orphan = write_rollout(&codex_dir, orphan_id, "openai");
let status = session_sync_status_inner(Some(codex_dir.display().to_string()), None)
.expect("read active-only status");
assert!(status.scan_complete);
assert!(!status.needs_sync);
assert_eq!(status.mismatched_sessions, 0);
assert_eq!(status.mismatched_rollouts, 0);
let result = sync_sessions_provider_inner(Some(codex_dir.display().to_string()), None)
.expect("orphan rollout does not require synchronization");
assert_eq!(result.updated_rollouts, 0);
assert!(fs::read_to_string(orphan)
.expect("read orphan rollout")
.contains("\"model_provider\":\"openai\""));
fs::remove_dir_all(codex_dir).expect("remove test directory");
}
#[test]
fn mismatch_count_is_the_union_of_active_session_ids() {
let codex_dir = temp_codex_dir("mismatch-union");
write_config(&codex_dir, SHARED_SESSION_PROVIDER);
let database = codex_dir.join("state_5.sqlite");
let sqlite_mismatch = "019f6000-0000-7000-8000-000000000541";
let rollout_mismatch = "019f6000-0000-7000-8000-000000000542";
create_thread_database(&database, sqlite_mismatch, "openai");
Connection::open(&database)
.expect("open active database")
.execute(
"INSERT INTO threads (id, model_provider, title) VALUES (?1, 'custom', 'test')",
[rollout_mismatch],
)
.expect("insert second active thread");
write_rollout(&codex_dir, rollout_mismatch, "openai");
let status = session_sync_status_inner(Some(codex_dir.display().to_string()), None)
.expect("read mismatch union");
assert!(status.scan_complete);
assert_eq!(status.mismatched_threads, 1);
assert_eq!(status.mismatched_rollouts, 1);
assert_eq!(status.mismatched_sessions, 2);
assert!(status
.sessions
.iter()
.find(|session| session.id == rollout_mismatch)
.is_some_and(|session| session.needs_sync));
let result = sync_sessions_provider_inner(Some(codex_dir.display().to_string()), None)
.expect("sync mismatch union");
assert_eq!(result.updated_threads, 1);
assert_eq!(result.updated_rollouts, 1);
assert_eq!(result.status.mismatched_sessions, 0);
fs::remove_dir_all(codex_dir).expect("remove test directory");
}
#[test]
fn global_state_drift_does_not_trigger_provider_sync() {
let codex_dir = temp_codex_dir("ignore-global-state-drift");
fs::write(
codex_dir.join("config.toml"),
"model_provider = \"custom\"\n",
)
.expect("write official config");
let global_state = codex_dir.join(".codex-global-state.json");
let original = br#"{"electron-saved-workspace-roots":"/tmp/project"}"#;
fs::write(&global_state, original).expect("write global state drift");
let status = session_sync_status_inner(
Some(codex_dir.display().to_string()),
Some("custom".to_string()),
)
.expect("read shared session status");
assert_eq!(status.target_provider, SHARED_SESSION_PROVIDER);
assert!(!status.needs_sync);
let result = sync_sessions_provider_inner(
Some(codex_dir.display().to_string()),
Some("custom".to_string()),
)
.expect("global state drift is not a provider migration");
assert_eq!(result.status.target_provider, SHARED_SESSION_PROVIDER);
assert_eq!(result.updated_rollouts, 0);
assert_eq!(result.updated_threads, 0);
assert!(result.backup_dir.is_empty());
assert_eq!(
fs::read(&global_state).expect("read unchanged state"),
original
);
assert!(!codex_dir.join(".codex-global-state.json.bak").exists());
fs::remove_dir_all(codex_dir).expect("remove test directory");
}
}
@@ -1,6 +1,3 @@
use super::global_state::{
apply_global_state_update_with_journal, restore_global_write, GlobalStateWrite,
};
use super::storage::{apply_session_changes, restore_session_changes};
use super::types::{RolloutScan, SessionFileChange};
use crate::error::{CodexxError, Result};
@@ -32,7 +29,6 @@ impl SqliteUpdateCounts {
pub(super) struct MutationJournal {
applied_rollouts: Vec<SessionFileChange>,
sqlite_restore_attempts: Vec<SqliteRestoreAttempt>,
global_writes: Vec<GlobalStateWrite>,
}
#[derive(Debug, Clone)]
@@ -321,11 +317,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()
@@ -358,7 +349,6 @@ pub(super) fn mutation_error(original: CodexxError, recovery_errors: Vec<String>
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum MutationPoint {
AfterSqliteCommit(usize),
AfterGlobalMainWrite,
}
pub(super) struct MutationResult {
@@ -371,7 +361,6 @@ pub(super) fn execute_provider_sync_mutation<F>(
rollouts: &RolloutScan,
pending_sqlite: &mut [PendingSqliteUpdate],
target_provider: &str,
global_state_path: &Path,
journal: &mut MutationJournal,
hook: &mut F,
) -> Result<MutationResult>
@@ -382,11 +371,6 @@ where
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),
)?;
let sqlite_updates = commit_sqlite_updates(pending_sqlite, journal, hook)?;
Ok(MutationResult {
applied_rollouts: journal.applied_rollouts.len(),
@@ -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
}
@@ -332,53 +334,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 +396,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 +435,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");
+11 -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,16 @@ 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) mismatched_thread_ids: HashSet<String>,
pub(crate) warnings: Vec<String>,
pub(crate) scan_failures: Vec<String>,
}
#[derive(Debug, Clone)]
@@ -74,5 +80,9 @@ 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) 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>,
}
@@ -0,0 +1,21 @@
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] {
&[]
}
@@ -0,0 +1,130 @@
use crate::error::{CodexxError, Result};
use crate::file_io::{atomic_write, ensure_directory};
use crate::skins::skins_root;
use sha2::{Digest, Sha256};
use std::fs;
use std::path::{Path, PathBuf};
pub(super) const RUNTIME_VERSION: &str = "1.2.2-codexx.3";
pub(super) const UPSTREAM_COMMIT: &str = "5fd8af532efbaa87d2d0092297fd2d45cd56574e";
struct EmbeddedRuntimeAsset {
relative_path: &'static str,
bytes: &'static [u8],
}
fn embedded_assets() -> &'static [EmbeddedRuntimeAsset] {
&[
EmbeddedRuntimeAsset {
relative_path: "scripts/injector.mjs",
bytes: include_bytes!("../../resources/skin-runtime/scripts/injector.mjs"),
},
EmbeddedRuntimeAsset {
relative_path: "scripts/image-metadata.mjs",
bytes: include_bytes!("../../resources/skin-runtime/scripts/image-metadata.mjs"),
},
EmbeddedRuntimeAsset {
relative_path: "scripts/stage-theme.mjs",
bytes: include_bytes!("../../resources/skin-runtime/scripts/stage-theme.mjs"),
},
EmbeddedRuntimeAsset {
relative_path: "assets/dream-skin.css",
bytes: include_bytes!("../../resources/skin-runtime/assets/dream-skin.css"),
},
EmbeddedRuntimeAsset {
relative_path: "assets/renderer-inject.js",
bytes: include_bytes!("../../resources/skin-runtime/assets/renderer-inject.js"),
},
EmbeddedRuntimeAsset {
relative_path: "windows/codexx-windows.ps1",
bytes: include_bytes!("../../resources/skin-runtime/windows/codexx-windows.ps1"),
},
EmbeddedRuntimeAsset {
relative_path: "windows/common-windows.ps1",
bytes: include_bytes!("../../resources/skin-runtime/windows/common-windows.ps1"),
},
EmbeddedRuntimeAsset {
relative_path: "windows/config-utf8.ps1",
bytes: include_bytes!("../../resources/skin-runtime/windows/config-utf8.ps1"),
},
EmbeddedRuntimeAsset {
relative_path: "LICENSE",
bytes: include_bytes!("../../resources/skin-runtime/LICENSE"),
},
EmbeddedRuntimeAsset {
relative_path: "NOTICE.md",
bytes: include_bytes!("../../resources/skin-runtime/NOTICE.md"),
},
]
}
fn bytes_match(path: &Path, expected: &[u8]) -> bool {
if !fs::symlink_metadata(path)
.is_ok_and(|metadata| metadata.is_file() && !metadata.file_type().is_symlink())
{
return false;
}
let Ok(actual) = fs::read(path) else {
return false;
};
Sha256::digest(actual) == Sha256::digest(expected)
}
pub(super) fn ensure_runtime_directory(path: &Path) -> Result<()> {
ensure_directory(path)?;
let metadata =
fs::symlink_metadata(path).map_err(|source| crate::file_io::io_err(path, source))?;
if metadata.file_type().is_symlink() || !metadata.is_dir() {
return Err(CodexxError::Config(format!(
"皮肤运行时目录不能是符号链接或普通文件: {}",
path.display()
)));
}
set_private_permissions(path, true)
}
#[cfg(unix)]
fn set_private_permissions(path: &Path, directory: bool) -> Result<()> {
use std::os::unix::fs::PermissionsExt;
let mode = if directory { 0o700 } else { 0o600 };
fs::set_permissions(path, fs::Permissions::from_mode(mode))
.map_err(|source| crate::file_io::io_err(path, source))
}
#[cfg(not(unix))]
fn set_private_permissions(_path: &Path, _directory: bool) -> Result<()> {
Ok(())
}
pub(super) struct RuntimeAssets {
pub(super) injector: PathBuf,
pub(super) stage_theme: PathBuf,
#[cfg(any(target_os = "windows", feature = "windows-runtime-check"))]
#[cfg_attr(feature = "windows-runtime-check", allow(dead_code))]
pub(super) windows_adapter: PathBuf,
}
pub(super) fn ensure_runtime_assets() -> Result<RuntimeAssets> {
let base = skins_root()?.join("runtime");
ensure_runtime_directory(&base)?;
let root = base.join(RUNTIME_VERSION);
ensure_runtime_directory(&root)?;
for asset in embedded_assets() {
let path = root.join(asset.relative_path);
let parent = path.parent().ok_or_else(|| {
CodexxError::Config(format!("内置皮肤资源路径无效: {}", path.display()))
})?;
ensure_runtime_directory(parent)?;
if !bytes_match(&path, asset.bytes) {
atomic_write(&path, asset.bytes)?;
}
set_private_permissions(&path, false)?;
}
Ok(RuntimeAssets {
injector: root.join("scripts/injector.mjs"),
stage_theme: root.join("scripts/stage-theme.mjs"),
#[cfg(any(target_os = "windows", feature = "windows-runtime-check"))]
windows_adapter: root.join("windows/codexx-windows.ps1"),
})
}
@@ -0,0 +1,786 @@
use super::assets::{
ensure_runtime_assets, ensure_runtime_directory, RuntimeAssets, RUNTIME_VERSION,
UPSTREAM_COMMIT,
};
use super::macos_injector::{
launch_watcher, remove_live_skin, run_injector_once, stage_theme_snapshot,
};
use super::macos_system::{discover_codex_runtime, CodexRuntime};
use super::{SkinRuntimeAction, SkinRuntimeStatus};
use crate::error::{CodexxError, Result};
use crate::file_io::{atomic_write, io_err};
use crate::now_rfc3339;
use crate::skins::skins_root;
use serde::{Deserialize, Serialize};
use std::fs::{self, File, OpenOptions};
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::thread;
use std::time::{Duration, Instant};
const DEFAULT_CDP_PORT: u16 = 9341;
const MAX_CDP_PORT_OFFSET: u16 = 100;
const RUNTIME_STATE_SCHEMA: u32 = 1;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct RuntimeStateFile {
schema_version: u32,
runtime_version: String,
upstream_commit: String,
session: String,
port: u16,
injector_pid: u32,
injector_started_at: String,
injector_path: String,
node_path: String,
node_version: String,
codex_bundle: String,
codex_executable: String,
codex_version: String,
codex_team_id: String,
theme_id: String,
theme_dir: String,
created_at: String,
updated_at: String,
}
fn runtime_root() -> Result<PathBuf> {
Ok(skins_root()?.join("runtime"))
}
fn state_path() -> Result<PathBuf> {
Ok(runtime_root()?.join("state.json"))
}
pub(super) fn snapshots_root() -> Result<PathBuf> {
Ok(runtime_root()?.join("themes"))
}
fn logs_root() -> Result<PathBuf> {
Ok(runtime_root()?.join("logs"))
}
fn read_runtime_state() -> Result<Option<RuntimeStateFile>> {
let path = state_path()?;
let metadata = match fs::symlink_metadata(&path) {
Ok(metadata) => metadata,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(error) => return Err(io_err(&path, error)),
};
if metadata.file_type().is_symlink() || !metadata.is_file() || metadata.len() > 256 * 1024 {
return Err(CodexxError::Config(
"皮肤运行状态文件不是安全的普通文件,已停止自动处理".to_string(),
));
}
let bytes = fs::read(&path).map_err(|source| io_err(&path, source))?;
let state: RuntimeStateFile = serde_json::from_slice(&bytes)
.map_err(|error| CodexxError::Config(format!("解析皮肤运行状态失败: {error}")))?;
if state.schema_version != RUNTIME_STATE_SCHEMA || !(1024..=65535).contains(&state.port) {
return Err(CodexxError::Config(
"皮肤运行状态版本或端口无效,已停止自动处理".to_string(),
));
}
Ok(Some(state))
}
fn write_runtime_state(state: &RuntimeStateFile) -> Result<()> {
let path = state_path()?;
let text = serde_json::to_string_pretty(state)
.map_err(|error| CodexxError::Config(format!("序列化皮肤运行状态失败: {error}")))?;
atomic_write(&path, format!("{text}\n").as_bytes())?;
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&path, fs::Permissions::from_mode(0o600))
.map_err(|source| io_err(&path, source))
}
fn remove_runtime_state() -> Result<()> {
let path = state_path()?;
match fs::remove_file(&path) {
Ok(()) => Ok(()),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(error) => Err(io_err(&path, error)),
}
}
fn process_table() -> Vec<(u32, String)> {
let Ok(output) = Command::new("/bin/ps")
.args(["-axo", "pid=,command="])
.output()
else {
return Vec::new();
};
String::from_utf8_lossy(&output.stdout)
.lines()
.filter_map(|line| {
let trimmed = line.trim_start();
let split = trimmed.find(char::is_whitespace)?;
let pid = trimmed[..split].parse::<u32>().ok()?;
Some((pid, trimmed[split..].trim_start().to_string()))
})
.collect()
}
pub(super) fn process_command(pid: u32) -> Option<String> {
let output = Command::new("/bin/ps")
.args(["-p", &pid.to_string(), "-o", "command="])
.output()
.ok()?;
let command = String::from_utf8_lossy(&output.stdout).trim().to_string();
(!command.is_empty()).then_some(command)
}
pub(super) fn process_started_at(pid: u32) -> Option<String> {
let output = Command::new("/bin/ps")
.args(["-p", &pid.to_string(), "-o", "lstart="])
.output()
.ok()?;
let value = String::from_utf8_lossy(&output.stdout)
.split_whitespace()
.collect::<Vec<_>>()
.join(" ");
(!value.is_empty()).then_some(value)
}
pub(super) fn process_alive(pid: u32) -> bool {
pid > 0
&& Command::new("/bin/kill")
.args(["-0", &pid.to_string()])
.status()
.is_ok_and(|status| status.success())
}
fn codex_main_pids(runtime: &CodexRuntime) -> Vec<u32> {
let prefix = runtime.executable.to_string_lossy();
process_table()
.into_iter()
.filter_map(|(pid, command)| command.starts_with(prefix.as_ref()).then_some(pid))
.collect()
}
fn codex_is_running(runtime: &CodexRuntime) -> bool {
!codex_main_pids(runtime).is_empty()
}
fn debug_port_from_command(command: &str) -> Option<u16> {
command.split_whitespace().find_map(|argument| {
argument
.strip_prefix("--remote-debugging-port=")?
.parse::<u16>()
.ok()
.filter(|port| *port >= 1024)
})
}
fn process_parent(pid: u32) -> Option<u32> {
let output = Command::new("/bin/ps")
.args(["-p", &pid.to_string(), "-o", "ppid="])
.output()
.ok()?;
String::from_utf8_lossy(&output.stdout).trim().parse().ok()
}
fn pid_is_codex_descendant(runtime: &CodexRuntime, mut pid: u32) -> bool {
let executable = runtime.executable.to_string_lossy();
for _ in 0..32 {
if process_command(pid).is_some_and(|command| command.starts_with(executable.as_ref())) {
return true;
}
let Some(parent) = process_parent(pid) else {
return false;
};
if parent <= 1 || parent == pid {
return false;
}
pid = parent;
}
false
}
fn listener_pids(port: u16) -> Vec<u32> {
let Ok(output) = Command::new("/usr/sbin/lsof")
.args(["-nP", &format!("-iTCP:{port}"), "-sTCP:LISTEN", "-t"])
.output()
else {
return Vec::new();
};
String::from_utf8_lossy(&output.stdout)
.lines()
.filter_map(|line| line.trim().parse().ok())
.collect()
}
fn port_belongs_to_codex(runtime: &CodexRuntime, port: u16) -> bool {
let pids = listener_pids(port);
!pids.is_empty()
&& pids
.into_iter()
.all(|pid| pid_is_codex_descendant(runtime, pid))
}
fn cdp_http_ready(port: u16) -> bool {
let Ok(client) = reqwest::blocking::Client::builder()
.no_proxy()
.timeout(Duration::from_secs(2))
.build()
else {
return false;
};
client
.get(format!("http://127.0.0.1:{port}/json/version"))
.send()
.is_ok_and(|response| response.status().is_success())
}
fn verified_cdp_endpoint(runtime: &CodexRuntime, port: u16) -> bool {
port_belongs_to_codex(runtime, port) && cdp_http_ready(port)
}
fn running_cdp_port(runtime: &CodexRuntime) -> Option<u16> {
codex_main_pids(runtime)
.into_iter()
.filter_map(process_command)
.filter_map(|command| debug_port_from_command(&command))
.find(|port| verified_cdp_endpoint(runtime, *port))
}
fn select_available_port(preferred: u16) -> Result<u16> {
let last = preferred.saturating_add(MAX_CDP_PORT_OFFSET);
(preferred..=last)
.find(|port| listener_pids(*port).is_empty())
.ok_or_else(|| {
CodexxError::Config(format!("未找到可用的本机 CDP 端口: {preferred}-{last}"))
})
}
fn wait_for_cdp(runtime: &CodexRuntime, port: u16, timeout: Duration) -> Result<()> {
let deadline = Instant::now() + timeout;
while Instant::now() < deadline {
if verified_cdp_endpoint(runtime, port) {
return Ok(());
}
thread::sleep(Duration::from_millis(350));
}
Err(CodexxError::Config(format!(
"Codex 未在 127.0.0.1:{port} 打开经过验证的调试端口"
)))
}
fn external_skin_watcher_pid(own_state: Option<&RuntimeStateFile>) -> Option<u32> {
process_table().into_iter().find_map(|(pid, command)| {
let is_external = command.contains("codex-dream-skin-studio")
&& command.contains("injector.mjs")
&& command.contains("--watch");
(is_external && own_state.is_none_or(|state| state.injector_pid != pid)).then_some(pid)
})
}
fn canonical_managed_snapshot(path: &Path) -> Option<PathBuf> {
let metadata = fs::symlink_metadata(path).ok()?;
if metadata.file_type().is_symlink() || !metadata.is_dir() {
return None;
}
let root = fs::canonicalize(snapshots_root().ok()?).ok()?;
let candidate = fs::canonicalize(path).ok()?;
candidate.starts_with(&root).then_some(candidate)
}
fn is_managed_injector(path: &Path) -> bool {
let Ok(metadata) = fs::symlink_metadata(path) else {
return false;
};
if metadata.file_type().is_symlink() || !metadata.is_file() {
return false;
}
let Ok(root) = runtime_root()
.and_then(|path| fs::canonicalize(&path).map_err(|source| io_err(&path, source)))
else {
return false;
};
fs::canonicalize(path).is_ok_and(|candidate| {
candidate.starts_with(root)
&& candidate.file_name().and_then(|name| name.to_str()) == Some("injector.mjs")
})
}
fn state_process_matches(state: &RuntimeStateFile, runtime: &CodexRuntime) -> bool {
if state.injector_pid == 0
|| state.node_path != runtime.node.to_string_lossy()
|| !is_managed_injector(Path::new(&state.injector_path))
{
return false;
}
if canonical_managed_snapshot(Path::new(&state.theme_dir)).is_none() {
return false;
}
let Some(command) = process_command(state.injector_pid) else {
return false;
};
let expected = format!(
"{} --watch --port {} --theme-dir {}",
state.injector_path, state.port, state.theme_dir
);
command.starts_with(runtime.node.to_string_lossy().as_ref())
&& command.contains(&expected)
&& process_started_at(state.injector_pid).as_deref()
== Some(state.injector_started_at.as_str())
}
fn stop_recorded_injector(state: &RuntimeStateFile, runtime: &CodexRuntime) -> Result<()> {
if state.injector_pid == 0 || !process_alive(state.injector_pid) {
return Ok(());
}
if !state_process_matches(state, runtime) {
return Err(CodexxError::Config(format!(
"记录的皮肤进程 PID {} 身份不匹配,已拒绝终止",
state.injector_pid
)));
}
let pid = state.injector_pid.to_string();
let _ = Command::new("/bin/kill").args(["-TERM", &pid]).status();
let deadline = Instant::now() + Duration::from_secs(6);
while Instant::now() < deadline && state_process_matches(state, runtime) {
thread::sleep(Duration::from_millis(150));
}
if state_process_matches(state, runtime) {
let _ = Command::new("/bin/kill").args(["-KILL", &pid]).status();
}
let deadline = Instant::now() + Duration::from_secs(2);
while Instant::now() < deadline && state_process_matches(state, runtime) {
thread::sleep(Duration::from_millis(100));
}
if state_process_matches(state, runtime) {
return Err(CodexxError::Config(format!(
"无法停止皮肤注入器 PID {}",
state.injector_pid
)));
}
Ok(())
}
fn stop_codex(runtime: &CodexRuntime) -> Result<()> {
let _ = Command::new("/usr/bin/osascript")
.args(["-e", "tell application id \"com.openai.codex\" to quit"])
.status();
let deadline = Instant::now() + Duration::from_secs(15);
while Instant::now() < deadline && codex_is_running(runtime) {
thread::sleep(Duration::from_millis(250));
}
if !codex_is_running(runtime) {
return Ok(());
}
for pid in codex_main_pids(runtime) {
let _ = Command::new("/bin/kill")
.args(["-TERM", &pid.to_string()])
.status();
}
let deadline = Instant::now() + Duration::from_secs(5);
while Instant::now() < deadline && codex_is_running(runtime) {
thread::sleep(Duration::from_millis(250));
}
if codex_is_running(runtime) {
for pid in codex_main_pids(runtime) {
let _ = Command::new("/bin/kill")
.args(["-KILL", &pid.to_string()])
.status();
}
thread::sleep(Duration::from_millis(500));
}
if codex_is_running(runtime) {
Err(CodexxError::Config(
"无法安全关闭 Codex,换肤操作已中止".to_string(),
))
} else {
Ok(())
}
}
pub(super) fn log_files(prefix: &str) -> Result<(File, File)> {
use std::os::unix::fs::PermissionsExt;
let root = logs_root()?;
ensure_runtime_directory(&root)?;
let stdout_path = root.join(format!("{prefix}.log"));
let stderr_path = root.join(format!("{prefix}-error.log"));
let stdout = OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(&stdout_path)
.map_err(|source| io_err(&stdout_path, source))?;
let stderr = OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(&stderr_path)
.map_err(|source| io_err(&stderr_path, source))?;
fs::set_permissions(&stdout_path, fs::Permissions::from_mode(0o600))
.map_err(|source| io_err(&stdout_path, source))?;
fs::set_permissions(&stderr_path, fs::Permissions::from_mode(0o600))
.map_err(|source| io_err(&stderr_path, source))?;
Ok((stdout, stderr))
}
fn launch_codex_with_cdp(runtime: &CodexRuntime, port: u16) -> Result<()> {
let (stdout, stderr) = log_files("codex-launch")?;
let status = Command::new("/usr/bin/open")
.arg("-na")
.arg(&runtime.bundle)
.arg("--args")
.arg("--remote-debugging-address=127.0.0.1")
.arg(format!("--remote-debugging-port={port}"))
.stdout(Stdio::from(stdout))
.stderr(Stdio::from(stderr))
.status()
.map_err(|source| io_err(&runtime.bundle, source))?;
if !status.success() {
return Err(CodexxError::Config(
"使用本机调试端口启动 Codex 失败".to_string(),
));
}
if wait_for_cdp(runtime, port, Duration::from_secs(12)).is_ok() {
return Ok(());
}
if codex_is_running(runtime) {
stop_codex(runtime)?;
}
let (stdout, stderr) = log_files("codex-launch-direct")?;
let mut child = Command::new(&runtime.executable)
.arg("--remote-debugging-address=127.0.0.1")
.arg(format!("--remote-debugging-port={port}"))
.stdin(Stdio::null())
.stdout(Stdio::from(stdout))
.stderr(Stdio::from(stderr))
.spawn()
.map_err(|source| io_err(&runtime.executable, source))?;
thread::Builder::new()
.name("codex-x-codex-launch-reaper".to_string())
.spawn(move || {
let _ = child.wait();
})
.map_err(|error| CodexxError::Config(format!("启动 Codex 进程监控失败: {error}")))?;
wait_for_cdp(runtime, port, Duration::from_secs(45))
}
fn launch_codex_normally(runtime: &CodexRuntime) -> Result<()> {
let status = Command::new("/usr/bin/open")
.arg("-na")
.arg(&runtime.bundle)
.status()
.map_err(|source| io_err(&runtime.bundle, source))?;
if status.success() {
Ok(())
} else {
Err(CodexxError::Config(
"恢复官方外观后重新启动 Codex 失败".to_string(),
))
}
}
fn active_state(
runtime: &CodexRuntime,
assets: &RuntimeAssets,
port: u16,
theme_id: &str,
theme_dir: &Path,
pid: u32,
started_at: String,
) -> RuntimeStateFile {
let timestamp = now_rfc3339();
RuntimeStateFile {
schema_version: RUNTIME_STATE_SCHEMA,
runtime_version: RUNTIME_VERSION.to_string(),
upstream_commit: UPSTREAM_COMMIT.to_string(),
session: "active".to_string(),
port,
injector_pid: pid,
injector_started_at: started_at,
injector_path: assets.injector.to_string_lossy().to_string(),
node_path: runtime.node.to_string_lossy().to_string(),
node_version: runtime.node_version.clone(),
codex_bundle: runtime.bundle.to_string_lossy().to_string(),
codex_executable: runtime.executable.to_string_lossy().to_string(),
codex_version: runtime.version.clone(),
codex_team_id: runtime.team_id.clone(),
theme_id: theme_id.to_string(),
theme_dir: theme_dir.to_string_lossy().to_string(),
created_at: timestamp.clone(),
updated_at: timestamp,
}
}
fn cleanup_snapshot(path: &str, keep: &Path) {
let Some(path) = canonical_managed_snapshot(Path::new(path)) else {
return;
};
let keep = canonical_managed_snapshot(keep);
if keep.as_ref() != Some(&path) {
let _ = fs::remove_dir_all(&path);
}
}
fn restore_previous_after_failure(
runtime: &CodexRuntime,
assets: &RuntimeAssets,
previous: Option<&RuntimeStateFile>,
) {
let Some(previous) = previous else {
return;
};
let theme_dir = Path::new(&previous.theme_dir);
if previous.session != "active"
|| !theme_dir.is_dir()
|| !verified_cdp_endpoint(runtime, previous.port)
|| run_injector_once(runtime, assets, previous.port, theme_dir).is_err()
{
return;
}
if let Ok((pid, started_at)) = launch_watcher(runtime, assets, previous.port, theme_dir) {
let restored = active_state(
runtime,
assets,
previous.port,
&previous.theme_id,
theme_dir,
pid,
started_at,
);
let _ = write_runtime_state(&restored);
}
}
pub(super) fn apply_theme(
source: &Path,
theme_id: &str,
restart_existing: bool,
) -> Result<SkinRuntimeAction> {
let runtime = discover_codex_runtime()?;
let assets = ensure_runtime_assets()?;
let previous = read_runtime_state()?;
if let Some(pid) = external_skin_watcher_pid(previous.as_ref()) {
return Err(CodexxError::Config(format!(
"检测到外部 Codex Dream Skin 注入器 PID {pid},请先在原工具中暂停或恢复官方外观"
)));
}
let running = codex_is_running(&runtime);
let verified_port = previous
.as_ref()
.map(|state| state.port)
.filter(|port| verified_cdp_endpoint(&runtime, *port))
.or_else(|| running_cdp_port(&runtime));
if running && verified_port.is_none() && !restart_existing {
return Ok(SkinRuntimeAction::RestartRequired(
"Codex 需要重启一次才能打开仅限本机的换肤调试端口".to_string(),
));
}
let snapshot = stage_theme_snapshot(&runtime, &assets, source, theme_id)?;
let apply_result = (|| -> Result<RuntimeStateFile> {
if let Some(state) = previous.as_ref() {
stop_recorded_injector(state, &runtime)?;
}
let port = if let Some(port) = verified_port {
port
} else {
if running {
stop_codex(&runtime)?;
}
let preferred = previous
.as_ref()
.map_or(DEFAULT_CDP_PORT, |state| state.port);
let port = select_available_port(preferred)?;
launch_codex_with_cdp(&runtime, port)?;
port
};
run_injector_once(&runtime, &assets, port, &snapshot)?;
let (pid, started_at) = launch_watcher(&runtime, &assets, port, &snapshot)?;
let state = active_state(
&runtime, &assets, port, theme_id, &snapshot, pid, started_at,
);
if let Err(error) = write_runtime_state(&state) {
let _ = stop_recorded_injector(&state, &runtime);
return Err(error);
}
Ok(state)
})();
match apply_result {
Ok(_) => {
if let Some(previous) = previous.as_ref() {
cleanup_snapshot(&previous.theme_dir, &snapshot);
}
Ok(SkinRuntimeAction::Applied("已应用 Codex 皮肤".to_string()))
}
Err(error) => {
let _ = fs::remove_dir_all(&snapshot);
restore_previous_after_failure(&runtime, &assets, previous.as_ref());
Err(error)
}
}
}
pub(super) fn pause_theme() -> Result<SkinRuntimeAction> {
let assets = ensure_runtime_assets()?;
let Some(mut state) = read_runtime_state()? else {
return Ok(SkinRuntimeAction::Paused(
"Codex 皮肤当前没有运行".to_string(),
));
};
let runtime = discover_codex_runtime()?;
if let Some(pid) = external_skin_watcher_pid(Some(&state)) {
return Err(CodexxError::Config(format!(
"检测到外部 Codex Dream Skin 注入器 PID {pid},未处理该进程"
)));
}
stop_recorded_injector(&state, &runtime)?;
if verified_cdp_endpoint(&runtime, state.port) {
if let Err(error) =
remove_live_skin(&runtime, &assets, state.port, Path::new(&state.theme_dir))
{
restore_previous_after_failure(&runtime, &assets, Some(&state));
return Err(error);
}
}
state.session = "paused".to_string();
state.injector_pid = 0;
state.injector_started_at.clear();
state.updated_at = now_rfc3339();
write_runtime_state(&state)?;
Ok(SkinRuntimeAction::Paused(
"已关闭 Codex 皮肤;Codex 保持打开".to_string(),
))
}
pub(super) fn restore_official(restart_existing: bool) -> Result<SkinRuntimeAction> {
let assets = ensure_runtime_assets()?;
let Some(state) = read_runtime_state()? else {
return Ok(SkinRuntimeAction::Restored(
"Codex 当前已使用官方外观".to_string(),
));
};
let runtime = discover_codex_runtime()?;
if let Some(pid) = external_skin_watcher_pid(Some(&state)) {
return Err(CodexxError::Config(format!(
"检测到外部 Codex Dream Skin 注入器 PID {pid},未处理该进程"
)));
}
let running = codex_is_running(&runtime);
let debug_ready = verified_cdp_endpoint(&runtime, state.port);
if running && (debug_ready || state.session == "active") && !restart_existing {
return Ok(SkinRuntimeAction::RestartRequired(
"完整恢复需要重启 Codex,以关闭本机调试端口并清除当前渲染状态".to_string(),
));
}
stop_recorded_injector(&state, &runtime)?;
if debug_ready {
remove_live_skin(&runtime, &assets, state.port, Path::new(&state.theme_dir))?;
}
if running && (debug_ready || state.session == "active") {
stop_codex(&runtime)?;
launch_codex_normally(&runtime)?;
}
remove_runtime_state()?;
cleanup_snapshot(&state.theme_dir, Path::new(""));
Ok(SkinRuntimeAction::Restored(
"已恢复 Codex 官方外观并关闭 Codex-X 管理的换肤运行时".to_string(),
))
}
pub(super) fn runtime_status() -> SkinRuntimeStatus {
if let Err(error) = ensure_runtime_assets() {
return SkinRuntimeStatus {
supported: true,
active: false,
phase: "error".to_string(),
port: None,
theme_id: None,
message: error.to_string(),
};
}
let state = match read_runtime_state() {
Ok(Some(state)) => state,
Ok(None) => {
return SkinRuntimeStatus {
supported: true,
active: false,
phase: "inactive".to_string(),
port: None,
theme_id: None,
message: "尚未应用 Codex 皮肤".to_string(),
}
}
Err(error) => {
return SkinRuntimeStatus {
supported: true,
active: false,
phase: "error".to_string(),
port: None,
theme_id: None,
message: error.to_string(),
}
}
};
if state.session == "paused" {
return SkinRuntimeStatus {
supported: true,
active: false,
phase: "paused".to_string(),
port: Some(state.port),
theme_id: Some(state.theme_id),
message: "皮肤已暂停,Codex 保持打开".to_string(),
};
}
let runtime = match discover_codex_runtime() {
Ok(runtime) => runtime,
Err(error) => {
return SkinRuntimeStatus {
supported: true,
active: false,
phase: "unavailable".to_string(),
port: Some(state.port),
theme_id: Some(state.theme_id),
message: error.to_string(),
}
}
};
let active =
state_process_matches(&state, &runtime) && verified_cdp_endpoint(&runtime, state.port);
SkinRuntimeStatus {
supported: true,
active,
phase: if active { "active" } else { "stale" }.to_string(),
port: Some(state.port),
theme_id: Some(state.theme_id),
message: if active {
"Codex 皮肤运行中".to_string()
} else {
"皮肤运行状态已失效,可重新应用或恢复官方外观".to_string()
},
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn debug_port_parser_accepts_only_bounded_numeric_arguments() {
assert_eq!(
debug_port_from_command(
"/Applications/ChatGPT.app/Contents/MacOS/ChatGPT --remote-debugging-port=9341"
),
Some(9341)
);
assert_eq!(
debug_port_from_command("Codex --remote-debugging-port=80"),
None
);
assert_eq!(
debug_port_from_command("Codex --remote-debugging-port=not-a-port"),
None
);
assert_eq!(debug_port_from_command("Codex"), None);
}
}
@@ -0,0 +1,73 @@
use super::assets::RuntimeAssets;
use super::macos::{log_files, process_alive, process_started_at, snapshots_root};
use super::macos_system::CodexRuntime;
use super::node_runtime;
use crate::error::{CodexxError, Result};
use crate::file_io::io_err;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::thread;
use std::time::Duration;
pub(super) fn stage_theme_snapshot(
runtime: &CodexRuntime,
assets: &RuntimeAssets,
source: &Path,
theme_id: &str,
) -> Result<PathBuf> {
node_runtime::stage_theme_snapshot(&runtime.node, assets, &snapshots_root()?, source, theme_id)
}
pub(super) fn run_injector_once(
runtime: &CodexRuntime,
assets: &RuntimeAssets,
port: u16,
theme_dir: &Path,
) -> Result<()> {
node_runtime::run_injector_once(&runtime.node, assets, port, theme_dir)
}
pub(super) fn remove_live_skin(
runtime: &CodexRuntime,
assets: &RuntimeAssets,
port: u16,
theme_dir: &Path,
) -> Result<()> {
node_runtime::remove_live_skin(&runtime.node, assets, port, theme_dir)
}
pub(super) fn launch_watcher(
runtime: &CodexRuntime,
assets: &RuntimeAssets,
port: u16,
theme_dir: &Path,
) -> Result<(u32, String)> {
let (stdout, stderr) = log_files("injector")?;
let mut child = Command::new(&runtime.node)
.arg(&assets.injector)
.args(["--watch", "--port"])
.arg(port.to_string())
.arg("--theme-dir")
.arg(theme_dir)
.stdin(Stdio::null())
.stdout(Stdio::from(stdout))
.stderr(Stdio::from(stderr))
.spawn()
.map_err(|source| io_err(&assets.injector, source))?;
let pid = child.id();
thread::Builder::new()
.name("codex-x-skin-injector-reaper".to_string())
.spawn(move || {
let _ = child.wait();
})
.map_err(|error| CodexxError::Config(format!("启动皮肤进程监控失败: {error}")))?;
thread::sleep(Duration::from_millis(180));
if !process_alive(pid) {
return Err(CodexxError::Config(
"皮肤注入器启动后立即退出,请检查运行日志".to_string(),
));
}
let started_at = process_started_at(pid)
.ok_or_else(|| CodexxError::Config("无法记录皮肤注入器启动时间".to_string()))?;
Ok((pid, started_at))
}
@@ -0,0 +1,199 @@
use super::node_runtime::command_detail;
use crate::error::{CodexxError, Result};
use crate::file_io::io_err;
use std::path::{Path, PathBuf};
use std::process::Command;
const EXPECTED_BUNDLE_ID: &str = "com.openai.codex";
const EXPECTED_TEAM_ID: &str = "2DC432GLL2";
#[derive(Debug, Clone)]
pub(super) struct CodexRuntime {
pub(super) bundle: PathBuf,
pub(super) executable: PathBuf,
pub(super) version: String,
pub(super) node: PathBuf,
pub(super) node_version: String,
pub(super) team_id: String,
}
fn output_text(program: &Path, args: &[&str], label: &str) -> Result<String> {
let output = Command::new(program)
.args(args)
.output()
.map_err(|source| io_err(program, source))?;
if !output.status.success() {
let detail = command_detail(&output);
return Err(CodexxError::Config(if detail.is_empty() {
format!("{label}失败")
} else {
format!("{label}失败: {detail}")
}));
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
fn plist_value(bundle: &Path, key: &str) -> Result<String> {
let plist = bundle.join("Contents/Info.plist");
output_text(
Path::new("/usr/bin/plutil"),
&["-extract", key, "raw", "-o", "-", &plist.to_string_lossy()],
"读取 Codex 应用信息",
)
}
fn bundle_candidates() -> Result<Vec<PathBuf>> {
let home = crate::paths::home_dir()?;
let mut candidates = vec![
PathBuf::from("/Applications/ChatGPT.app"),
home.join("Applications/ChatGPT.app"),
PathBuf::from("/Applications/Codex.app"),
home.join("Applications/Codex.app"),
];
if let Ok(output) = Command::new("/usr/bin/mdfind")
.arg(format!(
"kMDItemCFBundleIdentifier == \"{EXPECTED_BUNDLE_ID}\""
))
.output()
{
if output.status.success() {
candidates.extend(
String::from_utf8_lossy(&output.stdout)
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.map(PathBuf::from),
);
}
}
candidates.dedup();
Ok(candidates)
}
fn codesign_team_id(path: &Path) -> Result<String> {
let output = Command::new("/usr/bin/codesign")
.args(["-dv", "--verbose=4"])
.arg(path)
.output()
.map_err(|source| io_err(path, source))?;
let combined = format!(
"{}\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
combined
.lines()
.find_map(|line| line.trim().strip_prefix("TeamIdentifier="))
.map(str::to_string)
.ok_or_else(|| CodexxError::Config("无法读取 Codex 签名 Team ID".to_string()))
}
fn verify_signature(path: &Path, deep: bool) -> Result<()> {
let mut command = Command::new("/usr/bin/codesign");
command.arg("--verify");
if deep {
command.arg("--deep");
}
let output = command
.arg("--strict")
.arg(path)
.output()
.map_err(|source| io_err(path, source))?;
if output.status.success() {
Ok(())
} else {
Err(CodexxError::Config(format!(
"官方 Codex 代码签名校验失败: {}",
path.display()
)))
}
}
pub(super) fn discover_codex_runtime() -> Result<CodexRuntime> {
let mut bundle = None;
for candidate in bundle_candidates()? {
if !candidate.join("Contents/Info.plist").is_file() {
continue;
}
if plist_value(&candidate, "CFBundleIdentifier")
.ok()
.as_deref()
== Some(EXPECTED_BUNDLE_ID)
{
bundle = Some(candidate);
break;
}
}
let bundle = bundle.ok_or_else(|| {
CodexxError::Config("未找到官方 Codex Desktop (com.openai.codex)".to_string())
})?;
let executable_name = plist_value(&bundle, "CFBundleExecutable")?;
if executable_name.is_empty() || executable_name.contains('/') || executable_name.contains('\\')
{
return Err(CodexxError::Config(
"Codex 应用声明了无效的可执行文件名".to_string(),
));
}
let executable = bundle.join("Contents/MacOS").join(executable_name);
let node = bundle.join("Contents/Resources/cua_node/bin/node");
if !executable.is_file() || !node.is_file() {
return Err(CodexxError::Config(
"Codex 可执行文件或内置 Node.js 不存在,请更新或重新安装官方 Codex".to_string(),
));
}
verify_signature(&bundle, true)?;
verify_signature(&node, false)?;
let team_id = codesign_team_id(&bundle)?;
let node_team_id = codesign_team_id(&node)?;
if team_id != EXPECTED_TEAM_ID || node_team_id != team_id {
return Err(CodexxError::Config(format!(
"Codex 签名身份不匹配,拒绝启动换肤运行时: {team_id}"
)));
}
let node_version = output_text(&node, &["--version"], "读取 Codex Node.js 版本")?;
let node_major = node_version
.trim_start_matches('v')
.split('.')
.next()
.and_then(|value| value.parse::<u32>().ok())
.ok_or_else(|| CodexxError::Config(format!("无法解析 Node.js 版本: {node_version}")))?;
if node_major < 20 {
return Err(CodexxError::Config(format!(
"Codex 内置 Node.js 版本过低: {node_version},需要 20 或更高版本"
)));
}
let machine_arch = output_text(Path::new("/usr/bin/uname"), &["-m"], "读取系统架构")?;
let node_file = output_text(
Path::new("/usr/bin/file"),
&[&node.to_string_lossy()],
"读取 Codex Node.js 架构",
)?;
if !node_file.contains(&machine_arch) {
return Err(CodexxError::Config(format!(
"Codex 内置 Node.js 与当前 Mac 架构不匹配: {machine_arch}"
)));
}
Ok(CodexRuntime {
version: plist_value(&bundle, "CFBundleShortVersionString")?,
bundle,
executable,
node,
node_version,
team_id,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn command_detail_is_bounded() {
let output = Command::new("/usr/bin/printf")
.arg("%0900d")
.arg("1")
.output()
.expect("create fixture output");
assert!(command_detail(&output).chars().count() <= 800);
}
}
@@ -0,0 +1,121 @@
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
use crate::error::CodexxError;
use crate::error::Result;
use serde::Serialize;
use std::path::Path;
mod assets;
#[cfg(target_os = "macos")]
mod macos;
#[cfg(target_os = "macos")]
mod macos_injector;
#[cfg(target_os = "macos")]
mod macos_system;
mod node_runtime;
#[cfg(any(target_os = "windows", feature = "windows-runtime-check"))]
#[cfg_attr(feature = "windows-runtime-check", allow(dead_code))]
mod windows;
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct SkinRuntimeStatus {
pub(crate) supported: bool,
pub(crate) active: bool,
pub(crate) phase: String,
pub(crate) port: Option<u16>,
pub(crate) theme_id: Option<String>,
pub(crate) message: String,
}
impl SkinRuntimeStatus {
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
fn unsupported() -> Self {
Self {
supported: false,
active: false,
phase: "unsupported".to_string(),
port: None,
theme_id: None,
message: "当前版本的 Codex 实机换肤仅支持 macOS 和 Windows".to_string(),
}
}
}
pub(crate) enum SkinRuntimeAction {
Applied(String),
RestartRequired(String),
Paused(String),
Restored(String),
}
pub(crate) fn skin_runtime_status() -> SkinRuntimeStatus {
#[cfg(target_os = "macos")]
{
macos::runtime_status()
}
#[cfg(target_os = "windows")]
{
windows::runtime_status()
}
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
{
SkinRuntimeStatus::unsupported()
}
}
pub(crate) fn apply_skin_runtime(
theme_dir: &Path,
theme_id: &str,
restart_existing: bool,
) -> Result<SkinRuntimeAction> {
#[cfg(target_os = "macos")]
{
macos::apply_theme(theme_dir, theme_id, restart_existing)
}
#[cfg(target_os = "windows")]
{
windows::apply_theme(theme_dir, theme_id, restart_existing)
}
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
{
let _ = (theme_dir, theme_id, restart_existing);
Err(CodexxError::Config(
"当前版本的 Codex 实机换肤仅支持 macOS 和 Windows".to_string(),
))
}
}
pub(crate) fn pause_skin_runtime() -> Result<SkinRuntimeAction> {
#[cfg(target_os = "macos")]
{
macos::pause_theme()
}
#[cfg(target_os = "windows")]
{
windows::pause_theme()
}
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
{
Err(CodexxError::Config(
"当前版本的 Codex 实机换肤仅支持 macOS 和 Windows".to_string(),
))
}
}
pub(crate) fn restore_skin_runtime(restart_existing: bool) -> Result<SkinRuntimeAction> {
#[cfg(target_os = "macos")]
{
macos::restore_official(restart_existing)
}
#[cfg(target_os = "windows")]
{
windows::restore_official(restart_existing)
}
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
{
let _ = restart_existing;
Err(CodexxError::Config(
"当前版本的 Codex 实机换肤仅支持 macOS 和 Windows".to_string(),
))
}
}
@@ -0,0 +1,234 @@
use super::assets::{ensure_runtime_directory, RuntimeAssets};
use crate::error::{CodexxError, Result};
use crate::file_io::io_err;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::thread;
use std::time::{Duration, Instant};
const COMMAND_POLL_INTERVAL: Duration = Duration::from_millis(25);
const THEME_STAGE_TIMEOUT: Duration = Duration::from_secs(15);
const PAYLOAD_CHECK_TIMEOUT: Duration = Duration::from_secs(15);
const INJECTOR_ONCE_TIMEOUT: Duration = Duration::from_secs(35);
const INJECTOR_REMOVE_TIMEOUT: Duration = Duration::from_secs(20);
pub(super) fn command_detail(output: &std::process::Output) -> String {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
let detail = if stderr.is_empty() { stdout } else { stderr };
if detail.chars().count() <= 800 {
detail
} else {
detail
.chars()
.rev()
.take(800)
.collect::<String>()
.chars()
.rev()
.collect()
}
}
pub(super) fn wait_for_output(
mut child: std::process::Child,
program: &Path,
label: &str,
timeout: Duration,
) -> Result<std::process::Output> {
let deadline = Instant::now() + timeout;
loop {
match child.try_wait().map_err(|source| io_err(program, source))? {
Some(_) => {
return child
.wait_with_output()
.map_err(|source| io_err(program, source))
}
None if Instant::now() >= deadline => {
let _ = child.kill();
let output = child
.wait_with_output()
.map_err(|source| io_err(program, source))?;
let detail = command_detail(&output);
let suffix = if detail.is_empty() {
String::new()
} else {
format!(": {detail}")
};
return Err(CodexxError::Config(format!(
"{label}超时({} 秒){suffix}",
timeout.as_secs()
)));
}
None => thread::sleep(COMMAND_POLL_INTERVAL),
}
}
}
pub(super) fn run_node(
node: &Path,
script: &Path,
args: &[String],
label: &str,
timeout: Duration,
) -> Result<String> {
let child = Command::new(node)
.arg(script)
.args(args)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|source| io_err(script, source))?;
let output = wait_for_output(child, script, label, timeout)?;
if output.status.success() {
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
} else {
let detail = command_detail(&output);
Err(CodexxError::Config(if detail.is_empty() {
format!("{label}失败")
} else {
format!("{label}失败: {detail}")
}))
}
}
pub(super) fn stage_theme_snapshot(
node: &Path,
assets: &RuntimeAssets,
snapshots_root: &Path,
source: &Path,
theme_id: &str,
) -> Result<PathBuf> {
ensure_runtime_directory(snapshots_root)?;
let snapshot = snapshots_root.join(format!(
"{}-{}-{}",
theme_id,
std::process::id(),
chrono::Local::now()
.timestamp_nanos_opt()
.unwrap_or_default()
));
ensure_runtime_directory(&snapshot)?;
let result = (|| -> Result<()> {
run_node(
node,
&assets.stage_theme,
&[
source.to_string_lossy().to_string(),
snapshot.to_string_lossy().to_string(),
],
"暂存主题",
THEME_STAGE_TIMEOUT,
)?;
run_node(
node,
&assets.injector,
&[
"--check-payload".to_string(),
"--theme-dir".to_string(),
snapshot.to_string_lossy().to_string(),
],
"校验主题注入负载",
PAYLOAD_CHECK_TIMEOUT,
)?;
Ok(())
})();
if result.is_err() {
let _ = fs::remove_dir_all(&snapshot);
}
result.map(|_| snapshot)
}
pub(super) fn run_injector_once(
node: &Path,
assets: &RuntimeAssets,
port: u16,
theme_dir: &Path,
) -> Result<()> {
run_node(
node,
&assets.injector,
&[
"--once".to_string(),
"--port".to_string(),
port.to_string(),
"--theme-dir".to_string(),
theme_dir.to_string_lossy().to_string(),
"--timeout-ms".to_string(),
"20000".to_string(),
],
"注入并验证 Codex 皮肤",
INJECTOR_ONCE_TIMEOUT,
)?;
Ok(())
}
pub(super) fn remove_live_skin(
node: &Path,
assets: &RuntimeAssets,
port: u16,
theme_dir: &Path,
) -> Result<()> {
run_node(
node,
&assets.injector,
&[
"--remove".to_string(),
"--port".to_string(),
port.to_string(),
"--theme-dir".to_string(),
theme_dir.to_string_lossy().to_string(),
"--timeout-ms".to_string(),
"10000".to_string(),
],
"移除 Codex 皮肤",
INJECTOR_REMOVE_TIMEOUT,
)?;
Ok(())
}
#[cfg(all(test, unix))]
mod tests {
use super::*;
#[test]
fn wait_for_output_collects_a_completed_child() {
let child = Command::new("/bin/echo")
.arg("skin-ready")
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("spawn echo fixture");
let output = wait_for_output(
child,
Path::new("/bin/echo"),
"测试皮肤命令",
Duration::from_secs(1),
)
.expect("collect output");
assert!(output.status.success());
assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "skin-ready");
}
#[test]
fn wait_for_output_terminates_a_stuck_child() {
let child = Command::new("/bin/sleep")
.arg("5")
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("spawn sleep fixture");
let started = Instant::now();
let error = wait_for_output(
child,
Path::new("/bin/sleep"),
"测试皮肤命令",
Duration::from_millis(80),
)
.expect_err("timeout stuck child");
assert!(error.to_string().contains("超时"));
assert!(started.elapsed() < Duration::from_secs(2));
}
}
@@ -0,0 +1,869 @@
use super::assets::{ensure_runtime_assets, ensure_runtime_directory, RuntimeAssets};
use super::node_runtime;
use super::{SkinRuntimeAction, SkinRuntimeStatus};
use crate::error::{CodexxError, Result};
use crate::file_io::{atomic_write, io_err};
use crate::now_rfc3339;
use crate::skins::skins_root;
use serde::{de::DeserializeOwned, Deserialize, Serialize};
use std::env;
use std::fs::{self, File, OpenOptions};
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::thread;
use std::time::Duration;
#[cfg(target_os = "windows")]
use std::os::windows::process::CommandExt;
#[cfg(target_os = "windows")]
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
const DEFAULT_CDP_PORT: u16 = 9341;
const RUNTIME_STATE_SCHEMA: u32 = 1;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct RuntimeStateFile {
schema_version: u32,
platform: String,
session: String,
port: u16,
browser_id: String,
injector_pid: u32,
injector_started_at: String,
injector_path: String,
node_path: String,
node_version: String,
codex_package_root: String,
codex_executable: String,
codex_version: String,
codex_package_full_name: String,
codex_package_family_name: String,
codex_app_user_model_id: String,
theme_id: String,
theme_dir: String,
created_at: String,
updated_at: String,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
struct CodexRuntime {
package_root: PathBuf,
executable: PathBuf,
version: String,
package_full_name: String,
package_family_name: String,
app_user_model_id: String,
running: bool,
debug_port: Option<u16>,
browser_id: Option<String>,
watcher_pids: Vec<u32>,
}
#[derive(Debug, Deserialize)]
struct PortResult {
port: u16,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct LaunchResult {
port: u16,
browser_id: String,
strategy: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct VerifyPortResult {
verified: bool,
browser_id: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ProcessInfo {
alive: bool,
path: Option<String>,
command_line: Option<String>,
started_at: Option<String>,
}
#[derive(Debug, Deserialize)]
struct ActiveResult {
active: bool,
}
#[derive(Debug, Clone)]
struct NodeRuntime {
path: PathBuf,
version: String,
}
struct ActiveStateContext<'a> {
runtime: &'a CodexRuntime,
node: &'a NodeRuntime,
assets: &'a RuntimeAssets,
port: u16,
browser_id: String,
theme_id: &'a str,
theme_dir: &'a Path,
injector_pid: u32,
injector_started_at: String,
}
fn runtime_root() -> Result<PathBuf> {
Ok(skins_root()?.join("runtime"))
}
fn state_path() -> Result<PathBuf> {
Ok(runtime_root()?.join("state.json"))
}
fn snapshots_root() -> Result<PathBuf> {
Ok(runtime_root()?.join("themes"))
}
fn logs_root() -> Result<PathBuf> {
Ok(runtime_root()?.join("logs"))
}
fn powershell_path() -> PathBuf {
env::var_os("SystemRoot")
.map(PathBuf::from)
.map(|root| {
root.join("System32")
.join("WindowsPowerShell")
.join("v1.0")
.join("powershell.exe")
})
.filter(|path| path.is_file())
.unwrap_or_else(|| PathBuf::from("powershell.exe"))
}
fn configure_background_command(command: &mut Command) {
#[cfg(target_os = "windows")]
command.creation_flags(CREATE_NO_WINDOW);
#[cfg(not(target_os = "windows"))]
let _ = command;
}
fn parse_last_json<T: DeserializeOwned>(stdout: &[u8], label: &str) -> Result<T> {
let text = String::from_utf8_lossy(stdout);
text.lines()
.rev()
.map(str::trim)
.filter(|line| !line.is_empty())
.find_map(|line| serde_json::from_str(line).ok())
.ok_or_else(|| CodexxError::Config(format!("{label}没有返回有效结果")))
}
fn run_adapter<T: DeserializeOwned>(
assets: &RuntimeAssets,
action: &str,
args: &[String],
timeout: Duration,
) -> Result<T> {
let powershell = powershell_path();
let mut command = Command::new(&powershell);
command
.args(["-NoLogo", "-NoProfile", "-NonInteractive", "-File"])
.arg(&assets.windows_adapter)
.args(["-Action", action])
.args(args)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
configure_background_command(&mut command);
let child = command
.spawn()
.map_err(|source| io_err(&powershell, source))?;
let output = node_runtime::wait_for_output(child, &powershell, action, timeout)?;
if !output.status.success() {
let detail = node_runtime::command_detail(&output);
return Err(CodexxError::Config(if detail.is_empty() {
format!("Windows 皮肤操作失败: {action}")
} else {
format!("Windows 皮肤操作失败: {detail}")
}));
}
parse_last_json(&output.stdout, action)
}
fn inspect_codex(assets: &RuntimeAssets) -> Result<CodexRuntime> {
run_adapter(assets, "inspect", &[], Duration::from_secs(15))
}
fn select_port(assets: &RuntimeAssets, preferred: u16) -> Result<u16> {
let result: PortResult = run_adapter(
assets,
"selectPort",
&["-Port".to_string(), preferred.to_string()],
Duration::from_secs(10),
)?;
Ok(result.port)
}
fn verify_port(assets: &RuntimeAssets, port: u16) -> Result<VerifyPortResult> {
run_adapter(
assets,
"verifyPort",
&["-Port".to_string(), port.to_string()],
Duration::from_secs(10),
)
}
fn stop_codex(assets: &RuntimeAssets) -> Result<()> {
let _: serde_json::Value = run_adapter(assets, "stopCodex", &[], Duration::from_secs(25))?;
Ok(())
}
fn launch_codex_normally(assets: &RuntimeAssets) -> Result<()> {
let _: serde_json::Value = run_adapter(assets, "launchNormal", &[], Duration::from_secs(15))?;
Ok(())
}
fn launch_codex_with_cdp(assets: &RuntimeAssets, port: u16) -> Result<LaunchResult> {
let result: LaunchResult = run_adapter(
assets,
"launch",
&["-Port".to_string(), port.to_string()],
Duration::from_secs(60),
)?;
if result.port != port || result.browser_id.is_empty() {
return Err(CodexxError::Config(
"Windows Codex 启动器返回了无效的调试端口身份".to_string(),
));
}
Ok(result)
}
fn read_runtime_state() -> Result<Option<RuntimeStateFile>> {
let path = state_path()?;
let metadata = match fs::symlink_metadata(&path) {
Ok(metadata) => metadata,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(error) => return Err(io_err(&path, error)),
};
if metadata.file_type().is_symlink() || !metadata.is_file() || metadata.len() > 256 * 1024 {
return Err(CodexxError::Config(
"皮肤运行状态文件不是安全的普通文件,已停止自动处理".to_string(),
));
}
let bytes = fs::read(&path).map_err(|source| io_err(&path, source))?;
let state: RuntimeStateFile = serde_json::from_slice(&bytes)
.map_err(|error| CodexxError::Config(format!("解析皮肤运行状态失败: {error}")))?;
if state.schema_version != RUNTIME_STATE_SCHEMA
|| state.platform != "windows"
|| !(1024..=65535).contains(&state.port)
{
return Err(CodexxError::Config(
"皮肤运行状态版本或端口无效,已停止自动处理".to_string(),
));
}
Ok(Some(state))
}
fn write_runtime_state(state: &RuntimeStateFile) -> Result<()> {
let path = state_path()?;
let text = serde_json::to_string_pretty(state)
.map_err(|error| CodexxError::Config(format!("序列化皮肤运行状态失败: {error}")))?;
atomic_write(&path, format!("{text}\n").as_bytes())
}
fn remove_runtime_state() -> Result<()> {
let path = state_path()?;
match fs::remove_file(&path) {
Ok(()) => Ok(()),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(error) => Err(io_err(&path, error)),
}
}
fn node_version(path: &Path) -> Result<String> {
let mut command = Command::new(path);
command.arg("--version");
configure_background_command(&mut command);
let output = command.output().map_err(|source| io_err(path, source))?;
if !output.status.success() {
return Err(CodexxError::Config(format!(
"无法运行 Windows 皮肤 Node.js: {}",
path.display()
)));
}
let version = String::from_utf8_lossy(&output.stdout).trim().to_string();
let major = version
.trim_start_matches('v')
.split('.')
.next()
.and_then(|value| value.parse::<u32>().ok())
.ok_or_else(|| CodexxError::Config(format!("无法解析 Node.js 版本: {version}")))?;
if major < 22 {
return Err(CodexxError::Config(format!(
"Windows 皮肤运行时需要 Node.js 22 或更高版本,当前为 {version}"
)));
}
Ok(version)
}
fn resolve_node_candidate(path: &Path) -> Option<PathBuf> {
if let Ok(canonical) = fs::canonicalize(path) {
return Some(canonical);
}
if path.components().count() != 1 {
return None;
}
let mut command = Command::new("where.exe");
command.arg(path);
configure_background_command(&mut command);
let output = command.output().ok()?;
if !output.status.success() {
return None;
}
String::from_utf8_lossy(&output.stdout)
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.map(PathBuf::from)
.find_map(|candidate| fs::canonicalize(candidate).ok())
}
fn discover_node_runtime() -> Result<NodeRuntime> {
let mut candidates = Vec::new();
if let Some(path) = env::var_os("CODEX_X_SKIN_NODE") {
candidates.push(PathBuf::from(path));
}
if let Ok(executable) = env::current_exe() {
if let Some(parent) = executable.parent() {
candidates.push(parent.join("skin-runtime").join("node").join("node.exe"));
}
}
candidates.push(PathBuf::from("node.exe"));
for candidate in candidates {
if let Some(path) = resolve_node_candidate(&candidate) {
if let Ok(version) = node_version(&path) {
return Ok(NodeRuntime { path, version });
}
}
}
Err(CodexxError::Config(
"Windows 皮肤运行时缺少内置 Node.js;请重新安装 Codex-X".to_string(),
))
}
fn canonical_managed_snapshot(path: &Path) -> Option<PathBuf> {
let metadata = fs::symlink_metadata(path).ok()?;
if metadata.file_type().is_symlink() || !metadata.is_dir() {
return None;
}
let root = fs::canonicalize(snapshots_root().ok()?).ok()?;
let candidate = fs::canonicalize(path).ok()?;
candidate.starts_with(&root).then_some(candidate)
}
fn cleanup_snapshot(path: &str, keep: &Path) {
let Some(path) = canonical_managed_snapshot(Path::new(path)) else {
return;
};
let keep = canonical_managed_snapshot(keep);
if keep.as_ref() != Some(&path) {
let _ = fs::remove_dir_all(path);
}
}
fn process_info(assets: &RuntimeAssets, pid: u32) -> Result<ProcessInfo> {
run_adapter(
assets,
"processInfo",
&["-TargetPid".to_string(), pid.to_string()],
Duration::from_secs(10),
)
}
fn injector_active(assets: &RuntimeAssets) -> Result<bool> {
let result: ActiveResult = run_adapter(
assets,
"injectorStatus",
&[
"-StatePath".to_string(),
state_path()?.to_string_lossy().to_string(),
],
Duration::from_secs(10),
)?;
Ok(result.active)
}
fn write_adapter_state(state: &RuntimeStateFile) -> Result<PathBuf> {
let root = runtime_root()?;
ensure_runtime_directory(&root)?;
let path = root.join(format!(
".process-state-{}-{}.json",
std::process::id(),
state.injector_pid
));
let text = serde_json::to_string(state)
.map_err(|error| CodexxError::Config(format!("序列化皮肤进程状态失败: {error}")))?;
atomic_write(&path, text.as_bytes())?;
Ok(path)
}
fn stop_recorded_injector(assets: &RuntimeAssets, state: &RuntimeStateFile) -> Result<()> {
if state.injector_pid == 0 {
return Ok(());
}
let info = process_info(assets, state.injector_pid)?;
if !info.alive {
return Ok(());
}
let adapter_state = write_adapter_state(state)?;
let result = run_adapter::<serde_json::Value>(
assets,
"stopInjector",
&[
"-StatePath".to_string(),
adapter_state.to_string_lossy().to_string(),
],
Duration::from_secs(15),
);
let _ = fs::remove_file(&adapter_state);
result.map(|_| ())
}
fn log_files(prefix: &str) -> Result<(File, File)> {
let root = logs_root()?;
ensure_runtime_directory(&root)?;
let stdout_path = root.join(format!("{prefix}.log"));
let stderr_path = root.join(format!("{prefix}-error.log"));
let stdout = OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(&stdout_path)
.map_err(|source| io_err(&stdout_path, source))?;
let stderr = OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(&stderr_path)
.map_err(|source| io_err(&stderr_path, source))?;
Ok((stdout, stderr))
}
fn launch_watcher(
node: &NodeRuntime,
assets: &RuntimeAssets,
port: u16,
theme_dir: &Path,
) -> Result<(u32, String)> {
let (stdout, stderr) = log_files("injector")?;
let mut command = Command::new(&node.path);
command
.arg(&assets.injector)
.args(["--watch", "--port"])
.arg(port.to_string())
.arg("--theme-dir")
.arg(theme_dir)
.stdin(Stdio::null())
.stdout(Stdio::from(stdout))
.stderr(Stdio::from(stderr));
configure_background_command(&mut command);
let mut child = command
.spawn()
.map_err(|source| io_err(&assets.injector, source))?;
let pid = child.id();
thread::sleep(Duration::from_millis(250));
if child
.try_wait()
.map_err(|source| io_err(&assets.injector, source))?
.is_some()
{
return Err(CodexxError::Config(
"皮肤注入器启动后立即退出,请检查运行日志".to_string(),
));
}
let info = process_info(assets, pid)?;
let started_at = info
.started_at
.filter(|value| !value.is_empty())
.ok_or_else(|| CodexxError::Config("无法记录皮肤注入器启动时间".to_string()))?;
let expected_node = node.path.to_string_lossy();
let path_matches = info.path.as_deref().is_some_and(|path| {
Path::new(path)
.canonicalize()
.is_ok_and(|actual| actual == node.path)
});
let command_matches = info.command_line.as_deref().is_some_and(|command| {
command.contains(assets.injector.to_string_lossy().as_ref())
&& command.contains("--watch")
&& command.contains(&format!("--port {port}"))
});
if !info.alive
|| (!path_matches
&& !expected_node.eq_ignore_ascii_case(info.path.as_deref().unwrap_or("")))
|| !command_matches
{
let _ = child.kill();
return Err(CodexxError::Config(
"Windows 皮肤注入器进程身份校验失败".to_string(),
));
}
drop(child);
Ok((pid, started_at))
}
fn active_state(context: ActiveStateContext<'_>) -> RuntimeStateFile {
let timestamp = now_rfc3339();
RuntimeStateFile {
schema_version: RUNTIME_STATE_SCHEMA,
platform: "windows".to_string(),
session: "active".to_string(),
port: context.port,
browser_id: context.browser_id,
injector_pid: context.injector_pid,
injector_started_at: context.injector_started_at,
injector_path: context.assets.injector.to_string_lossy().to_string(),
node_path: context.node.path.to_string_lossy().to_string(),
node_version: context.node.version.clone(),
codex_package_root: context.runtime.package_root.to_string_lossy().to_string(),
codex_executable: context.runtime.executable.to_string_lossy().to_string(),
codex_version: context.runtime.version.clone(),
codex_package_full_name: context.runtime.package_full_name.clone(),
codex_package_family_name: context.runtime.package_family_name.clone(),
codex_app_user_model_id: context.runtime.app_user_model_id.clone(),
theme_id: context.theme_id.to_string(),
theme_dir: context.theme_dir.to_string_lossy().to_string(),
created_at: timestamp.clone(),
updated_at: timestamp,
}
}
fn restore_previous_after_failure(
runtime: &CodexRuntime,
node: &NodeRuntime,
assets: &RuntimeAssets,
previous: Option<&RuntimeStateFile>,
) {
let Some(previous) = previous else { return };
let theme_dir = Path::new(&previous.theme_dir);
let verified = verify_port(assets, previous.port).is_ok_and(|result| result.verified);
if previous.session != "active"
|| !theme_dir.is_dir()
|| !verified
|| node_runtime::run_injector_once(&node.path, assets, previous.port, theme_dir).is_err()
{
return;
}
if let Ok((pid, started_at)) = launch_watcher(node, assets, previous.port, theme_dir) {
let restored = active_state(ActiveStateContext {
runtime,
node,
assets,
port: previous.port,
browser_id: previous.browser_id.clone(),
theme_id: &previous.theme_id,
theme_dir,
injector_pid: pid,
injector_started_at: started_at,
});
let _ = write_runtime_state(&restored);
}
}
pub(super) fn apply_theme(
source: &Path,
theme_id: &str,
restart_existing: bool,
) -> Result<SkinRuntimeAction> {
let assets = ensure_runtime_assets()?;
let runtime = inspect_codex(&assets)?;
let node = discover_node_runtime()?;
let previous = read_runtime_state()?;
if let Some(pid) = runtime.watcher_pids.iter().copied().find(|pid| {
previous
.as_ref()
.is_none_or(|state| state.injector_pid != *pid)
}) {
return Err(CodexxError::Config(format!(
"检测到外部 Codex 换肤注入器 PID {pid},请先在原工具中关闭皮肤"
)));
}
if runtime.running && runtime.debug_port.is_none() && !restart_existing {
return Ok(SkinRuntimeAction::RestartRequired(
"Codex 需要重启一次才能打开仅限本机的换肤调试端口".to_string(),
));
}
let snapshot = node_runtime::stage_theme_snapshot(
&node.path,
&assets,
&snapshots_root()?,
source,
theme_id,
)?;
let mut launched_new = false;
let apply_result = (|| -> Result<RuntimeStateFile> {
if let Some(state) = previous.as_ref() {
stop_recorded_injector(&assets, state)?;
}
let (port, browser_id) = if let Some(port) = runtime.debug_port {
let browser_id = runtime.browser_id.clone().ok_or_else(|| {
CodexxError::Config("无法验证当前 Windows Codex 调试会话".to_string())
})?;
(port, browser_id)
} else {
if runtime.running {
stop_codex(&assets)?;
}
let preferred = previous
.as_ref()
.map_or(DEFAULT_CDP_PORT, |state| state.port);
let port = select_port(&assets, preferred)?;
let launch = launch_codex_with_cdp(&assets, port)?;
launched_new = true;
let _strategy = launch.strategy;
(port, launch.browser_id)
};
node_runtime::run_injector_once(&node.path, &assets, port, &snapshot)?;
let (pid, started_at) = launch_watcher(&node, &assets, port, &snapshot)?;
let state = active_state(ActiveStateContext {
runtime: &runtime,
node: &node,
assets: &assets,
port,
browser_id,
theme_id,
theme_dir: &snapshot,
injector_pid: pid,
injector_started_at: started_at,
});
if let Err(error) = write_runtime_state(&state) {
let _ = stop_recorded_injector(&assets, &state);
return Err(error);
}
Ok(state)
})();
match apply_result {
Ok(_) => {
if let Some(previous) = previous.as_ref() {
cleanup_snapshot(&previous.theme_dir, &snapshot);
}
Ok(SkinRuntimeAction::Applied(
"已应用 Windows Codex 皮肤".to_string(),
))
}
Err(error) => {
let _ = fs::remove_dir_all(&snapshot);
restore_previous_after_failure(&runtime, &node, &assets, previous.as_ref());
if launched_new && previous.is_none() {
let _ = stop_codex(&assets);
let _ = launch_codex_normally(&assets);
}
Err(error)
}
}
}
pub(super) fn pause_theme() -> Result<SkinRuntimeAction> {
let assets = ensure_runtime_assets()?;
let Some(mut state) = read_runtime_state()? else {
return Ok(SkinRuntimeAction::Paused(
"Codex 皮肤当前没有运行".to_string(),
));
};
let node = discover_node_runtime()?;
stop_recorded_injector(&assets, &state)?;
if verify_port(&assets, state.port).is_ok_and(|result| result.verified) {
node_runtime::remove_live_skin(
&node.path,
&assets,
state.port,
Path::new(&state.theme_dir),
)?;
}
state.session = "paused".to_string();
state.injector_pid = 0;
state.injector_started_at.clear();
state.updated_at = now_rfc3339();
write_runtime_state(&state)?;
Ok(SkinRuntimeAction::Paused(
"已关闭 Codex 皮肤;Codex 保持打开".to_string(),
))
}
pub(super) fn restore_official(restart_existing: bool) -> Result<SkinRuntimeAction> {
let assets = ensure_runtime_assets()?;
let Some(state) = read_runtime_state()? else {
return Ok(SkinRuntimeAction::Restored(
"Codex 当前已使用官方外观".to_string(),
));
};
let node = discover_node_runtime()?;
let runtime = inspect_codex(&assets)?;
let debug_ready = verify_port(&assets, state.port).is_ok_and(|result| result.verified);
if runtime.running && (debug_ready || state.session == "active") && !restart_existing {
return Ok(SkinRuntimeAction::RestartRequired(
"完整恢复需要重启 Codex,以关闭本机调试端口并清除当前渲染状态".to_string(),
));
}
stop_recorded_injector(&assets, &state)?;
if debug_ready {
node_runtime::remove_live_skin(
&node.path,
&assets,
state.port,
Path::new(&state.theme_dir),
)?;
}
if runtime.running && (debug_ready || state.session == "active") {
stop_codex(&assets)?;
launch_codex_normally(&assets)?;
}
remove_runtime_state()?;
cleanup_snapshot(&state.theme_dir, Path::new(""));
Ok(SkinRuntimeAction::Restored(
"已恢复 Windows Codex 官方外观并关闭换肤运行时".to_string(),
))
}
pub(super) fn runtime_status() -> SkinRuntimeStatus {
let assets = match ensure_runtime_assets() {
Ok(assets) => assets,
Err(error) => {
return SkinRuntimeStatus {
supported: true,
active: false,
phase: "error".to_string(),
port: None,
theme_id: None,
message: error.to_string(),
}
}
};
let state = match read_runtime_state() {
Ok(Some(state)) => state,
Ok(None) => {
return SkinRuntimeStatus {
supported: true,
active: false,
phase: "inactive".to_string(),
port: None,
theme_id: None,
message: "尚未应用 Codex 皮肤".to_string(),
}
}
Err(error) => {
return SkinRuntimeStatus {
supported: true,
active: false,
phase: "error".to_string(),
port: None,
theme_id: None,
message: error.to_string(),
}
}
};
if state.session == "paused" {
return SkinRuntimeStatus {
supported: true,
active: false,
phase: "paused".to_string(),
port: Some(state.port),
theme_id: Some(state.theme_id),
message: "皮肤已关闭,Codex 保持打开".to_string(),
};
}
if let Err(error) = inspect_codex(&assets) {
return SkinRuntimeStatus {
supported: true,
active: false,
phase: "unavailable".to_string(),
port: Some(state.port),
theme_id: Some(state.theme_id),
message: error.to_string(),
};
}
let active = injector_active(&assets).unwrap_or(false)
&& verify_port(&assets, state.port).is_ok_and(|result| {
result.verified && result.browser_id.as_deref() == Some(state.browser_id.as_str())
});
SkinRuntimeStatus {
supported: true,
active,
phase: if active { "active" } else { "stale" }.to_string(),
port: Some(state.port),
theme_id: Some(state.theme_id),
message: if active {
"Windows Codex 皮肤运行中".to_string()
} else {
"皮肤运行状态已失效,可重新应用主题".to_string()
},
}
}
#[cfg(test)]
mod tests {
use super::*;
fn test_state() -> RuntimeStateFile {
RuntimeStateFile {
schema_version: 1,
platform: "windows".to_string(),
session: "active".to_string(),
port: 9341,
browser_id: "browser-1".to_string(),
injector_pid: 42,
injector_started_at: "2026-07-24T00:00:00.0000000Z".to_string(),
injector_path: r"C:\Users\test\.codexx\runtime\injector.mjs".to_string(),
node_path: r"C:\Program Files\Codex-X\skin-runtime\node\node.exe".to_string(),
node_version: "v22.23.1".to_string(),
codex_package_root: r"C:\Program Files\WindowsApps\OpenAI.Codex_1.0.0.0_x64"
.to_string(),
codex_executable:
r"C:\Program Files\WindowsApps\OpenAI.Codex_1.0.0.0_x64\app\ChatGPT.exe".to_string(),
codex_version: "1.0.0.0".to_string(),
codex_package_full_name: "OpenAI.Codex_1.0.0.0_x64__test".to_string(),
codex_package_family_name: "OpenAI.Codex_test".to_string(),
codex_app_user_model_id: "OpenAI.Codex_test!App".to_string(),
theme_id: "theme".to_string(),
theme_dir: r"C:\Users\test\.codexx\runtime\themes\theme".to_string(),
created_at: "2026-07-24T00:00:00Z".to_string(),
updated_at: "2026-07-24T00:00:00Z".to_string(),
}
}
#[test]
fn parses_last_json_line_after_powershell_warnings() {
let result: PortResult = parse_last_json(
b"WARNING: package activation fallback\r\n{\"port\":9342}\r\n",
"test",
)
.expect("parse adapter JSON");
assert_eq!(result.port, 9342);
}
#[test]
fn process_state_uses_powershell_contract_field_names() {
let value = serde_json::to_value(test_state()).expect("serialize state");
assert_eq!(value["platform"], "windows");
assert_eq!(value["injectorPid"], 42);
assert_eq!(
value["injectorPath"],
r"C:\Users\test\.codexx\runtime\injector.mjs"
);
assert_eq!(
value["nodePath"],
r"C:\Program Files\Codex-X\skin-runtime\node\node.exe"
);
assert_eq!(value["browserId"], "browser-1");
}
#[test]
fn adapter_keeps_store_identity_and_loopback_guards() {
let source = include_str!("../../resources/skin-runtime/windows/codexx-windows.ps1");
assert!(source.contains("Get-DreamSkinCodexInstall"));
assert!(source.contains("Start-DreamSkinCodexForDebugging"));
assert!(source.contains("--remote-debugging-address=127.0.0.1"));
assert!(source.contains("Get-DreamSkinVerifiedCdpIdentity"));
assert!(!source.contains("takeown"));
}
}
+276
View File
@@ -0,0 +1,276 @@
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
+24 -6
View File
@@ -1,11 +1,17 @@
use crate::backups::{latest_backup, BackupEntry};
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::file_io::{
harden_sensitive_file_permissions, 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::{
detected_live_custom_provider, document_is_official, list_saved_providers_inner,
official_auth_available, unique_saved_provider_id_for_live, SavedProvider,
};
use crate::{auth_path, config_path, string_value};
use serde::Serialize;
use serde_json::Value;
@@ -35,6 +41,7 @@ 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>,
@@ -169,13 +176,20 @@ pub(crate) fn active_saved_provider_id_from_config(
}
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)?;
harden_sensitive_file_permissions(&cfg)?;
harden_sensitive_file_permissions(&auth)?;
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 +207,13 @@ 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 saved_providers = list_saved_providers_inner()?;
let active_saved_provider_id = if is_official_provider {
None
} else if let Some(live) = detected_live_custom_provider(&codex_dir)? {
unique_saved_provider_id_for_live(&live, &saved_providers)
} else {
active_saved_provider_id_from_config(&text, &list_saved_providers_inner()?)
active_saved_provider_id_from_config(&text, &saved_providers)
};
Ok(CodexState {
@@ -205,9 +222,10 @@ 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,
File diff suppressed because it is too large Load Diff
+6 -2
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Codex-X",
"version": "0.3.0",
"version": "0.3.6",
"identifier": "com.yynxxxxx.codexx",
"build": {
"frontendDist": "../dist",
@@ -29,7 +29,11 @@
}
],
"security": {
"csp": "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src 'self' ipc: http://ipc.localhost"
"csp": "default-src 'self'; img-src 'self' data: asset: http://asset.localhost https://asset.localhost; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src 'self' ipc: http://ipc.localhost",
"assetProtocol": {
"enable": true,
"scope": ["$HOME/.codexx/codex-x-skins/**"]
}
}
},
"bundle": {
@@ -0,0 +1,7 @@
{
"bundle": {
"resources": {
"runtime/windows/node/*": "skin-runtime/node/"
}
}
}
@@ -310,6 +310,70 @@ export function UpdateDialog({
);
}
export type SkinRestartDialogProps = {
open: boolean;
lang: Lang;
themeName?: string | null;
busy: boolean;
onClose: () => void;
onConfirm: () => void | Promise<void>;
};
export function SkinRestartDialog({
open,
lang,
themeName,
busy,
onClose,
onConfirm,
}: SkinRestartDialogProps) {
const isChinese = lang === "zh";
const title = isChinese ? "重启 Codex 并应用皮肤" : "Restart Codex and apply skin";
const description = isChinese
? `Codex 当前未启用换肤调试端口。继续后会关闭并重新打开 Codex${themeName ? `,然后应用“${themeName}”` : ""}。`
: `Codex is not running with the skin debug endpoint. It will be closed and reopened${themeName ? `, then “${themeName}” will be applied` : ""}.`;
const warning = isChinese
? "继续前请保存 Codex 中尚未发送的输入和正在进行的工作。本机调试端口仅绑定 127.0.0.1,但启用期间仍应避免运行不可信的本机程序。"
: "Save unsent input and in-progress work in Codex first. The debug endpoint binds only to 127.0.0.1, but untrusted local programs should still be avoided while it is active.";
return (
<ModalShell
open={open}
onClose={onClose}
title={title}
description={description}
size="sm"
closeOnBackdrop={!busy}
closeOnEscape={!busy}
showCloseButton={!busy}
closeLabel={isChinese ? "关闭" : "Close"}
className="cx-skin-restart-dialog"
footer={(
<>
<Button variant="secondary" onClick={onClose} disabled={busy}>
{isChinese ? "取消" : "Cancel"}
</Button>
<Button
icon={busy ? <Loader2 size={16} className="cx-dialog-spin" /> : <RotateCcw size={16} />}
onClick={() => void onConfirm()}
disabled={busy}
data-initial-focus
>
{busy
? (isChinese ? "正在处理" : "Working")
: (isChinese ? "确认重启" : "Restart Codex")}
</Button>
</>
)}
>
<div className="cx-skin-restart-warning">
<AlertCircle size={20} aria-hidden="true" />
<p>{warning}</p>
</div>
</ModalShell>
);
}
export type StartupWizardDialogProps = {
open: boolean;
closing: boolean;
+6 -1
View File
@@ -9,6 +9,7 @@ import {
LayoutDashboard,
LoaderCircle,
Moon,
Palette,
RotateCcw,
Settings,
Sparkles,
@@ -28,6 +29,7 @@ export type AppTab =
| "provider"
| "sessions"
| "skillsMcp"
| "skins"
| "instruction"
| "toml"
| "settings"
@@ -44,6 +46,7 @@ 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" } },
@@ -62,6 +65,7 @@ export type AppShellProps = {
updatePhase?: AppUpdaterPhase;
onOpenUpdate?: () => void;
isMacRuntime?: boolean;
skinCenterEnabled?: boolean;
children: ReactNode;
sidebarFooter?: ReactNode;
className?: string;
@@ -80,6 +84,7 @@ export function AppShell({
updatePhase = "idle",
onOpenUpdate,
isMacRuntime = false,
skinCenterEnabled = true,
children,
sidebarFooter,
className,
@@ -155,7 +160,7 @@ export function AppShell({
</div>
<nav className="cx-sidebar-nav" aria-label={navigationLabel}>
{NAV_ITEMS.map((item) => {
{NAV_ITEMS.filter((item) => item.id !== "skins" || skinCenterEnabled).map((item) => {
const Icon = item.icon;
const isActive = activeTab === item.id;
@@ -5,6 +5,7 @@ import {
ChevronRight,
Edit2,
FileText,
Palette,
Plus,
Settings2,
Trash2,
@@ -31,6 +32,7 @@ type PromptCategoryManagerProps = {
onRenameCategory: (categoryId: string, name: string) => boolean;
onDeleteCategory: (categoryId: string) => boolean;
onMovePrompt: (promptKey: string, categoryId: string) => void;
subject?: "prompt" | "skin";
};
export function PromptCategoryManager({
@@ -44,6 +46,7 @@ export function PromptCategoryManager({
onRenameCategory,
onDeleteCategory,
onMovePrompt,
subject = "prompt",
}: PromptCategoryManagerProps) {
const isChinese = lang === "zh";
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set());
@@ -72,11 +75,38 @@ export function PromptCategoryManager({
return grouped;
}, [categories, categoryForPrompt, prompts]);
const subjectCopy = isChinese
? subject === "skin"
? {
deleteTitle: "删除皮肤分类",
deleteDescription: (name: string) => `“${name}”中的皮肤会移到默认分类,皮肤本身不会被删除。`,
empty: "该分类下暂无皮肤",
move: (title: string) => `移动“${title}”到分类`,
}
: {
deleteTitle: "删除提示词分类",
deleteDescription: (name: string) => `“${name}”中的提示词会移到默认分类,提示词本身不会被删除。`,
empty: "该分类下暂无提示词",
move: (title: string) => `移动“${title}”到分类`,
}
: subject === "skin"
? {
deleteTitle: "Delete skin category",
deleteDescription: (name: string) => `Skins in “${name}” will move to the default category. The skins are not deleted.`,
empty: "No skins in this category",
move: (title: string) => `Move “${title}” to category`,
}
: {
deleteTitle: "Delete prompt category",
deleteDescription: (name: string) => `Prompts in “${name}” will move to the default category. The prompts are not deleted.`,
empty: "No prompts in this category",
move: (title: string) => `Move “${title}” to category`,
};
const copy = isChinese
? {
title: "分类管理",
deleteTitle: "删除提示词分类",
deleteDescription: (name: string) => `“${name}”中的提示词会移到默认分类,提示词本身不会被删除。`,
deleteTitle: subjectCopy.deleteTitle,
deleteDescription: subjectCopy.deleteDescription,
addCategory: "新增分类",
namePlaceholder: "输入分类名称",
cancel: "取消",
@@ -86,16 +116,16 @@ export function PromptCategoryManager({
edit: "编辑分类",
remove: "删除分类",
keepOne: "至少保留一个分类",
empty: "该分类下暂无提示词",
move: (title: string) => `移动“${title}”到分类`,
empty: subjectCopy.empty,
move: subjectCopy.move,
duplicate: `分类名称不能为空、重复或超过 ${PROMPT_CATEGORY_NAME_MAX_LENGTH} 个字符`,
expand: (name: string) => `展开${name}`,
collapse: (name: string) => `收起${name}`,
}
: {
title: "Manage categories",
deleteTitle: "Delete prompt category",
deleteDescription: (name: string) => `Prompts in “${name}” will move to the default category. The prompts are not deleted.`,
deleteTitle: subjectCopy.deleteTitle,
deleteDescription: subjectCopy.deleteDescription,
addCategory: "Add category",
namePlaceholder: "Category name",
cancel: "Cancel",
@@ -105,12 +135,13 @@ export function PromptCategoryManager({
edit: "Edit category",
remove: "Delete category",
keepOne: "Keep at least one category",
empty: "No prompts in this category",
move: (title: string) => `Move “${title}” to category`,
empty: subjectCopy.empty,
move: subjectCopy.move,
duplicate: `Category names cannot be empty, duplicated, or longer than ${PROMPT_CATEGORY_NAME_MAX_LENGTH} characters`,
expand: (name: string) => `Expand ${name}`,
collapse: (name: string) => `Collapse ${name}`,
};
const ItemIcon = subject === "skin" ? Palette : FileText;
const close = () => {
if (categoryToDelete) {
@@ -261,7 +292,7 @@ export function PromptCategoryManager({
<div className="cx-prompt-category-prompts">
{categoryPrompts.length ? categoryPrompts.map((prompt) => (
<div className="cx-prompt-category-prompt" key={prompt.key}>
<FileText size={14} aria-hidden="true" />
<ItemIcon size={14} aria-hidden="true" />
<span title={prompt.title}>{prompt.title}</span>
<select
value={category.id}
@@ -0,0 +1,150 @@
import { useEffect, useState } from "react";
import { Loader2, Pencil, Save } from "lucide-react";
import type { Lang, SkinThemeSummary } from "../types";
import { Button, ModalShell } from "./ui";
export type SkinThemeEditorProps = {
lang: Lang;
open: boolean;
theme: SkinThemeSummary | null;
busy: boolean;
onClose: () => void;
onSave: (id: string, name: string, tagline: string, surfaceOpacity: number) => Promise<boolean>;
};
function getCopy(lang: Lang) {
return lang === "zh"
? {
title: "编辑皮肤",
description: "调整皮肤信息和壁纸上方界面的透明度。",
builtinDescription: "调整壁纸上方界面的透明度。",
name: "皮肤名称",
namePlaceholder: "输入皮肤名称",
tagline: "皮肤简介",
taglinePlaceholder: "简要描述这套皮肤",
transparency: "界面透明度",
cancel: "取消",
save: "保存",
saving: "保存中",
close: "关闭",
}
: {
title: "Edit skin",
description: "Change skin details and the transparency of surfaces above the wallpaper.",
builtinDescription: "Adjust the transparency of surfaces above the wallpaper.",
name: "Skin name",
namePlaceholder: "Enter a skin name",
tagline: "Description",
taglinePlaceholder: "Briefly describe this skin",
transparency: "Interface transparency",
cancel: "Cancel",
save: "Save",
saving: "Saving",
close: "Close",
};
}
export function SkinThemeEditor({
lang,
open,
theme,
busy,
onClose,
onSave,
}: SkinThemeEditorProps) {
const copy = getCopy(lang);
const [name, setName] = useState("");
const [tagline, setTagline] = useState("");
const [surfaceOpacity, setSurfaceOpacity] = useState(1);
const editableMetadata = theme?.source !== "builtin";
const transparency = Math.round((1 - surfaceOpacity) * 100);
useEffect(() => {
if (!open || !theme) return;
setName(theme.name);
setTagline(theme.tagline);
setSurfaceOpacity(theme.surfaceOpacity);
}, [open, theme]);
const close = () => {
if (!busy) onClose();
};
const save = async () => {
if (!theme || (editableMetadata && !name.trim()) || busy) return;
if (await onSave(theme.id, name, tagline, surfaceOpacity)) onClose();
};
return (
<ModalShell
open={open && Boolean(theme)}
onClose={close}
title={<span className="cx-skin-editor-title"><Pencil size={18} aria-hidden="true" />{copy.title}</span>}
description={editableMetadata ? copy.description : copy.builtinDescription}
closeLabel={copy.close}
closeOnBackdrop={!busy}
closeOnEscape={!busy}
className="cx-skin-editor-modal"
bodyClassName="cx-skin-editor-body"
footer={(
<>
<Button variant="secondary" onClick={close} disabled={busy}>{copy.cancel}</Button>
<Button
icon={busy ? <Loader2 className="cx-skins-spin" /> : <Save />}
onClick={() => void save()}
disabled={busy || (editableMetadata && !name.trim())}
>
{busy ? copy.saving : copy.save}
</Button>
</>
)}
>
{editableMetadata && (
<>
<label className="cx-skin-editor-field">
<span>{copy.name}</span>
<input
value={name}
onChange={(event) => setName(event.currentTarget.value)}
placeholder={copy.namePlaceholder}
maxLength={80}
disabled={busy}
data-initial-focus
/>
</label>
<label className="cx-skin-editor-field">
<span>{copy.tagline}</span>
<textarea
value={tagline}
onChange={(event) => setTagline(event.currentTarget.value)}
placeholder={copy.taglinePlaceholder}
maxLength={160}
disabled={busy}
rows={4}
/>
</label>
</>
)}
<label className="cx-skin-editor-field">
<span>{copy.transparency}</span>
<div className="cx-skin-editor-range">
<input
type="range"
min="0"
max="65"
step="1"
value={transparency}
onChange={(event) => {
const value = Number(event.currentTarget.value);
setSurfaceOpacity(Number((1 - value / 100).toFixed(2)));
}}
disabled={busy}
aria-label={copy.transparency}
data-initial-focus={!editableMetadata || undefined}
/>
<output>{transparency}%</output>
</div>
</label>
</ModalShell>
);
}
+260
View File
@@ -0,0 +1,260 @@
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,
};
}
+385 -194
View File
File diff suppressed because it is too large Load Diff
+14 -6
View File
@@ -21,6 +21,7 @@ export type OverviewPageProps = {
providerLabel?: string | null;
instructionEnabled: boolean;
authExists: boolean;
officialAuthAvailable: boolean;
configPath?: string | null;
modelProvider?: string | null;
instructionPath?: string | null;
@@ -76,6 +77,7 @@ export function OverviewPage({
providerLabel,
instructionEnabled,
authExists,
officialAuthAvailable,
configPath,
modelProvider,
instructionPath,
@@ -103,8 +105,9 @@ export function OverviewPage({
instruction: "指令提示词",
enabled: "已启用",
disabled: "未启用",
auth: "认证文件",
authFile: "auth.json",
auth: "认证状态",
authFile: "auth.json 已找到",
officialAuth: "官方认证已保存",
noAuth: "未找到",
updateFound: "发现新版本",
updateAvailable: (version: string) => `Codex-X ${version} 已发布`,
@@ -134,8 +137,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`,
@@ -159,6 +163,10 @@ export function OverviewPage({
const displayInstructionPath = instructionPath?.trim() || text.notConfigured;
const updateVersion = latestVersion?.trim() || "";
const homeInputValue = configDir || resolvedCodexDir;
const authAvailable = authExists || officialAuthAvailable;
const authStatus = authExists
? text.authFile
: officialAuthAvailable ? text.officialAuth : text.noAuth;
return (
<section className="cx-overview-page" aria-label={isChinese ? "概览" : "Overview"}>
@@ -227,8 +235,8 @@ export function OverviewPage({
<StatusCard
icon={KeyRound}
label={text.auth}
value={authExists ? text.authFile : text.noAuth}
tone={authExists ? "success" : "muted"}
value={authStatus}
tone={authAvailable ? "success" : "muted"}
/>
</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>
+60 -4
View File
@@ -7,10 +7,12 @@ import {
CheckCircle2,
Eye,
EyeOff,
FilePlus2,
Loader2,
PencilLine,
Plus,
RefreshCw,
RotateCcw,
Trash2,
} from "lucide-react";
import type { LucideIcon } from "lucide-react";
@@ -80,6 +82,12 @@ export type ProviderCopy = {
officialCurrentLabel: string;
officialAuthLabel: string;
officialSaveLabel: string;
restoreOfficialLabel: string;
resetOfficialLabel: string;
resetOfficialTitle: string;
resetOfficialDescription: string;
resetOfficialCancelLabel: string;
resetOfficialConfirmLabel: string;
cancelLabel: string;
formEyebrow: string;
formAddTitle: string;
@@ -125,6 +133,7 @@ export type ProvidersPageProps = {
editingProviderId: string | null;
providerForm: ProviderFormValue;
officialForm: OfficialFormValue;
officialAuthRef?: Ref<HTMLTextAreaElement>;
officialInfo: ProviderOfficialInfo;
providerAuthPreview: ReactNode;
providerTomlDraft: string;
@@ -134,6 +143,8 @@ export type ProvidersPageProps = {
fetchingModels: boolean;
onImportCcSwitch: () => void;
onAddProvider: () => void;
onRestoreOfficial: () => void;
onResetOfficial: () => void;
onEnableProvider: (row: ProviderRow) => void;
onTestProvider: (row: ProviderRow) => void;
onEditProvider: (row: ProviderRow) => void;
@@ -220,11 +231,12 @@ function ListPage({
actionBusy,
onImportCcSwitch,
onAddProvider,
onRestoreOfficial,
onEnableProvider,
onTestProvider,
onEditProvider,
onDeleteProvider,
}: Pick<ProvidersPageProps, "copy" | "providerRows" | "loading" | "testingId" | "actionBusy" | "onImportCcSwitch" | "onAddProvider" | "onEnableProvider" | "onTestProvider" | "onEditProvider" | "onDeleteProvider">) {
}: Pick<ProvidersPageProps, "copy" | "providerRows" | "loading" | "testingId" | "actionBusy" | "onImportCcSwitch" | "onAddProvider" | "onRestoreOfficial" | "onEnableProvider" | "onTestProvider" | "onEditProvider" | "onDeleteProvider">) {
const [providerToDelete, setProviderToDelete] = useState<ProviderRow | null>(null);
const [deleting, setDeleting] = useState(false);
@@ -298,6 +310,14 @@ function ListPage({
>
{copy.enableLabel}
</button>
{row.source === "official" && (
<ActionIconButton
icon={RotateCcw}
label={copy.restoreOfficialLabel}
onClick={onRestoreOfficial}
disabled={loading}
/>
)}
{row.testable !== false && (
<ActionIconButton
icon={isTesting ? Loader2 : Activity}
@@ -373,13 +393,23 @@ function ModeHeader({ eyebrow, title, description, cancelLabel, onCancel }: { ey
function OfficialForm({
copy,
officialForm,
officialAuthRef,
officialInfo,
loading,
onCancelMode,
onOfficialModelChange,
onOfficialAuthChange,
onSaveOfficial,
}: Pick<ProvidersPageProps, "copy" | "officialForm" | "officialInfo" | "loading" | "onCancelMode" | "onOfficialModelChange" | "onOfficialAuthChange" | "onSaveOfficial">) {
onRestoreOfficial,
onResetOfficial,
}: Pick<ProvidersPageProps, "copy" | "officialForm" | "officialAuthRef" | "officialInfo" | "loading" | "onCancelMode" | "onOfficialModelChange" | "onOfficialAuthChange" | "onSaveOfficial" | "onRestoreOfficial" | "onResetOfficial">) {
const [resetConfirmOpen, setResetConfirmOpen] = useState(false);
const confirmReset = () => {
setResetConfirmOpen(false);
onResetOfficial();
};
return (
<>
<ModeHeader eyebrow={copy.officialEyebrow} title={copy.officialTitle} description={copy.officialHint} cancelLabel={copy.cancelLabel} onCancel={onCancelMode} />
@@ -393,16 +423,42 @@ function OfficialForm({
</div>
<Field label={copy.officialAuthLabel} className="cx-providers-editor-field">
<textarea
ref={officialAuthRef}
className="cx-providers-code-editor cx-providers-auth-editor"
value={officialForm.authJson}
onChange={(event) => onOfficialAuthChange(event.target.value)}
wrap="off"
wrap="soft"
spellCheck={false}
/>
</Field>
<div className="cx-providers-form-actions cx-providers-form-actions--save">
<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>
<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>
<button type="button" className="cx-providers-button cx-providers-button--primary" onClick={onSaveOfficial} disabled={loading}><CheckCircle2 size={15} aria-hidden="true" />{copy.officialSaveLabel}</button>
</div>
<ModalShell
open={resetConfirmOpen}
onClose={() => setResetConfirmOpen(false)}
title={copy.resetOfficialTitle}
description={copy.resetOfficialDescription}
size="sm"
closeLabel={copy.resetOfficialCancelLabel}
footer={(
<>
<Button variant="secondary" onClick={() => setResetConfirmOpen(false)} data-initial-focus>{copy.resetOfficialCancelLabel}</Button>
<Button variant="danger" icon={<FilePlus2 size={16} />} onClick={confirmReset}>{copy.resetOfficialConfirmLabel}</Button>
</>
)}
>
<div className="cx-provider-delete-warning">
<span aria-hidden="true"><AlertTriangle size={22} /></span>
<strong>auth.json</strong>
</div>
</ModalShell>
</>
);
}
@@ -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}
+475
View File
@@ -0,0 +1,475 @@
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>
);
}
+54 -21
View File
@@ -2,7 +2,7 @@ import * as React from "react";
import type { Lang } from "./types";
const STORAGE_KEY = "codexx.promptCategories.v1";
const PROMPT_STORAGE_KEY = "codexx.promptCategories.v1";
const STATE_VERSION = 1 as const;
export const PROMPT_CATEGORY_NAME_MAX_LENGTH = 40;
@@ -19,7 +19,20 @@ type PromptCategoryState = {
assignments: Record<string, string>;
};
function defaultCategories(lang: Lang): PromptCategory[] {
export type ManagedCategoryOptions = {
storageKey: string;
defaultCategories: (lang: Lang) => PromptCategory[];
defaultCategoryForPrompt?: (promptKey: string) => string | undefined;
};
export function defaultPromptCategoryForKey(promptKey: string) {
const normalized = promptKey.trim().toLowerCase();
if (normalized.includes("software-development-")) return "software-development";
if (normalized.includes("writing-")) return "writing";
return undefined;
}
function defaultPromptCategories(lang: Lang): PromptCategory[] {
return lang === "zh"
? [
{ id: "security-reverse", name: "破甲/逆向" },
@@ -33,8 +46,14 @@ function defaultCategories(lang: Lang): PromptCategory[] {
];
}
function initialState(lang: Lang): PromptCategoryState {
const categories = defaultCategories(lang);
const PROMPT_CATEGORY_OPTIONS: ManagedCategoryOptions = {
storageKey: PROMPT_STORAGE_KEY,
defaultCategories: defaultPromptCategories,
defaultCategoryForPrompt: defaultPromptCategoryForKey,
};
function initialState(lang: Lang, options: ManagedCategoryOptions): PromptCategoryState {
const categories = options.defaultCategories(lang);
return {
version: STATE_VERSION,
categories,
@@ -48,8 +67,12 @@ function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
export function sanitizePromptCategoryState(value: unknown, lang: Lang): PromptCategoryState {
const fallback = initialState(lang);
export function sanitizePromptCategoryState(
value: unknown,
lang: Lang,
options: ManagedCategoryOptions = PROMPT_CATEGORY_OPTIONS,
): PromptCategoryState {
const fallback = initialState(lang, options);
if (!isRecord(value) || value.version !== STATE_VERSION || !Array.isArray(value.categories)) {
return fallback;
}
@@ -91,19 +114,19 @@ export function sanitizePromptCategoryState(value: unknown, lang: Lang): PromptC
};
}
function loadState(lang: Lang): PromptCategoryState {
function loadState(lang: Lang, options: ManagedCategoryOptions): PromptCategoryState {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return initialState(lang);
return sanitizePromptCategoryState(JSON.parse(raw), lang);
const raw = localStorage.getItem(options.storageKey);
if (!raw) return initialState(lang, options);
return sanitizePromptCategoryState(JSON.parse(raw), lang, options);
} catch {
return initialState(lang);
return initialState(lang, options);
}
}
function saveState(state: PromptCategoryState) {
function saveState(storageKey: string, state: PromptCategoryState) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
localStorage.setItem(storageKey, JSON.stringify(state));
return true;
} catch {
return false;
@@ -122,12 +145,12 @@ function nextCategoryId(categories: PromptCategory[]) {
return id;
}
export function usePromptCategories(lang: Lang) {
const [state, setState] = React.useState<PromptCategoryState>(() => loadState(lang));
export function useManagedCategories(lang: Lang, options: ManagedCategoryOptions) {
const [state, setState] = React.useState<PromptCategoryState>(() => loadState(lang, options));
React.useEffect(() => {
saveState(state);
}, [state]);
saveState(options.storageKey, state);
}, [options.storageKey, state]);
const categoryIds = React.useMemo(
() => new Set(state.categories.map((category) => category.id)),
@@ -137,9 +160,11 @@ export function usePromptCategories(lang: Lang) {
const categoryForPrompt = React.useCallback(
(promptKey: string) => {
const assigned = state.assignments[promptKey];
return assigned && categoryIds.has(assigned) ? assigned : state.defaultCategoryId;
if (assigned && categoryIds.has(assigned)) return assigned;
const inferred = options.defaultCategoryForPrompt?.(promptKey);
return inferred && categoryIds.has(inferred) ? inferred : state.defaultCategoryId;
},
[categoryIds, state.assignments, state.defaultCategoryId],
[categoryIds, options, state.assignments, state.defaultCategoryId],
);
const setActiveCategoryId = React.useCallback((categoryId: string) => {
@@ -210,11 +235,15 @@ export function usePromptCategories(lang: Lang) {
if (!promptKey || !categoryIds.has(categoryId)) return;
setState((current) => {
const assignments = { ...current.assignments };
if (categoryId === current.defaultCategoryId) delete assignments[promptKey];
const inferred = options.defaultCategoryForPrompt?.(promptKey);
const naturalCategoryId = inferred && categoryIds.has(inferred)
? inferred
: current.defaultCategoryId;
if (categoryId === naturalCategoryId) delete assignments[promptKey];
else assignments[promptKey] = categoryId;
return { ...current, assignments };
});
}, [categoryIds]);
}, [categoryIds, options]);
const forgetPrompt = React.useCallback((promptKey: string) => {
if (!promptKey) return;
@@ -238,3 +267,7 @@ export function usePromptCategories(lang: Lang) {
forgetPrompt,
};
}
export function usePromptCategories(lang: Lang) {
return useManagedCategories(lang, PROMPT_CATEGORY_OPTIONS);
}
+30
View File
@@ -350,3 +350,33 @@
transform: translateX(80%);
}
}
.cx-dialog-spin {
animation: cx-app-dialog-spin 0.8s linear infinite;
}
.cx-skin-restart-warning {
display: grid;
grid-template-columns: 24px minmax(0, 1fr);
gap: 10px;
align-items: start;
padding: 12px;
border: 1px solid #ead6a9;
border-radius: 8px;
color: #76531c;
background: #fff9eb;
}
.cx-skin-restart-warning svg {
margin-top: 1px;
}
.cx-skin-restart-warning p {
margin: 0;
font-size: 12px;
line-height: 1.55;
}
@keyframes cx-app-dialog-spin {
to { transform: rotate(360deg); }
}
+61
View File
@@ -345,6 +345,67 @@
border-color: transparent;
}
/* Skins */
:root[data-theme="dark"] .cx-skins-page {
--cx-skins-text: var(--cx-dark-text);
--cx-skins-muted: var(--cx-dark-muted);
--cx-skins-line: var(--cx-dark-border);
--cx-skins-surface: var(--cx-dark-surface);
--cx-skins-surface-muted: var(--cx-dark-surface-recessed);
--cx-skins-blue: var(--cx-dark-blue);
--cx-skins-blue-soft: var(--cx-dark-blue-surface);
}
:root[data-theme="dark"] .cx-skins-header {
border-color: var(--cx-dark-border);
}
:root[data-theme="dark"] .cx-skins-heading h2,
:root[data-theme="dark"] .cx-skins-status strong,
:root[data-theme="dark"] .cx-skins-card-title strong {
color: #f1f2f4;
}
:root[data-theme="dark"] .cx-skins-status,
:root[data-theme="dark"] .cx-skins-card {
background: var(--cx-dark-surface);
border-color: var(--cx-dark-border);
box-shadow: 0 5px 18px rgba(0, 0, 0, 0.14);
}
:root[data-theme="dark"] .cx-skins-card--enabled {
border-color: color-mix(in srgb, var(--skin-accent) 48%, var(--cx-dark-border));
box-shadow: 0 8px 24px color-mix(in srgb, var(--skin-accent) 18%, transparent);
}
:root[data-theme="dark"] .cx-skins-card--selected {
border-color: var(--cx-dark-blue);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--cx-dark-blue) 28%, transparent),
0 10px 26px rgba(0, 0, 0, 0.22);
}
:root[data-theme="dark"] .cx-skins-card-body p {
color: #c7cbd2;
}
:root[data-theme="dark"] .cx-skins-empty {
color: var(--cx-dark-muted);
background: var(--cx-dark-surface-recessed);
border-color: var(--cx-dark-border);
}
:root[data-theme="dark"] .cx-skins-refresh-status {
color: #a9c6ff;
background: var(--cx-dark-blue-surface);
border-color: #4d6691;
}
:root[data-theme="dark"] .cx-skin-restart-warning {
border-color: #6f5a2f;
color: #ead8ac;
background: #302817;
}
/* Providers */
:root[data-theme="dark"] .cx-providers {
--cx-provider-text: var(--cx-dark-text);
+10 -3
View File
@@ -702,9 +702,12 @@
}
.cx-providers-auth-editor {
overflow: auto;
white-space: pre;
overflow-wrap: normal;
min-height: 420px;
overflow-x: hidden;
overflow-y: auto;
white-space: pre-wrap;
overflow-wrap: anywhere;
word-break: break-all;
}
.cx-providers-toml-editor {
@@ -719,6 +722,10 @@
justify-content: flex-end;
}
.cx-providers-official-actions {
margin-top: 18px;
}
.cx-providers-spin {
animation: cx-providers-spin 1s linear infinite;
}
@@ -24,13 +24,18 @@
color: var(--cx-tool-text);
}
.cx-skills-page,
.cx-prompts-page--list {
.cx-skills-page {
height: 100%;
flex: 1 1 auto;
overflow: hidden;
}
.cx-prompts-page--list {
min-height: 100%;
flex: 0 0 auto;
overflow: visible;
}
.cx-prompts-page--form {
min-height: 100%;
}
@@ -1074,7 +1079,7 @@
min-width: 0;
min-height: 0;
display: flex;
flex: 1 1 auto;
flex: 0 0 auto;
flex-direction: column;
gap: 10px;
}
@@ -1082,12 +1087,12 @@
.cx-prompts-list {
min-width: 0;
min-height: 0;
flex: 1 1 auto;
flex: 0 0 auto;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
align-content: start;
gap: 16px;
overflow-y: auto;
overflow: visible;
padding: 0 8px 32px 0;
scrollbar-gutter: stable;
}
@@ -1283,6 +1288,10 @@
letter-spacing: 0;
}
.cx-prompts-form-panel-head .ui-status-badge {
margin-left: auto;
}
.cx-prompts-form-grid {
min-width: 0;
display: grid;
@@ -1357,6 +1366,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;
}
+648
View File
@@ -0,0 +1,648 @@
.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%;
}
}
+78
View File
@@ -53,6 +53,15 @@ export type BuiltinPromptStatus = {
syncIssue?: "catalog" | "content" | null;
checkedAt?: string | null;
message: string;
customized: boolean;
};
export type BuiltinPromptDetail = {
id: string;
filename: string;
title: string;
content: string;
customized: boolean;
};
export type BackupEntry = {
@@ -74,6 +83,7 @@ export type CodexState = {
officialAuthAvailable: boolean;
model?: string;
modelProvider?: string;
isOfficialProvider: boolean;
instructionFile?: string;
instructionEnabled: boolean;
instructionInjectionMode?: PromptInjectionMode;
@@ -94,6 +104,12 @@ export type ActionResult = {
state: CodexState;
};
export type OfficialConfigDraft = {
authJson: string;
model?: string;
source: string;
};
export type ImportResult = {
imported: number;
added: number;
@@ -223,3 +239,65 @@ export type StartupDiagnostics = {
summary: string;
items: DiagnosticItem[];
};
export type SkinThemeColors = {
background: string;
panel: string;
panelAlt: string;
accent: string;
accentAlt: string;
secondary: string;
highlight: string;
text: string;
muted: string;
line: string;
};
export type SkinThemeSummary = {
id: string;
name: string;
tagline: string;
quote: string;
image: string;
imagePath: string;
source: "builtin" | "imported" | string;
enabled: boolean;
directory: string;
adaptive: boolean;
surfaceOpacity: number;
art: {
focusX?: number | null;
focusY?: number | null;
safeArea?: "auto" | "left" | "right" | "center" | "none" | string | null;
taskMode?: "auto" | "ambient" | "banner" | "off" | string | null;
};
colors: SkinThemeColors;
};
export type SkinRuntimeStatus = {
supported: boolean;
active: boolean;
phase: "unsupported" | "inactive" | "active" | "paused" | "stale" | "unavailable" | "error" | string;
port?: number | null;
themeId?: string | null;
message: string;
};
export type SkinCenterState = {
skinsDir: string;
currentThemeId?: string | null;
currentThemePath?: string | null;
themes: SkinThemeSummary[];
runtime: SkinRuntimeStatus;
};
export type SkinActionResult = {
message: string;
state: SkinCenterState;
restartRequired: boolean;
};
export type SkinExportResult = {
path: string;
message: string;
};
+123
View File
@@ -0,0 +1,123 @@
# Codex-X 项目维护日志
本文件记录面向维护者的根因、设计决策、验证依据和遗留风险。它与
`CHANGELOG.md` 分工如下:
- `CHANGELOG.md` 面向用户,记录每个发布版本可感知的新增、调整和修复。
- 本文件面向开发维护,解释为什么修改、哪些约束不能破坏,以及如何验证。
日志不得包含 Token、认证文件内容、用户数据或仅适用于某台电脑的隐私路径。
## 2026-07-30:live 配置并发与失败回滚
### 已确认根因
Codex-X 的供应商、提示词、备份恢复和状态刷新曾各自读写 `config.toml`。即使每次
写出的内容都是合法 TOML,只要 Codex、CC Switch 或另一条 Codex-X 操作在“读取旧值”和
“整文件替换”之间更新配置,后写入者就会覆盖前一个新值。多文件操作在中途失败时还可能
留下 config、auth、AGENTS 或应用数据库互不对应的混合状态。
### 约束
- 所有 Codex live 配置写入共用跨进程文件锁;已经持锁的内部函数使用 `_locked` 入口,
禁止重复获取非重入锁。
- 写入前保存原始字节快照,原子替换前再次核对当前内容;快照已过期时拒绝写入并要求刷新。
- 回滚只能覆盖本次操作实际写出的期望值。多文件回滚先统一预检,再逆序恢复,不能覆盖
Codex 或 CC Switch 在失败后写入的新内容。
- 活动供应商编辑从身份识别到数据库更新、live 热更新和最终状态构造都处在同一配置锁
边界内。
- 备份恢复只接受备份根目录下的单个普通目录名和普通文件;元数据 ID、`CODEX_HOME` 与
`had_config` / `had_auth` / `had_agents` 声明必须和实际载荷完全一致。损坏或缺失元数据、
跨目录恢复、路径穿越、额外/缺失载荷和符号链接均在 live 写入前拒绝。旧提示词结构先在
内存迁移,再作为一次可回滚写入落盘。
- 状态刷新在同一锁内完成旧配置迁移、官方认证捕获和状态构造;认证快照失败必须返回错误,
不得吞掉后显示成功状态。
## 2026-07-30:关闭主窗口后驻留系统托盘
### 问题
Codex-X 原先没有系统托盘和窗口关闭事件处理。用户点击主窗口关闭按钮后,
Tauri 事件循环随最后一个窗口关闭而结束,后台管理能力也随之退出。
首版修复只调用了 `window.hide()`。这可以保留进程和窗口状态,但 macOS 应用仍是
`ActivationPolicy::Regular`,因此 Dock 图标不会消失,尚未真正进入仅菜单栏驻留状态。
### 决策
- 仅拦截标签为 `main` 的主窗口关闭请求,将窗口隐藏而不是销毁或结束进程。
- macOS 关闭时同时隐藏 Dock 并切换为 `ActivationPolicy::Accessory`;恢复窗口时切回
`Regular`。这两项都执行,避免不同 macOS 版本残留 Dock 图标。
- Windows 关闭时显式启用 `skip_taskbar`,恢复时关闭,避免隐藏窗口仍占用任务栏。
- macOS 左键点击顶部栏图标打开菜单;Windows 左键点击托盘图标直接恢复窗口。
- 选择“显示 Codex-X”会恢复、取消最小化并聚焦原主窗口。
- 托盘菜单始终提供“退出 Codex-X”,用于明确结束后台进程。
- macOS 再次点击 Dock 图标时也恢复已经隐藏的主窗口。
- 不拦截应用级退出或更新器的显式重启,保证退出菜单、`Cmd+Q` 和安装更新仍可结束进程。
### 参考实现
对照 CC Switch `56fb46c09310ff52dabefd2b32f0e799e8357d9e` 的
`src-tauri/src/lib.rs` 和 `src-tauri/src/tray.rs`:其 Windows 路径使用
`set_skip_taskbar`,macOS 路径同时使用 `set_dock_visibility` 和
`set_activation_policy`。Codex-X 复用相同的平台生命周期规则,但保留自己的简化托盘菜单。
### 验证
- `cargo fmt --all -- --check`、`cargo check --locked --lib` 和 `git diff --check` 通过。
- `cargo test --locked --features windows-runtime-check` 全量通过。
- `pnpm --dir apps/desktop typecheck` 和 `pnpm --dir apps/desktop build:renderer` 通过。
- macOS 本机包实测:窗口显示时 Launch Services 类型为 `Foreground`;点击关闭按钮后
主窗口消失、PID 保持不变,类型切换为 `UIElement`。这证明应用已从 Dock 模式进入仅
顶部栏驻留模式;再次激活后同一 PID 恢复为 `Foreground`,配置数据正常加载。
- `Cmd+H` 后可以恢复同一进程;`Cmd+Q` 和托盘“退出 Codex-X”仍用于真正结束进程。
- 本机 Windows 交叉编译在第三方依赖 `ring` 阶段因缺少 MSVC C 头文件停止,尚未进入
项目代码;发布前仍须以 GitHub Windows Actions 的原生构建结果为准。
## 2026-07-29:供应商切换与配置完整性
### 已确认根因
旧实现会把检测到的整份历史 `config.toml` 保存到供应商记录,并在后续切换时覆盖
当前文件。该文件仍可能是合法 TOML,但新增加的项目、插件、MCP 和功能设置会丢失,
表现为 Codex 要求重新设置或配置被“破坏”。
### 约束
- 供应商记录只拥有供应商相关字段,不拥有整份用户配置。
- 激活供应商必须基于最新 live 文档合并目标 provider 表,不回放历史整文件快照。
- 官方认证快照与中转认证严格隔离,供应商切换不能覆盖可信官方认证。
- 自动捕获官方认证只接受明确的 ChatGPT 登录模式及 access/refresh/id token;代理路由下的
API key 属于不可信来源,不能晋升为官方快照。
- 用户明确从已确认的官方 live 路由切换到第三方时,允许保存非空的官方 API Key 快照,
以保证“官方 API Key → 第三方 → 官方”往返不会丢失认证;只读状态刷新仍不自动采信 API Key。
- 第三方密钥只写入活动 provider 表的 `experimental_bearer_token`,不写入 `auth.json`。
- 官方与第三方路由都使用稳定的 `custom` 会话分桶;官方 provider 不设置 `base_url`,继续
使用真实 OpenAI 后端和独立保存的官方 `auth.json`。
- 编辑活动供应商必须原子更新原记录,不能通过“保存后再切换”制造副本。
- 切换只影响后续新建会话,不强制重启 Codex 客户端。
## 2026-07-29:会话供应商同步
### 决策
- 统一使用 Codex 可识别的 `custom` 共享分桶。
- 活动会话的唯一权威来源是当前存储根目录中最高版本的可读 `state_N.sqlite`;旧 SQLite、
单独存在的 JSONL 和 JSONL 行数都不能当作会话数量。
- 扫描 JSONL 前先按活动 thread ID 和 SQLite 的 `rollout_path` 建立候选集。标准命名孤立
文件和未被引用的非常规文件不读取、不计入活动 rollout,也不会因损坏而阻断同步。
- SQLite 一旦为线程提供 `rollout_path`,该路径就是唯一权威文件;不得再按 UUID 文件名
回退并修改同 ID 的旧副本。权威路径缺失、不可读或越界时必须标记扫描失败并阻止同步。
- `rollout_path` 指向的文件必须包含与该 SQLite 记录相同的 `session_meta.id`;缺少元数据、
ID 串线或多个线程引用同一个文件时必须阻止同步,不能只更新 SQLite 后报告成功。
- SQLite 明确引用的非常规 rollout 仅在 `sessions` / `archived_sessions` 内、且为非符号链接
普通 JSONL 时处理;活动候选无法读取或解析时保持失败关闭,不能显示“已同步”。
- 只修改 rollout 元数据和活动 SQLite 线程索引中的 provider 字段。
- 不修改会话正文、工作目录、用户事件标记或全局界面状态。
- 备份和回滚只覆盖本次实际修改的会话存储。
### 来源说明
当前 `b-nnett/codex-plusplus` 源码没有 rollout JSONL/SQLite 分桶同步实现,不能把这部分
描述为对其逐行复刻;`custom` 共享分桶语义来自对 CC Switch 可验证实现和本地 Codex
存储行为的交叉检查。
@@ -0,0 +1,47 @@
## 审查角色
你是严格、务实的高级代码审查者。审查目标是发现会造成错误行为、回归、安全问题、数据损坏、兼容性故障或维护成本失控的具体问题。
除非用户明确要求修改代码,否则只审查和报告,不直接编辑文件。
## 审查方法
1. 先读取变更差异,再读取相关函数、类型、调用者、测试和配置。
2. 理解修改前后的行为契约,不只检查语法和局部代码。
3. 追踪输入、状态、错误和副作用经过的完整路径。
4. 检查正常路径、失败路径、空值、边界值、并发和重复执行。
5. 检查平台、版本、序列化、数据库和公共接口兼容性。
6. 检查测试是否真的覆盖新行为,而不是只让覆盖率数字增加。
7. 只报告能够用代码和场景解释清楚的问题。
## 重点检查
* 条件判断错误、状态不同步、过期闭包和生命周期问题
* 权限绕过、注入、路径穿越、敏感信息泄漏和不安全默认值
* 非原子写入、部分失败、错误回滚和数据迁移问题
* 竞态、死锁、资源泄漏、无界循环和无界重试
* API、Schema、配置、文件格式和跨平台行为回归
* 吞异常、误报成功、错误信息丢失和不可观察的失败
* 未覆盖关键失败路径或会通过但无法阻止回归的测试
不要把纯个人偏好、无影响的命名差异或格式问题当成缺陷。除非影响理解或会诱发错误,否则不报告样式类意见。
## 严重级别
* `P0`:会造成严重安全事件、广泛数据损坏或服务不可用,必须立即阻止合并。
* `P1`:高概率产生错误行为、安全风险或重要回归,应在合并前修复。
* `P2`:在明确条件下产生缺陷或显著维护风险,建议本次修复。
* `P3`:低影响但真实存在的问题,可排期处理。
## 输出要求
先列发现,按严重级别排序。每条发现必须包含:
* 简短标题
* 文件和尽可能精确的行号
* 触发条件
* 实际影响
* 为什么当前实现会发生该问题
* 可执行的修复方向
然后列出必要的开放问题或假设,最后给出简短变更摘要。若没有发现,明确写“未发现需要阻止合并的问题”,并说明仍未覆盖的测试或残余风险。
@@ -0,0 +1,49 @@
## 角色与目标
你是负责线上质量的高级软件工程师。面对 Bug、崩溃、性能下降、构建失败或行为异常时,目标是找到可证明的根因并完成最小修复,而不是用绕过、重试或吞异常掩盖问题。
默认使用用户的语言沟通。代码、命令、日志、错误信息和标识符保持原样。
## 调试流程
1. 读取项目说明、相关实现、近期差异和现有测试。
2. 明确预期行为、实际行为、触发条件和影响范围。
3. 尽可能稳定复现;无法复现时,先补充低风险诊断信息或构造最小复现。
4. 沿真实调用链追踪数据、状态和副作用,不根据表面症状猜修复点。
5. 建立少量可证伪的根因假设,并用日志、测试、断点、查询或最小实验逐一验证。
6. 找到首次偏离预期的位置,区分根因、传播路径和最终症状。
7. 在权威实现处修复,补充能在修复前失败、修复后通过的回归测试。
8. 运行与风险相匹配的测试、类型检查、Lint 和构建。
## 证据要求
* 结论必须来自代码、运行结果、日志、数据或可重复实验。
* 不得虚构命令输出、接口响应、数据库内容或测试结果。
* 区分已确认事实、合理推断和仍待验证的信息。
* 记录关键触发条件,包括输入、平台、版本、并发、时序、缓存和外部依赖。
* 日志和报告不得泄露 Token、Cookie、密码、私钥或个人数据。
## 修复约束
* 优先复用现有 Service、Hook、Repository、校验和错误处理。
* 避免扩大公共接口、数据结构和持久化格式的变更范围。
* 不通过空 `catch`、无上限重试、硬编码延迟或静默降级隐藏失败。
* 涉及并发时检查竞态、锁顺序、幂等性、取消、超时和重复提交。
* 涉及文件或数据库时检查原子性、部分写入、回滚和崩溃恢复。
* 涉及跨平台行为时分别检查路径、权限、编码、换行符和进程模型。
* 保留用户已有修改,不顺手重构无关模块。
## 验证标准
至少回答:
* 原问题能否稳定复现?
* 根因位于哪里,证据是什么?
* 为什么修复放在这里?
* 修复是否覆盖空值、边界值和失败路径?
* 是否可能影响其他调用者或平台?
* 哪些检查已实际运行,哪些因环境限制未运行?
## 输出格式
先给出结果,再简要说明根因、修改文件、验证结果和剩余风险。若仍被阻塞,指出唯一的实际阻塞条件和下一条最有价值的验证动作。
+192
View File
@@ -0,0 +1,192 @@
## 基本原则
这是长期维护的正式项目,不是一次性 Demo。
修改代码时优先保证:
1. 正确性和安全性
2. 不破坏现有功能
3. 复用现有实现
4. 最小改动
5. 可维护性和可测试性
---
## 编码前
开始修改前,先搜索项目中是否已有类似实现,包括:
* 函数、类、组件
* Service、Repository、Hook
* API 封装
* 类型、常量、配置
* 校验、转换、错误处理逻辑
优先顺序:
1. 直接复用
2. 扩展现有实现
3. 抽取公共逻辑
4. 确实无法复用时再新增
禁止在未检查现有代码的情况下重新实现相同功能。
复杂任务开始前,先简要说明:
* 相关现有实现
* 可复用模块
* 准备修改的文件
* 是否需要新增文件
* 主要风险
小改动无需写冗长计划。
---
## 禁止重复造轮子
禁止:
* 创建与现有功能高度相似的新函数、类或组件
* 复制已有代码后只修改少量内容
* 重复定义类型、常量、枚举、错误码和接口地址
* 绕过现有 Service、Repository 或 API Client 重写调用逻辑
* 在多个位置分别实现同一业务规则
* 引入与现有依赖功能重复的新依赖
相同业务规则应只有一个权威实现。
---
## 文件与函数规模
行数只作为预警,不是硬性限制。禁止为了减少行数而机械拆分。
参考范围:
* 函数建议不超过 80 行
* 函数超过 100 行时检查是否职责过多
* 组件或类建议不超过 500 行
* 业务文件建议不超过 800 行
* 文件超过 1200 行时优先评估拆分
* 文件超过 1500 行且包含多个职责时应拆分
以下情况即使代码不长,也应考虑拆分:
* 一个模块承担多个无关职责
* 嵌套过深或条件分支过多
* 相同逻辑重复出现
* 难以测试、阅读或复用
* 修改一个功能经常影响无关功能
以下文件可适当放宽:
* 自动生成代码
* 类型声明
* 静态配置或数据
* Schema
* 数据库迁移
* 集中式路由或注册文件
拆分必须依据业务职责,不得创建大量无意义的小文件。
---
## 修改原则
* 优先最小改动
* 不做与当前需求无关的重构
* 不为小需求重写整个模块
* 不擅自修改公共接口、数据库结构或返回格式
* 保持现有目录、命名、风格和架构
* 不创建 `new`、`final`、`v2`、`copy`、`backup` 等重复文件
* 不保留废弃代码、注释代码、调试输出和无意义 TODO
* 不随意新增依赖
发现架构问题时,优先采用渐进式改造,不要一次性重写。
---
## 代码设计
每个函数、类、组件和文件应有明确职责。
避免:
* UI、请求、状态和业务逻辑全部写在一个组件
* Controller 同时处理校验、业务和数据库操作
* 巨型 `utils`、`constants`、`types` 文件
* 过深嵌套
* 大量布尔参数
* 隐藏副作用
* 硬编码配置和业务值
* 大量 `any`
* 空 `catch`
* 吞掉异常
不要过度抽象。只有在逻辑重复、稳定或需要复用时才抽取公共模块。
---
## 安全与错误处理
必须考虑:
* 参数非法
* 空值和边界值
* 权限校验
* 网络或数据库失败
* 外部服务超时
* 并发和重复提交
* 文件操作失败
* 敏感信息泄漏
禁止:
* 硬编码密码、Token 或密钥
* 关闭安全校验
* 记录敏感信息
* SQL 注入、命令注入、XSS、路径穿越等明显风险
* 捕获异常后不处理
优先复用项目现有错误类型、日志和权限机制。
---
## 测试与验证
修改完成后检查:
* 是否重复实现已有功能
* 是否出现复制粘贴代码
* 是否存在超长函数或职责混乱
* 是否破坏已有接口
* 是否存在未使用代码或导入
* 是否遗漏错误处理和边界条件
* 是否需要新增或更新测试
根据项目情况实际运行:
* 测试
* Lint
* 类型检查
* 构建
* 格式检查
没有实际运行的检查,不得声称已经通过。
---
## 输出要求
修改多个文件时,先列出文件变更清单。
输出结果应说明:
1. 复用了哪些现有实现
2. 修改、新增或删除了哪些文件
3. 核心改动是什么
4. 是否存在兼容性风险
5. 实际运行了哪些验证命令
不要输出冗长的过程说明,不要无意义重复整个文件。
+37
View File
@@ -0,0 +1,37 @@
## 编辑目标
你是重视准确性和读者体验的中英文编辑。你的任务是在不改变作者核心意思、不增加未经证实事实的前提下,让文本更清晰、自然、紧凑和可信。
优先交付可以直接使用的修订稿,不用大段解释写作理论。
## 基本原则
* 保留原意、事实、立场、数字、引用和专业术语。
* 不虚构数据、来源、案例、经历、结论或作者没有表达的承诺。
* 删除空话、套话、重复、机械过渡和无实际信息的总结。
* 修复歧义、指代不清、逻辑跳跃、句式拖沓和段落失衡。
* 使用目标读者熟悉的词语,避免为了显得专业而堆叠术语。
* 保持同一文档中的称谓、时态、标点、数字和术语一致。
* 不把作者鲜明的语气统一改成模板化或客服式表达。
## 工作流程
1. 判断文本用途、读者、语气和必须保留的信息。
2. 找出主旨、支撑信息和行动要求,调整信息顺序。
3. 先解决事实与逻辑问题,再处理句子和措辞。
4. 合并重复内容,拆分负担过重的长句和段落。
5. 检查标题、开头、段落衔接和结尾是否承担明确功能。
6. 完成后复核是否误改含义、遗漏限定条件或夸大结论。
## 中英文处理
* 中文优先自然、明确,避免翻译腔、滥用顿号和连续名词堆叠。
* 英文优先直接、具体,减少 nominalization、冗余被动语态和空泛修饰语。
* 技术名词、产品名、接口名和代码标识符保持原文,除非用户指定译法。
* 翻译时传达原意和语气,不逐字硬译,也不擅自补充信息。
## 输出方式
默认只输出完整修订稿。
当原文存在事实冲突、关键歧义或缺少决定性信息时,在修订稿后增加“待确认”,只列真正影响内容的问题。用户要求对照或解释时,再补充精简的修改说明。
+48
View File
@@ -0,0 +1,48 @@
## 写作任务
你是擅长整理复杂材料的结构化写作助手。将用户提供的笔记、事实、观点、数据和零散片段组织成一篇重点明确、层次清楚、论证连贯的完整初稿。
适用于报告、方案、复盘、文章、说明、提案和较长的业务文本。
## 内容边界
* 只使用用户材料和明确给出的可靠来源。
* 不虚构数字、引文、人物观点、研究结论、客户反馈或实施结果。
* 区分事实、判断、建议和待验证假设,不把它们混写成确定结论。
* 保留重要限定条件和反例,不为了流畅删除影响结论的信息。
* 材料冲突时先保留冲突并标记,不擅自选择有利版本。
## 起草流程
1. 明确文本目的、目标读者、期望语气、篇幅和读者读完后的行动。
2. 提取一个核心命题,以及支撑它的关键事实和论点。
3. 合并重复材料,将内容按因果、时间、问题解决或重要性组织。
4. 先形成简洁提纲,再扩写为完整段落。
5. 每段只承担一个主要功能,并通过明确过渡连接上下文。
6. 检查结论是否由前文支撑,建议是否对应已识别的问题。
7. 删除重复结论、空泛口号、模板化开场和无信息量的收尾。
## 常用结构
按内容选择,而不是机械套用:
* 问题解决:背景 → 问题 → 原因 → 方案 → 实施 → 风险 → 结论。
* 分析报告:结论摘要 → 证据 → 分析 → 限制 → 建议。
* 项目复盘:目标 → 结果 → 过程 → 偏差 → 根因 → 改进措施。
* 观点文章:核心观点 → 语境 → 论据 → 反方或限制 → 结论。
* 提案方案:目标 → 现状 → 方案 → 成本收益 → 里程碑 → 风险与决策项。
## 表达要求
* 先说结论和关键信息,再补背景。
* 使用具体名词和动词,减少“赋能、抓手、闭环”等空泛表达。
* 不连续使用含义相近的小标题,不把一句话拆成一个章节。
* 数据要说明口径、时间范围和比较基准。
* 建议要有负责人、动作、条件或验证标准中的至少一项。
* 语气应与用途一致:报告克制,方案明确,文章自然,复盘诚实。
## 输出方式
信息充分时,直接输出完整初稿。
信息零散但可合理组织时,先给一份短提纲,再给完整初稿。只有缺失信息会实质改变结论时,才在末尾列出不超过 5 个“待确认问题”。
+47
View File
@@ -0,0 +1,47 @@
## 文档角色
你是面向开发者和实际使用者的技术文档工程师。根据代码、配置、接口、命令、测试结果和用户提供的事实,编写可执行、可验证、可维护的技术文档。
## 事实优先
* 写作前先读取相关源码、类型、配置、脚本和已有文档。
* 不虚构 API、参数、默认值、返回结果、版本支持或命令输出。
* 无法从材料确认的信息要明确标为待确认,不用常识补全。
* 示例必须与当前代码契约一致;条件允许时实际运行命令或最小示例。
* 不公开 Token、Cookie、私钥、内部地址、个人路径或其他敏感信息。
## 结构原则
根据文档用途选择最小充分结构:
* README:项目是什么、适用对象、安装、快速开始、配置、常见问题。
* 操作指南:目标、前置条件、编号步骤、验证方法、回滚或排错。
* API 文档:用途、认证、请求、字段、响应、错误、示例和兼容性。
* 架构说明:边界、核心组件、数据流、关键决策、约束和扩展点。
* 发布说明:用户可感知变化、兼容性、升级步骤、已知问题。
不要为了形式完整而添加空章节。标题应帮助读者查找信息,不要用大量装饰性标题切碎内容。
## 写作要求
* 开头直接说明文档对象和读者能完成什么。
* 步骤使用可操作动词,并说明成功后的可观察结果。
* 命令、路径、环境变量、字段名和代码使用准确格式。
* 前置条件放在执行步骤之前,警告放在对应风险动作之前。
* 相同概念只保留一个权威解释,其他位置使用链接或简短引用。
* 清楚区分必需项、可选项、默认值和平台差异。
* 保持术语、示例名称和参数值前后一致。
## 维护检查
提交前确认:
* 文档描述的是当前实现,不是计划中的功能。
* 所有内部链接、文件路径和命令均可定位。
* 示例没有省略会导致失败的关键步骤。
* 升级、破坏性变更和兼容性风险已明确说明。
* 没有重复复制大段容易过期的配置或源码。
## 输出要求
先给出完整可用的文档正文。若材料不足,在正文后列出“待确认信息”和对应影响;不要用占位段落冒充已完成内容。
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "codex-x-workspace",
"private": true,
"version": "0.3.0",
"version": "0.3.6",
"scripts": {
"dev": "pnpm --dir apps/desktop tauri dev",
"build": "pnpm --dir apps/desktop tauri build",
+10
View File
@@ -13,6 +13,9 @@ importers:
'@tauri-apps/api':
specifier: ^2.8.0
version: 2.11.1
'@tauri-apps/plugin-dialog':
specifier: ^2.7.2
version: 2.7.2
'@tauri-apps/plugin-process':
specifier: ^2.3.1
version: 2.3.1
@@ -525,6 +528,9 @@ packages:
engines: {node: '>= 10'}
hasBin: true
'@tauri-apps/plugin-dialog@2.7.2':
resolution: {integrity: sha512-pX0IGm1I3I6wc+zeKYcq1GSqogK6okCNX5fOdaNU5ab1AjGS6l1E5wFNjEb7meg7ZFSp0JUs+0jQGQNyOvLrsg==}
'@tauri-apps/plugin-process@2.3.1':
resolution: {integrity: sha512-nCa4fGVaDL/B9ai03VyPOjfAHRHSBz5v6F/ObsB73r/dA3MHHhZtldaDMIc0V/pnUw9ehzr2iEG+XkSEyC0JJA==}
@@ -1093,6 +1099,10 @@ snapshots:
'@tauri-apps/cli-win32-ia32-msvc': 2.11.4
'@tauri-apps/cli-win32-x64-msvc': 2.11.4
'@tauri-apps/plugin-dialog@2.7.2':
dependencies:
'@tauri-apps/api': 2.11.1
'@tauri-apps/plugin-process@2.3.1':
dependencies:
'@tauri-apps/api': 2.11.1
+136
View File
@@ -0,0 +1,136 @@
#!/usr/bin/env python3
import base64
import json
import subprocess
import tempfile
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
VALIDATOR = ROOT / "scripts" / "validate_updater_release.py"
REPOSITORY = "example/Codex-X"
RELEASE_TAG = "v0.3.1"
VERSION = "0.3.1"
PLATFORM_ASSETS = {
"darwin-aarch64": "Codex-X.app.tar.gz",
"darwin-aarch64-app": "Codex-X.app.tar.gz",
"darwin-x86_64": "Codex-X-intel.app.tar.gz",
"darwin-x86_64-app": "Codex-X-intel.app.tar.gz",
"windows-x86_64": "Codex-X.msi",
"windows-x86_64-msi": "Codex-X.msi",
"linux-x86_64": "Codex-X.AppImage",
"linux-x86_64-deb": "Codex-X.deb",
"linux-x86_64-rpm": "Codex-X.rpm",
"linux-x86_64-appimage": "Codex-X.AppImage",
}
def draft_url(asset_name: str) -> str:
return (
"https://github.com/example/Codex-X/releases/download/"
f"untagged-test/{asset_name}"
)
class ValidateUpdaterReleaseTests(unittest.TestCase):
def setUp(self) -> None:
self.temp_dir = tempfile.TemporaryDirectory()
self.addCleanup(self.temp_dir.cleanup)
self.root = Path(self.temp_dir.name)
self.manifest_path = self.root / "latest.json"
self.assets_path = self.root / "assets.json"
signature = base64.b64encode(b"s" * 64).decode("ascii")
manifest = {
"version": VERSION,
"platforms": {
platform: {
"signature": signature,
"url": draft_url(asset_name),
}
for platform, asset_name in PLATFORM_ASSETS.items()
},
}
asset_names = sorted(set(PLATFORM_ASSETS.values()))
assets = [
{
"name": asset_name,
"url": f"https://api.github.com/assets/{index}",
"browser_download_url": draft_url(asset_name),
}
for index, asset_name in enumerate(asset_names, start=1)
]
assets.append(
{
"name": "latest.json",
"url": "https://api.github.com/assets/latest",
"browser_download_url": draft_url("latest.json"),
}
)
self.manifest_path.write_text(json.dumps(manifest), encoding="utf-8")
self.assets_path.write_text(json.dumps(assets), encoding="utf-8")
def run_validator(self, *extra_args: str) -> subprocess.CompletedProcess[str]:
return subprocess.run(
[
"python3",
str(VALIDATOR),
"--manifest",
str(self.manifest_path),
"--assets",
str(self.assets_path),
"--version",
VERSION,
"--repository",
REPOSITORY,
"--release-tag",
RELEASE_TAG,
*extra_args,
],
check=False,
capture_output=True,
text=True,
)
def test_rewrites_draft_urls_to_stable_release_tag(self) -> None:
result = self.run_validator("--rewrite-download-urls")
self.assertEqual(result.returncode, 0, result.stderr)
manifest = json.loads(self.manifest_path.read_text(encoding="utf-8"))
urls = {entry["url"] for entry in manifest["platforms"].values()}
self.assertTrue(urls)
self.assertTrue(
all("/releases/download/v0.3.1/" in url for url in urls),
urls,
)
self.assertTrue(all("untagged-" not in url for url in urls), urls)
def test_rejects_draft_urls_without_rewrite(self) -> None:
result = self.run_validator()
self.assertNotEqual(result.returncode, 0)
self.assertIn("stable release tag download URL", result.stderr)
def test_repairs_stale_urls_after_release_is_published(self) -> None:
assets = json.loads(self.assets_path.read_text(encoding="utf-8"))
for asset in assets:
asset["browser_download_url"] = (
f"https://github.com/{REPOSITORY}/releases/download/"
f"{RELEASE_TAG}/{asset['name']}"
)
self.assets_path.write_text(json.dumps(assets), encoding="utf-8")
result = self.run_validator("--rewrite-download-urls")
self.assertEqual(result.returncode, 0, result.stderr)
manifest = json.loads(self.manifest_path.read_text(encoding="utf-8"))
self.assertTrue(
all(
"/releases/download/v0.3.1/" in entry["url"]
for entry in manifest["platforms"].values()
)
)
if __name__ == "__main__":
unittest.main()
+52 -8
View File
@@ -9,6 +9,7 @@ import binascii
import json
from pathlib import Path
from typing import Any
from urllib.parse import quote, unquote, urlparse
REQUIRED_PLATFORMS = {
@@ -48,10 +49,20 @@ def validate_signature(platform: str, value: Any) -> None:
fail(f"{platform} signature is unexpectedly short")
def canonical_asset_url(repository: str, release_tag: str, asset_name: str) -> str:
return (
f"https://github.com/{repository}/releases/download/"
f"{quote(release_tag, safe='')}/{quote(asset_name, safe='')}"
)
def rewrite_download_urls(
manifest_path: Path,
manifest: dict[str, Any],
assets_by_url: dict[str, dict[str, Any]],
assets_by_name: dict[str, dict[str, Any]],
repository: str,
release_tag: str,
) -> int:
platforms = manifest.get("platforms")
if not isinstance(platforms, dict):
@@ -65,11 +76,14 @@ def rewrite_download_urls(
if not isinstance(current_url, str):
fail(f"{platform} has an invalid download URL")
asset = assets_by_url.get(current_url)
if asset is None:
parsed_url = urlparse(current_url)
asset_name = unquote(parsed_url.path.rsplit("/", 1)[-1])
if parsed_url.scheme == "https" and parsed_url.netloc == "github.com":
asset = assets_by_name.get(asset_name)
if asset is None:
fail(f"{platform} URL does not point to an asset in this release")
download_url = asset.get("browser_download_url")
if not isinstance(download_url, str) or not download_url.startswith("https://"):
fail(f"{platform} asset has no public download URL")
download_url = canonical_asset_url(repository, release_tag, asset["name"])
if current_url != download_url:
entry["url"] = download_url
rewritten += 1
@@ -86,9 +100,16 @@ def main() -> None:
parser.add_argument("--manifest", type=Path, required=True)
parser.add_argument("--assets", type=Path, required=True)
parser.add_argument("--version", required=True)
parser.add_argument("--repository", required=True)
parser.add_argument("--release-tag", required=True)
parser.add_argument("--rewrite-download-urls", action="store_true")
parser.add_argument("--require-signature-assets", action="store_true")
args = parser.parse_args()
repository_parts = args.repository.split("/")
if len(repository_parts) != 2 or not all(repository_parts):
fail(f"invalid GitHub repository {args.repository!r}")
manifest = load_json(args.manifest)
assets = load_json(args.assets)
if not isinstance(manifest, dict):
@@ -101,15 +122,20 @@ def main() -> None:
)
asset_names: set[str] = set()
assets_by_name: dict[str, dict[str, Any]] = {}
assets_by_url: dict[str, dict[str, Any]] = {}
for asset in assets:
if not isinstance(asset, dict) or not isinstance(asset.get("name"), str):
fail("release assets response contains an invalid entry")
asset_names.add(asset["name"])
assets_by_name[asset["name"]] = asset
for field in ("url", "browser_download_url"):
value = asset.get(field)
if isinstance(value, str) and value:
assets_by_url[value] = asset
assets_by_url[
canonical_asset_url(args.repository, args.release_tag, asset["name"])
] = asset
if "latest.json" not in asset_names:
fail("the draft release does not contain latest.json")
@@ -119,7 +145,14 @@ def main() -> None:
fail("latest.json has no platforms object")
if args.rewrite_download_urls:
rewritten = rewrite_download_urls(args.manifest, manifest, assets_by_url)
rewritten = rewrite_download_urls(
args.manifest,
manifest,
assets_by_url,
assets_by_name,
args.repository,
args.release_tag,
)
print(f"Rewrote {rewritten} updater asset URLs to public download URLs.")
for platform, entry in platforms.items():
@@ -132,8 +165,13 @@ def main() -> None:
asset = assets_by_url.get(url)
if asset is None:
fail(f"{platform} URL does not point to an asset in this release")
if url != asset.get("browser_download_url"):
fail(f"{platform} URL points to GitHub metadata instead of the asset download")
expected_url = canonical_asset_url(
args.repository,
args.release_tag,
asset["name"],
)
if url != expected_url:
fail(f"{platform} URL does not use the stable release tag download URL")
for platform, suffix in REQUIRED_PLATFORMS.items():
entry = platforms.get(platform)
@@ -144,12 +182,18 @@ def main() -> None:
asset_name = asset["name"]
if not asset_name.endswith(suffix):
fail(f"{platform} points to {asset_name!r}, expected a {suffix} updater")
if f"{asset_name}.sig" not in asset_names:
if args.require_signature_assets and f"{asset_name}.sig" not in asset_names:
fail(f"signature asset is missing for {asset_name}")
signature_status = (
"signature assets required"
if args.require_signature_assets
else "signature assets optional"
)
print(
f"Validated updater {args.version}: "
f"{len(REQUIRED_PLATFORMS)} platform installers and signatures are complete."
f"{len(REQUIRED_PLATFORMS)} platform installers are complete; "
f"{signature_status}."
)