mirror of
https://github.com/yynxxxxx/Codex-X.git
synced 2026-10-18 05:59:23 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d1b40285dd | ||
|
|
3ac28c3193 | ||
|
|
39dc8fc535 | ||
|
|
762ff36131 | ||
|
|
5894dceb98 | ||
|
|
92c371e5e0 |
@@ -221,31 +221,6 @@ 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
|
||||
@@ -380,9 +355,6 @@ jobs:
|
||||
rm -rf "$portable_root"
|
||||
mkdir -p "$portable_dir"
|
||||
cp apps/desktop/src-tauri/target/release/codex-x.exe "$portable_dir/Codex-X.exe"
|
||||
mkdir -p "$portable_dir/skin-runtime/node"
|
||||
cp apps/desktop/src-tauri/runtime/windows/node/node.exe "$portable_dir/skin-runtime/node/node.exe"
|
||||
cp apps/desktop/src-tauri/runtime/windows/node/LICENSE "$portable_dir/skin-runtime/node/LICENSE"
|
||||
touch "$portable_dir/Codex-X.portable"
|
||||
cat > "$portable_dir/README-portable.txt" <<'EOF'
|
||||
Codex-X Windows Portable
|
||||
@@ -390,7 +362,6 @@ jobs:
|
||||
Double click Codex-X.exe to run. No MSI installation is required.
|
||||
The portable edition uses manual downloads instead of in-app installation.
|
||||
Keep Codex-X.portable next to Codex-X.exe so the app can identify this edition.
|
||||
Keep the skin-runtime directory beside Codex-X.exe; it contains the verified Windows skin runtime.
|
||||
Microsoft Edge WebView2 Runtime is required; Windows 10/11 usually already includes it.
|
||||
EOF
|
||||
powershell -NoProfile -Command "Compress-Archive -Path '${portable_dir}/*' -DestinationPath '${archive}' -Force"
|
||||
|
||||
@@ -2,6 +2,38 @@
|
||||
|
||||
All notable changes to Codex-X will be documented here.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [v0.3.11] - 2026-08-11
|
||||
|
||||
### 更新
|
||||
|
||||
- 优化了软件的流畅度和部分 bug。
|
||||
|
||||
## [v0.3.10] - 2026-08-11
|
||||
|
||||
### 更新
|
||||
|
||||
- 优化了软件的流畅度和部分 bug。
|
||||
|
||||
## [v0.3.9] - 2026-08-11
|
||||
|
||||
### 更新
|
||||
|
||||
- 优化了软件的流畅度和部分 bug。
|
||||
|
||||
## [v0.3.8] - 2026-08-03
|
||||
|
||||
### 修复 Bug
|
||||
|
||||
- 修复了某些问题。
|
||||
|
||||
## [v0.3.7] - 2026-08-02
|
||||
|
||||
### 修复 Bug
|
||||
|
||||
- 修复了一些 bug。
|
||||
|
||||
## [v0.3.6] - 2026-07-31
|
||||
|
||||
### 更新
|
||||
|
||||
+1
-14
@@ -10,7 +10,7 @@
|
||||
|
||||
**Codex Prompts · API / Providers · Sessions · Skills / MCP in One Place**
|
||||
|
||||
A cross-platform desktop tool for **OpenAI Codex Desktop / Codex CLI**. Manage prompt templates, switch third-party APIs, organize / repair / permanently delete local sessions, manage Skills / MCP, apply macOS / Windows skins, and inspect TOML and login credentials without repeatedly editing configuration files by hand.
|
||||
A cross-platform desktop tool for **OpenAI Codex Desktop / Codex CLI**. Manage prompt templates, switch third-party APIs, organize / repair / permanently delete local sessions, manage Skills / MCP, and inspect TOML and login credentials without repeatedly editing configuration files by hand.
|
||||
|
||||
<p>
|
||||
<img src="https://img.shields.io/github/v/release/yynxxxxx/Codex-X?label=version&color=blue" alt="version" />
|
||||
@@ -131,10 +131,6 @@ 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>
|
||||
@@ -405,15 +401,6 @@ pnpm --dir apps/desktop tauri build
|
||||
|
||||
## Desktop Installation Notes
|
||||
|
||||
### Skin Center security boundary
|
||||
|
||||
- Live skin application supports macOS and Windows. Linux can still manage, import, and export packs but is never reported as applied.
|
||||
- macOS uses only the signed Node.js bundled with the official Codex app and validates its bundle and process identity. Windows dynamically validates the current user's registered `OpenAI.Codex` Store package and uses the pinned Node.js 22 runtime shipped with Codex-X releases.
|
||||
- CDP binds only to `127.0.0.1`; Codex-X does not modify the official `.app`, `WindowsApps`, `app.asar`, code signature, directory permissions, or `config.toml`.
|
||||
- First application usually asks to restart Codex. Turn off skin removes the live interface skin immediately, and any theme can be applied again later.
|
||||
- Themes adapt cropping from image dimensions and content. Subject-focused artwork can use `art.focusX`, `art.focusY` (`0` to `1`), and `art.safeArea` in `theme.json` for precise framing without generating duplicate images for the same theme.
|
||||
- Injection is based on the MIT-licensed [Fei-Away/Codex-Dream-Skin](https://github.com/Fei-Away/Codex-Dream-Skin) source. The pinned source and license notice are in `apps/desktop/src-tauri/resources/skin-runtime/NOTICE.md`.
|
||||
|
||||
If you see “app is damaged” when opening an unsigned / unnotarized DMG, this is normal macOS Gatekeeper behavior.
|
||||
|
||||
- Best option: sign and notarize with an Apple Developer ID
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
**Codex 可视化提示词注入 · Provider · 会话 · Skills / MCP 管理工具**
|
||||
|
||||
一款面向 **OpenAI Codex 桌面端 / Codex CLI** 的跨平台桌面工具。把提示词模板、自定义 Prompt、第三方 API 供应商、会话同步、Skills / MCP、macOS / Windows 皮肤和 TOML 配置都放进可视化界面里,不用反复手改文件。
|
||||
一款面向 **OpenAI Codex 桌面端 / Codex CLI** 的跨平台桌面工具。把提示词模板、自定义 Prompt、第三方 API 供应商、会话同步、Skills / MCP 和 TOML 配置都放进可视化界面里,不用反复手改文件。
|
||||
|
||||
<p>
|
||||
<img src="https://img.shields.io/github/v/release/yynxxxxx/Codex-X?label=version&color=blue" alt="version" />
|
||||
@@ -111,10 +111,6 @@
|
||||
<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>
|
||||
@@ -394,15 +390,6 @@ pnpm --dir apps/desktop tauri build
|
||||
|
||||
## 桌面端安装说明
|
||||
|
||||
### 皮肤中心安全边界
|
||||
|
||||
- 实机换肤支持 macOS 和 Windows;Linux 仍可管理、导入和导出主题包,但不会显示为已应用。
|
||||
- macOS 只使用官方 Codex 内置且签名一致的 Node.js,并校验 Bundle ID、代码签名和进程归属;Windows 动态校验当前用户注册的 `OpenAI.Codex` Store 包,并使用发布包内固定校验的 Node.js 22 运行时。
|
||||
- CDP 仅绑定 `127.0.0.1`,不修改官方 `.app`、`WindowsApps`、`app.asar`、代码签名、目录权限或 `config.toml`。
|
||||
- 首次应用通常需要确认重启 Codex;“关闭皮肤”会立即移除当前界面皮肤,之后可随时重新应用主题。
|
||||
- 主题默认会根据图片尺寸和内容自适应裁切;人物或主体构图可在 `theme.json` 使用 `art.focusX`、`art.focusY`(`0` 到 `1`)和 `art.safeArea` 精确控制,无需为同一主题重复生成图片。
|
||||
- 注入实现基于 [Fei-Away/Codex-Dream-Skin](https://github.com/Fei-Away/Codex-Dream-Skin) 的 MIT 源码,固定来源和许可见 `apps/desktop/src-tauri/resources/skin-runtime/NOTICE.md`。
|
||||
|
||||
如果你在未签名 / 未公证的 DMG 中看到“软件已损坏”提示,这是 macOS Gatekeeper 的正常行为。
|
||||
|
||||
- 最佳方式:使用 Apple Developer ID 签名并 notarize
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "codex-x",
|
||||
"version": "0.3.6",
|
||||
"version": "0.3.11",
|
||||
"private": true,
|
||||
"description": "Codex Switch & Instruct desktop manager",
|
||||
"type": "module",
|
||||
@@ -10,12 +10,10 @@
|
||||
"tauri": "tauri",
|
||||
"dev:renderer": "vite --host 127.0.0.1 --port 1420",
|
||||
"build:renderer": "vite build",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test:skin-runtime": "node src-tauri/resources/skin-runtime/tests/run-tests.mjs"
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.8.0",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.2",
|
||||
"@tauri-apps/plugin-process": "^2.3.1",
|
||||
"@tauri-apps/plugin-updater": "^2.10.1",
|
||||
"lucide-react": "^0.542.0",
|
||||
|
||||
Generated
+1
-75
@@ -490,7 +490,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "codex-x"
|
||||
version = "0.3.6"
|
||||
version = "0.3.11"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dirs 5.0.1",
|
||||
@@ -504,7 +504,6 @@ dependencies = [
|
||||
"sha2",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-dialog",
|
||||
"tauri-plugin-process",
|
||||
"tauri-plugin-single-instance",
|
||||
"tauri-plugin-updater",
|
||||
@@ -1603,12 +1602,6 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "http-range"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "21dec9db110f5f872ed9699c3ecf50cf16f423502706ba5c72462e28d3157573"
|
||||
|
||||
[[package]]
|
||||
name = "httparse"
|
||||
version = "1.10.1"
|
||||
@@ -2923,30 +2916,6 @@ dependencies = [
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rfd"
|
||||
version = "0.16.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672"
|
||||
dependencies = [
|
||||
"block2",
|
||||
"dispatch2",
|
||||
"glib-sys",
|
||||
"gobject-sys",
|
||||
"gtk-sys",
|
||||
"js-sys",
|
||||
"log",
|
||||
"objc2",
|
||||
"objc2-app-kit",
|
||||
"objc2-core-foundation",
|
||||
"objc2-foundation",
|
||||
"raw-window-handle",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ring"
|
||||
version = "0.17.14"
|
||||
@@ -3706,7 +3675,6 @@ dependencies = [
|
||||
"gtk",
|
||||
"heck 0.5.0",
|
||||
"http",
|
||||
"http-range",
|
||||
"jni 0.21.1",
|
||||
"libc",
|
||||
"log",
|
||||
@@ -3819,48 +3787,6 @@ dependencies = [
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-dialog"
|
||||
version = "2.7.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b2d3c1dbe38037e7f590cdf2492594d5ceebe031e7bc7e827509b22a999d2940"
|
||||
dependencies = [
|
||||
"log",
|
||||
"raw-window-handle",
|
||||
"rfd",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"tauri-plugin-fs",
|
||||
"thiserror 2.0.18",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-fs"
|
||||
version = "2.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7ecc274121aca0c036a2b42d1cbe83d368d348f54e0bb8a735c2b1548e8f371"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"dunce",
|
||||
"glob",
|
||||
"log",
|
||||
"objc2-foundation",
|
||||
"percent-encoding",
|
||||
"schemars 0.8.22",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_repr",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"tauri-utils",
|
||||
"thiserror 2.0.18",
|
||||
"toml 1.1.2+spec-1.1.0",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-process"
|
||||
version = "2.3.1"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "codex-x"
|
||||
version = "0.3.6"
|
||||
version = "0.3.11"
|
||||
description = "Codex Switch & Instruct desktop manager"
|
||||
authors = ["yynxxxxx"]
|
||||
license = "MIT"
|
||||
@@ -11,14 +11,11 @@ rust-version = "1.89.0"
|
||||
name = "codexx_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[features]
|
||||
windows-runtime-check = []
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2.4.0", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2.8.2", features = ["protocol-asset", "tray-icon"] }
|
||||
tauri = { version = "2.8.2", features = ["tray-icon"] }
|
||||
tauri-plugin-process = "2.3.1"
|
||||
tauri-plugin-updater = "2.10.1"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
@@ -40,5 +37,4 @@ rustls = { version = "0.23", default-features = false, features = ["ring"] }
|
||||
sha2 = "0.10"
|
||||
percent-encoding = "2.3"
|
||||
zip = { version = "2.2", default-features = false, features = ["deflate"] }
|
||||
tauri-plugin-dialog = "2"
|
||||
tauri-plugin-single-instance = "=2.4.3"
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"core:window:allow-start-dragging",
|
||||
"dialog:allow-save",
|
||||
"updater:allow-check",
|
||||
"updater:allow-download",
|
||||
"updater:allow-install",
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.7 MiB |
@@ -1,32 +0,0 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "shiina-mashiro-blossom",
|
||||
"name": "椎名真白·樱花画室",
|
||||
"brandSubtitle": "椎名真白主题",
|
||||
"tagline": "椎名真白在白樱画室里安静作画。",
|
||||
"projectPrefix": "选择项目 · ",
|
||||
"projectLabel": "◉ 选择项目",
|
||||
"statusText": "椎名真白主题运行中",
|
||||
"quote": "安静创作,也会发光",
|
||||
"image": "background.png",
|
||||
"appearance": "light",
|
||||
"surfaceOpacity": 0.62,
|
||||
"art": {
|
||||
"focusX": 0.28,
|
||||
"focusY": 0.26,
|
||||
"safeArea": "right",
|
||||
"taskMode": "ambient"
|
||||
},
|
||||
"colors": {
|
||||
"background": "#f9f4f7",
|
||||
"panel": "#fffafd",
|
||||
"panelAlt": "#fff1f7",
|
||||
"accent": "#ef7da4",
|
||||
"accentAlt": "#ffadc9",
|
||||
"secondary": "#f5c2d6",
|
||||
"highlight": "#c95f8a",
|
||||
"text": "#2a2026",
|
||||
"muted": "#8d7180",
|
||||
"line": "rgba(239, 125, 164, .26)"
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Codex Dream Skin Studio contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -1,22 +0,0 @@
|
||||
# Codex Dream Skin Runtime Notice
|
||||
|
||||
The files in this directory are adapted from Codex Dream Skin Studio:
|
||||
|
||||
- Source: https://github.com/Fei-Away/Codex-Dream-Skin
|
||||
- Pinned source commit: `5fd8af532efbaa87d2d0092297fd2d45cd56574e`
|
||||
- Upstream runtime version: `1.2.0`
|
||||
- License: MIT, reproduced in `LICENSE`
|
||||
|
||||
Codex-X embeds these runtime sources and manages them itself. It does not require
|
||||
the separate Codex Dream Skin Studio installer or its `~/.codex` engine directory.
|
||||
The Windows release also bundles Node.js 22.23.1 from nodejs.org, verified against
|
||||
SHA-256 `7df0bc9375723f4a86b3aa1b7cc73342423d9677a8df4538aca31a049e309c29`;
|
||||
its upstream license is distributed beside `node.exe`.
|
||||
|
||||
Only the upstream software source and procedurally generated abstract presets are
|
||||
included. The Arina Hashimoto preset and other assets excluded by the upstream
|
||||
notice are intentionally not distributed with Codex-X.
|
||||
|
||||
Codex Dream Skin Studio and Codex-X are unofficial projects and are not affiliated
|
||||
with, endorsed by, or sponsored by OpenAI. Codex and related trademarks belong to
|
||||
their respective owners.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,796 +0,0 @@
|
||||
((cssText, artDataUrl, themeConfig) => {
|
||||
const STATE_KEY = "__CODEX_DREAM_SKIN_STATE__";
|
||||
const DISABLED_KEY = "__CODEX_DREAM_SKIN_DISABLED__";
|
||||
const STYLE_ID = "codex-dream-skin-style";
|
||||
const CHROME_ID = "codex-dream-skin-chrome";
|
||||
const SHELL_ATTR = "data-dream-shell";
|
||||
const ART_ATTRS = [
|
||||
"data-dream-art-wide", "data-dream-art-safe", "data-dream-task-mode",
|
||||
"data-dream-art-safe-area", "data-dream-art-task-mode", "data-dream-art-aspect",
|
||||
"data-dream-art-ready",
|
||||
];
|
||||
const VERSION = __DREAM_SKIN_VERSION_JSON__;
|
||||
const STYLE_REVISION = __DREAM_SKIN_STYLE_REVISION_JSON__;
|
||||
const THEME = themeConfig && typeof themeConfig === "object" ? themeConfig : {};
|
||||
const ART = THEME.art && typeof THEME.art === "object" ? THEME.art : {};
|
||||
const ART_METADATA = THEME.artMetadata && typeof THEME.artMetadata === "object"
|
||||
? THEME.artMetadata : null;
|
||||
const ANALYSIS_CACHE_KEY = "__CODEX_DREAM_SKIN_ANALYSIS_CACHE__";
|
||||
const THEME_VARIABLES = [
|
||||
"--ds-bg", "--ds-panel", "--ds-panel-2", "--ds-green", "--ds-lime",
|
||||
"--ds-cyan", "--ds-purple", "--ds-text", "--ds-muted", "--ds-line",
|
||||
"--ds-bg-rgb", "--ds-panel-rgb", "--ds-panel-2-rgb", "--ds-accent-rgb",
|
||||
"--ds-accent-alt-rgb", "--ds-secondary-rgb", "--ds-highlight-rgb",
|
||||
"--ds-text-rgb", "--ds-muted-rgb", "--ds-line-rgb", "--ds-surface-opacity",
|
||||
"--dream-art-focus-x", "--dream-art-focus-y", "--dream-art-position",
|
||||
"--dream-skin-focus-x", "--dream-skin-focus-y", "--dream-skin-art-position",
|
||||
"--dream-skin-home-art-position",
|
||||
"--dream-skin-name", "--dream-skin-tagline", "--dream-skin-project-prefix",
|
||||
"--dream-skin-project-label",
|
||||
];
|
||||
const installToken = {};
|
||||
const existingAnalysisCache = window[ANALYSIS_CACHE_KEY];
|
||||
const analysisCache = existingAnalysisCache && typeof existingAnalysisCache.get === "function" &&
|
||||
typeof existingAnalysisCache.set === "function" ? existingAnalysisCache : new Map();
|
||||
window[ANALYSIS_CACHE_KEY] = analysisCache;
|
||||
let artAnalysis = typeof THEME.artKey === "string" ? analysisCache.get(THEME.artKey) ?? null : null;
|
||||
let analysisTimer = null;
|
||||
let samplingNativeShell = false;
|
||||
let rootObserver = null;
|
||||
const now = () => typeof performance === "object" && typeof performance.now === "function"
|
||||
? performance.now() : Date.now();
|
||||
const metrics = {
|
||||
ensureCalls: 0,
|
||||
rootPasses: 0,
|
||||
routePasses: 0,
|
||||
layoutReads: 0,
|
||||
attributeWrites: 0,
|
||||
styleWrites: 0,
|
||||
textWrites: 0,
|
||||
analysisRuns: 0,
|
||||
analysisCacheHits: artAnalysis ? 1 : 0,
|
||||
firstEnsureMs: null,
|
||||
analysisMs: null,
|
||||
};
|
||||
window[DISABLED_KEY] = false;
|
||||
|
||||
const previous = window[STATE_KEY];
|
||||
const artUrl = (() => {
|
||||
const comma = artDataUrl.indexOf(",");
|
||||
const mime = /^data:([^;,]+)/.exec(artDataUrl)?.[1] || "image/png";
|
||||
const binary = atob(artDataUrl.slice(comma + 1));
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index);
|
||||
return URL.createObjectURL(new Blob([bytes], { type: mime }));
|
||||
})();
|
||||
|
||||
if (previous?.observer) previous.observer.disconnect();
|
||||
if (previous?.rootObserver) previous.rootObserver.disconnect();
|
||||
if (previous?.resizeObserver) previous.resizeObserver.disconnect();
|
||||
if (previous?.timer) clearInterval(previous.timer);
|
||||
if (previous?.scheduler?.timeout) clearTimeout(previous.scheduler.timeout);
|
||||
if (previous?.scheduler?.frame != null && typeof cancelAnimationFrame === "function") {
|
||||
cancelAnimationFrame(previous.scheduler.frame);
|
||||
}
|
||||
if (previous?.analysisTimer) clearTimeout(previous.analysisTimer);
|
||||
if (previous?.resizeHandler) window.removeEventListener("resize", previous.resizeHandler);
|
||||
if (previous?.mediaHandler && previous?.mediaQuery) {
|
||||
try { previous.mediaQuery.removeEventListener("change", previous.mediaHandler); } catch {}
|
||||
}
|
||||
|
||||
const cssString = (value) => JSON.stringify(String(value ?? ""));
|
||||
|
||||
const setStyleProperty = (root, name, value) => {
|
||||
if (root.style.getPropertyValue(name) !== value) {
|
||||
root.style.setProperty(name, value);
|
||||
metrics.styleWrites += 1;
|
||||
}
|
||||
};
|
||||
|
||||
const setAttribute = (root, name, value) => {
|
||||
const normalized = String(value);
|
||||
if (root.getAttribute(name) !== normalized) {
|
||||
root.setAttribute(name, normalized);
|
||||
metrics.attributeWrites += 1;
|
||||
}
|
||||
};
|
||||
|
||||
const setTextContent = (node, value) => {
|
||||
if (node && node.textContent !== value) {
|
||||
node.textContent = value;
|
||||
metrics.textWrites += 1;
|
||||
}
|
||||
};
|
||||
|
||||
const parseRgb = (value) => {
|
||||
if (!value || value === "transparent") return null;
|
||||
const hex = String(value).trim().match(/^#([0-9a-f]{6})$/i);
|
||||
if (hex) {
|
||||
const number = Number.parseInt(hex[1], 16);
|
||||
return { r: number >> 16, g: (number >> 8) & 255, b: number & 255 };
|
||||
}
|
||||
const m = String(value).match(/rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)/i);
|
||||
if (!m) return null;
|
||||
return { r: Number(m[1]), g: Number(m[2]), b: Number(m[3]) };
|
||||
};
|
||||
|
||||
const clamp = (value, min, max) => Math.min(max, Math.max(min, value));
|
||||
|
||||
const rgbString = (value) => {
|
||||
const rgb = parseRgb(value);
|
||||
return rgb ? `${Math.round(rgb.r)} ${Math.round(rgb.g)} ${Math.round(rgb.b)}` : null;
|
||||
};
|
||||
|
||||
const rgbToHex = ({ r, g, b }) => `#${[r, g, b]
|
||||
.map((value) => clamp(Math.round(value), 0, 255).toString(16).padStart(2, "0"))
|
||||
.join("")}`;
|
||||
|
||||
const rgbToHsl = ({ r, g, b }) => {
|
||||
const values = [r, g, b].map((value) => value / 255);
|
||||
const max = Math.max(...values);
|
||||
const min = Math.min(...values);
|
||||
const lightness = (max + min) / 2;
|
||||
if (max === min) return { h: 0, s: 0, l: lightness };
|
||||
const delta = max - min;
|
||||
const saturation = lightness > 0.5 ? delta / (2 - max - min) : delta / (max + min);
|
||||
let hue;
|
||||
if (max === values[0]) hue = (values[1] - values[2]) / delta + (values[1] < values[2] ? 6 : 0);
|
||||
else if (max === values[1]) hue = (values[2] - values[0]) / delta + 2;
|
||||
else hue = (values[0] - values[1]) / delta + 4;
|
||||
return { h: hue * 60, s: saturation, l: lightness };
|
||||
};
|
||||
|
||||
const hslToRgb = ({ h, s, l }) => {
|
||||
const hue = ((h % 360) + 360) % 360 / 360;
|
||||
if (s === 0) {
|
||||
const neutral = Math.round(l * 255);
|
||||
return { r: neutral, g: neutral, b: neutral };
|
||||
}
|
||||
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
|
||||
const p = 2 * l - q;
|
||||
const channel = (offset) => {
|
||||
let t = hue + offset;
|
||||
if (t < 0) t += 1;
|
||||
if (t > 1) t -= 1;
|
||||
if (t < 1 / 6) return p + (q - p) * 6 * t;
|
||||
if (t < 1 / 2) return q;
|
||||
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
|
||||
return p;
|
||||
};
|
||||
return { r: channel(1 / 3) * 255, g: channel(0) * 255, b: channel(-1 / 3) * 255 };
|
||||
};
|
||||
|
||||
const luminance = ({ r, g, b }) => {
|
||||
const lin = [r, g, b].map((c) => {
|
||||
const x = c / 255;
|
||||
return x <= 0.03928 ? x / 12.92 : ((x + 0.055) / 1.055) ** 2.4;
|
||||
});
|
||||
return 0.2126 * lin[0] + 0.7152 * lin[1] + 0.0722 * lin[2];
|
||||
};
|
||||
|
||||
/** Detect Codex app light/dark shell for CSS branching. */
|
||||
const detectShellMode = () => {
|
||||
const root = document.documentElement;
|
||||
const body = document.body;
|
||||
const cls = `${root.className || ""} ${body?.className || ""}`.toLowerCase();
|
||||
|
||||
if (/\b(dark|theme-dark|appearance-dark)\b/.test(cls)) return "dark";
|
||||
if (/\b(light|theme-light|appearance-light)\b/.test(cls)) return "light";
|
||||
|
||||
const dataTheme = (
|
||||
root.getAttribute("data-theme") ||
|
||||
root.getAttribute("data-appearance") ||
|
||||
root.getAttribute("data-color-mode") ||
|
||||
body?.getAttribute("data-theme") ||
|
||||
body?.getAttribute("data-appearance") ||
|
||||
""
|
||||
).toLowerCase();
|
||||
if (dataTheme.includes("dark")) return "dark";
|
||||
if (dataTheme.includes("light")) return "light";
|
||||
|
||||
// Radios in profile menu (if present in DOM)
|
||||
const checked = document.querySelector('input[name="appearance-theme"]:checked');
|
||||
if (checked) {
|
||||
const label = (checked.getAttribute("aria-label") || checked.value || "").toLowerCase();
|
||||
if (label.includes("暗") || label.includes("dark")) return "dark";
|
||||
if (label.includes("浅") || label.includes("light")) return "light";
|
||||
if (label.includes("系统") || label.includes("system")) {
|
||||
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
||||
}
|
||||
}
|
||||
|
||||
// The skin itself declares color-scheme on :root. Once installed,
|
||||
// reading getComputedStyle(root) directly would therefore keep `auto`
|
||||
// themes locked to the previous shell mode. Temporarily remove only our
|
||||
// own root class/attribute, sample the native computed scheme, then restore
|
||||
// synchronously. Mutation records created by this probe are drained below
|
||||
// so the root observer does not schedule a redundant ensure pass.
|
||||
try {
|
||||
const hadSkin = root.classList.contains("codex-dream-skin");
|
||||
const savedShell = root.getAttribute(SHELL_ATTR);
|
||||
samplingNativeShell = true;
|
||||
if (hadSkin) root.classList.remove("codex-dream-skin");
|
||||
if (savedShell !== null) root.removeAttribute(SHELL_ATTR);
|
||||
let colorScheme = "";
|
||||
try {
|
||||
colorScheme = getComputedStyle(root).colorScheme || "";
|
||||
} finally {
|
||||
if (hadSkin) root.classList.add("codex-dream-skin");
|
||||
if (savedShell !== null) root.setAttribute(SHELL_ATTR, savedShell);
|
||||
rootObserver?.takeRecords?.();
|
||||
samplingNativeShell = false;
|
||||
}
|
||||
if (colorScheme.includes("dark") && !colorScheme.includes("light")) return "dark";
|
||||
if (colorScheme.includes("light") && !colorScheme.includes("dark")) return "light";
|
||||
} catch {
|
||||
samplingNativeShell = false;
|
||||
}
|
||||
|
||||
try {
|
||||
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
||||
} catch {}
|
||||
|
||||
// Only use surface luminance before the skin owns those surfaces. Sampling
|
||||
// our own translucent layers would create route-dependent light/dark flips.
|
||||
if (!root.classList.contains("codex-dream-skin")) {
|
||||
const samples = [
|
||||
body,
|
||||
document.querySelector("main.main-surface"),
|
||||
document.querySelector("aside.app-shell-left-panel"),
|
||||
].filter(Boolean);
|
||||
let votesLight = 0;
|
||||
let votesDark = 0;
|
||||
for (const el of samples) {
|
||||
try {
|
||||
const rgb = parseRgb(getComputedStyle(el).backgroundColor);
|
||||
if (!rgb) continue;
|
||||
const L = luminance(rgb);
|
||||
if (L >= 0.55) votesLight += 1;
|
||||
else if (L <= 0.25) votesDark += 1;
|
||||
} catch {}
|
||||
}
|
||||
if (votesLight > votesDark) return "light";
|
||||
if (votesDark > votesLight) return "dark";
|
||||
}
|
||||
return "light";
|
||||
};
|
||||
|
||||
const makeAdaptivePalette = (sample, shell) => {
|
||||
const source = sample || { r: 108, g: 126, b: 136 };
|
||||
const hsl = rgbToHsl(source);
|
||||
const hue = hsl.s < 0.12 ? 214 : hsl.h;
|
||||
const saturation = clamp(hsl.s, 0.38, 0.72);
|
||||
const accent = hslToRgb({ h: hue, s: saturation, l: shell === "light" ? 0.42 : 0.66 });
|
||||
const accentAlt = hslToRgb({ h: hue + 12, s: saturation * 0.82, l: shell === "light" ? 0.52 : 0.73 });
|
||||
const secondary = hslToRgb({ h: hue - 24, s: saturation * 0.64, l: shell === "light" ? 0.56 : 0.62 });
|
||||
const highlight = hslToRgb({ h: hue + 24, s: saturation * 0.76, l: shell === "light" ? 0.36 : 0.58 });
|
||||
const neutral = (lightness, chroma = 0.08) => rgbToHex(hslToRgb({ h: hue, s: chroma, l: lightness }));
|
||||
return shell === "light" ? {
|
||||
background: neutral(0.965, 0.07),
|
||||
panel: neutral(0.987, 0.035),
|
||||
panelAlt: neutral(0.945, 0.09),
|
||||
accent: rgbToHex(accent),
|
||||
accentAlt: rgbToHex(accentAlt),
|
||||
secondary: rgbToHex(secondary),
|
||||
highlight: rgbToHex(highlight),
|
||||
text: neutral(0.13, 0.10),
|
||||
muted: neutral(0.42, 0.08),
|
||||
line: `rgba(${Math.round(accent.r)}, ${Math.round(accent.g)}, ${Math.round(accent.b)}, .24)`,
|
||||
} : {
|
||||
background: neutral(0.055, 0.045),
|
||||
panel: neutral(0.085, 0.04),
|
||||
panelAlt: neutral(0.125, 0.05),
|
||||
accent: rgbToHex(accent),
|
||||
accentAlt: rgbToHex(accentAlt),
|
||||
secondary: rgbToHex(secondary),
|
||||
highlight: rgbToHex(highlight),
|
||||
text: neutral(0.93, 0.025),
|
||||
muted: neutral(0.69, 0.03),
|
||||
line: `rgba(${Math.round(accent.r)}, ${Math.round(accent.g)}, ${Math.round(accent.b)}, .28)`,
|
||||
};
|
||||
};
|
||||
|
||||
const resolvedShell = () => {
|
||||
if (THEME.appearance === "light" || THEME.appearance === "dark") return THEME.appearance;
|
||||
// Image luminance may tune accents and scrims, but auto appearance follows
|
||||
// Codex/ChatGPT (or the OS fallback) so a bright wallpaper cannot flip a
|
||||
// native dark session back to a light shell after analysis.
|
||||
return detectShellMode();
|
||||
};
|
||||
|
||||
const applyTheme = (root, shell) => {
|
||||
const colors = THEME.colors || {};
|
||||
const explicit = new Set(Array.isArray(THEME.explicitColorKeys) ? THEME.explicitColorKeys : []);
|
||||
const adaptive = makeAdaptivePalette(artAnalysis?.accentRgb, shell);
|
||||
const legacyLight = !THEME.appearance && shell === "light";
|
||||
const structural = new Set(["background", "panel", "panelAlt", "text", "muted"]);
|
||||
const pick = (name) => {
|
||||
const allowExplicit = explicit.has(name) && !(legacyLight && structural.has(name));
|
||||
return allowExplicit && typeof colors[name] === "string" ? colors[name] : adaptive[name];
|
||||
};
|
||||
const accent = pick("accent");
|
||||
const accentAlt = explicit.has("accentAlt") ? pick("accentAlt") : (explicit.has("accent") ? accent : adaptive.accentAlt);
|
||||
const variables = {
|
||||
"--ds-bg": pick("background"),
|
||||
"--ds-panel": pick("panel"),
|
||||
"--ds-panel-2": pick("panelAlt"),
|
||||
"--ds-green": accent,
|
||||
"--ds-lime": accentAlt,
|
||||
"--ds-cyan": pick("secondary"),
|
||||
"--ds-purple": pick("highlight"),
|
||||
"--ds-text": pick("text"),
|
||||
"--ds-muted": pick("muted"),
|
||||
"--ds-line": explicit.has("line") && typeof colors.line === "string" ? colors.line : adaptive.line,
|
||||
};
|
||||
const surfaceOpacity = Number.isFinite(Number(THEME.surfaceOpacity))
|
||||
? clamp(Number(THEME.surfaceOpacity), 0.35, 1)
|
||||
: 1;
|
||||
setStyleProperty(root, "--ds-surface-opacity", surfaceOpacity.toFixed(2));
|
||||
|
||||
for (const [name, value] of Object.entries(variables)) {
|
||||
if (typeof value === "string" && value) setStyleProperty(root, name, value);
|
||||
}
|
||||
const rgbVariables = {
|
||||
"--ds-bg-rgb": variables["--ds-bg"],
|
||||
"--ds-panel-rgb": variables["--ds-panel"],
|
||||
"--ds-panel-2-rgb": variables["--ds-panel-2"],
|
||||
"--ds-accent-rgb": variables["--ds-green"],
|
||||
"--ds-accent-alt-rgb": variables["--ds-lime"],
|
||||
"--ds-secondary-rgb": variables["--ds-cyan"],
|
||||
"--ds-highlight-rgb": variables["--ds-purple"],
|
||||
"--ds-text-rgb": variables["--ds-text"],
|
||||
"--ds-muted-rgb": variables["--ds-muted"],
|
||||
"--ds-line-rgb": variables["--ds-line"],
|
||||
};
|
||||
for (const [name, value] of Object.entries(rgbVariables)) {
|
||||
const rgb = rgbString(value);
|
||||
if (rgb) setStyleProperty(root, name, rgb);
|
||||
}
|
||||
setStyleProperty(root, "--dream-skin-name", cssString(THEME.name || "Codex Dream Skin"));
|
||||
setStyleProperty(root, "--dream-skin-tagline", cssString(THEME.tagline || "Make something wonderful."));
|
||||
setStyleProperty(root, "--dream-skin-project-prefix", cssString(THEME.projectPrefix || "选择项目 · "));
|
||||
setStyleProperty(root, "--dream-skin-project-label", cssString(THEME.projectLabel || "◉ 选择项目"));
|
||||
};
|
||||
|
||||
const applyArtMetadata = (root) => {
|
||||
const profile = artAnalysis || ART_METADATA;
|
||||
const inferredSafe = profile?.safeArea || "center";
|
||||
const safeArea = ART.safeArea && ART.safeArea !== "auto" ? ART.safeArea : inferredSafe;
|
||||
const canonicalSafe = ["left", "right", "center", "none"].includes(safeArea)
|
||||
? safeArea : "center";
|
||||
const focusX = typeof ART.focusX === "number" ? ART.focusX
|
||||
: profile?.focusX ?? (safeArea === "left" ? 0.72 : safeArea === "right" ? 0.28 : 0.5);
|
||||
const focusY = typeof ART.focusY === "number" ? ART.focusY : profile?.focusY ?? 0.5;
|
||||
const explicitFocusY = typeof ART.focusY === "number";
|
||||
const taskMode = ART.taskMode && ART.taskMode !== "auto"
|
||||
? ART.taskMode : profile?.taskMode || "ambient";
|
||||
const wide = profile?.wide || false;
|
||||
const aspect = profile?.aspect || "unknown";
|
||||
const homeFocusY = !explicitFocusY && !wide && ["wide", "landscape"].includes(aspect)
|
||||
? Math.min(focusY, 0.28) : focusY;
|
||||
const focusXValue = `${(clamp(focusX, 0, 1) * 100).toFixed(2)}%`;
|
||||
const focusYValue = `${(clamp(focusY, 0, 1) * 100).toFixed(2)}%`;
|
||||
const homeFocusYValue = `${(clamp(homeFocusY, 0, 1) * 100).toFixed(2)}%`;
|
||||
|
||||
setAttribute(root, "data-dream-art-wide", wide ? "true" : "false");
|
||||
setAttribute(root, "data-dream-art-safe", canonicalSafe);
|
||||
setAttribute(root, "data-dream-task-mode", taskMode);
|
||||
setAttribute(root, "data-dream-art-safe-area", safeArea);
|
||||
setAttribute(root, "data-dream-art-task-mode", taskMode);
|
||||
setAttribute(root, "data-dream-art-aspect", aspect);
|
||||
setAttribute(root, "data-dream-art-ready", artAnalysis ? "true" : "false");
|
||||
setStyleProperty(root, "--dream-art-focus-x", focusXValue);
|
||||
setStyleProperty(root, "--dream-art-focus-y", focusYValue);
|
||||
setStyleProperty(root, "--dream-art-position", `${focusXValue} ${focusYValue}`);
|
||||
setStyleProperty(root, "--dream-skin-focus-x", focusXValue);
|
||||
setStyleProperty(root, "--dream-skin-focus-y", focusYValue);
|
||||
setStyleProperty(root, "--dream-skin-art-position", `${focusXValue} ${focusYValue}`);
|
||||
setStyleProperty(root, "--dream-skin-home-art-position", `${focusXValue} ${homeFocusYValue}`);
|
||||
};
|
||||
|
||||
const analyzeArt = () => new Promise((resolve) => {
|
||||
const startedAt = now();
|
||||
metrics.analysisRuns += 1;
|
||||
if (typeof window.Image !== "function" || !document?.createElement) {
|
||||
metrics.analysisMs = Number((now() - startedAt).toFixed(3));
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
const image = new window.Image();
|
||||
let settled = false;
|
||||
const finish = (value) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
if (analysisTimer) clearTimeout(analysisTimer);
|
||||
analysisTimer = null;
|
||||
metrics.analysisMs = Number((now() - startedAt).toFixed(3));
|
||||
resolve(value);
|
||||
};
|
||||
analysisTimer = setTimeout(() => finish(null), 6000);
|
||||
image.onerror = () => finish(null);
|
||||
image.onload = () => {
|
||||
try {
|
||||
const ratio = image.naturalWidth / image.naturalHeight;
|
||||
if (!Number.isFinite(ratio) || ratio <= 0) throw new Error("Invalid image dimensions");
|
||||
const maxDimension = 96;
|
||||
const width = Math.max(16, Math.round(ratio >= 1 ? maxDimension : maxDimension * ratio));
|
||||
const height = Math.max(16, Math.round(ratio >= 1 ? maxDimension / ratio : maxDimension));
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
const context = canvas.getContext?.("2d", { willReadFrequently: true });
|
||||
if (!context) throw new Error("Canvas is unavailable");
|
||||
context.drawImage(image, 0, 0, width, height);
|
||||
const data = context.getImageData(0, 0, width, height).data;
|
||||
const samples = new Array(width * height);
|
||||
const bins = Array.from({ length: 24 }, () => ({ weight: 0, r: 0, g: 0, b: 0 }));
|
||||
let lightTotal = 0;
|
||||
let count = 0;
|
||||
|
||||
for (let y = 0; y < height; y += 1) {
|
||||
for (let x = 0; x < width; x += 1) {
|
||||
const offset = (y * width + x) * 4;
|
||||
if (data[offset + 3] < 32) continue;
|
||||
const rgb = { r: data[offset], g: data[offset + 1], b: data[offset + 2] };
|
||||
const light = (0.2126 * rgb.r + 0.7152 * rgb.g + 0.0722 * rgb.b) / 255;
|
||||
const hsl = rgbToHsl(rgb);
|
||||
samples[y * width + x] = { light, saturation: hsl.s };
|
||||
lightTotal += light;
|
||||
count += 1;
|
||||
if (hsl.s >= 0.16 && hsl.l >= 0.16 && hsl.l <= 0.86) {
|
||||
const bin = bins[Math.min(23, Math.floor(hsl.h / 15))];
|
||||
const weight = hsl.s * (1 - Math.abs(hsl.l - 0.52) * 0.85);
|
||||
bin.weight += weight;
|
||||
bin.r += rgb.r * weight;
|
||||
bin.g += rgb.g * weight;
|
||||
bin.b += rgb.b * weight;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!count) throw new Error("Image has no visible pixels");
|
||||
const brightness = lightTotal / count;
|
||||
const information = (start, end) => {
|
||||
let total = 0;
|
||||
let totalSquared = 0;
|
||||
let edges = 0;
|
||||
let edgeCount = 0;
|
||||
let pixels = 0;
|
||||
for (let y = 0; y < height; y += 1) {
|
||||
for (let x = start; x < end; x += 1) {
|
||||
const sample = samples[y * width + x];
|
||||
if (!sample) continue;
|
||||
total += sample.light;
|
||||
totalSquared += sample.light * sample.light;
|
||||
pixels += 1;
|
||||
const previous = x > start ? samples[y * width + x - 1] : null;
|
||||
const above = y > 0 ? samples[(y - 1) * width + x] : null;
|
||||
if (previous) { edges += Math.abs(sample.light - previous.light); edgeCount += 1; }
|
||||
if (above) { edges += Math.abs(sample.light - above.light); edgeCount += 1; }
|
||||
}
|
||||
}
|
||||
const mean = pixels ? total / pixels : 0;
|
||||
const variance = pixels ? Math.max(0, totalSquared / pixels - mean * mean) : 1;
|
||||
return Math.sqrt(variance) * 0.58 + (edgeCount ? edges / edgeCount : 1) * 0.42;
|
||||
};
|
||||
const zoneWidth = Math.max(1, Math.floor(width * 0.38));
|
||||
const leftInformation = information(0, zoneWidth);
|
||||
const rightInformation = information(width - zoneWidth, width);
|
||||
let safeArea = "center";
|
||||
if (leftInformation < rightInformation * 0.86) safeArea = "left";
|
||||
else if (rightInformation < leftInformation * 0.86) safeArea = "right";
|
||||
|
||||
let saliencyTotal = 0;
|
||||
let saliencyX = 0;
|
||||
let saliencyY = 0;
|
||||
for (let y = 0; y < height; y += 1) {
|
||||
for (let x = 0; x < width; x += 1) {
|
||||
const sample = samples[y * width + x];
|
||||
if (!sample) continue;
|
||||
const previous = x > 0 ? samples[y * width + x - 1] : null;
|
||||
const above = y > 0 ? samples[(y - 1) * width + x] : null;
|
||||
const edge = (previous ? Math.abs(sample.light - previous.light) : 0) +
|
||||
(above ? Math.abs(sample.light - above.light) : 0);
|
||||
const weight = 0.01 + Math.abs(sample.light - brightness) * 0.48 +
|
||||
sample.saturation * 0.34 + edge * 0.28;
|
||||
saliencyTotal += weight;
|
||||
saliencyX += (x + 0.5) / width * weight;
|
||||
saliencyY += (y + 0.5) / height * weight;
|
||||
}
|
||||
}
|
||||
let focusX = saliencyTotal ? saliencyX / saliencyTotal : 0.5;
|
||||
let focusY = saliencyTotal ? saliencyY / saliencyTotal : 0.5;
|
||||
if (safeArea === "left") focusX = Math.max(0.64, focusX);
|
||||
if (safeArea === "right") focusX = Math.min(0.36, focusX);
|
||||
focusX = clamp(focusX, 0.12, 0.88);
|
||||
focusY = clamp(focusY, 0.18, 0.82);
|
||||
|
||||
const accentBin = bins.reduce((best, candidate) => candidate.weight > best.weight ? candidate : best, bins[0]);
|
||||
const accentRgb = accentBin.weight > 0 ? {
|
||||
r: accentBin.r / accentBin.weight,
|
||||
g: accentBin.g / accentBin.weight,
|
||||
b: accentBin.b / accentBin.weight,
|
||||
} : null;
|
||||
const aspect = ratio >= 2.25 ? "ultrawide" : ratio >= 1.45 ? "wide"
|
||||
: ratio >= 1.08 ? "landscape" : ratio >= 0.9 ? "square" : "portrait";
|
||||
finish({
|
||||
width: image.naturalWidth,
|
||||
height: image.naturalHeight,
|
||||
ratio,
|
||||
wide: ratio >= 1.75,
|
||||
aspect,
|
||||
brightness,
|
||||
shell: brightness >= 0.58 ? "light" : "dark",
|
||||
safeArea,
|
||||
focusX,
|
||||
focusY,
|
||||
taskMode: ratio >= 2.25 ? "banner" : "ambient",
|
||||
accentRgb,
|
||||
});
|
||||
} catch {
|
||||
finish(null);
|
||||
}
|
||||
};
|
||||
image.src = artUrl;
|
||||
});
|
||||
|
||||
let chromeParts = null;
|
||||
let observedShellMain = null;
|
||||
let resizeObserver = null;
|
||||
|
||||
const ensureStyle = (root) => {
|
||||
let style = document.getElementById(STYLE_ID);
|
||||
if (!style) {
|
||||
style = document.createElement("style");
|
||||
style.id = STYLE_ID;
|
||||
style.textContent = cssText;
|
||||
style.dataset.dreamSkinVersion = VERSION;
|
||||
(document.head || root).appendChild(style);
|
||||
} else if (style.dataset.dreamSkinStyleRevision !== STYLE_REVISION) {
|
||||
style.textContent = cssText;
|
||||
}
|
||||
style.dataset.dreamSkinVersion = VERSION;
|
||||
style.dataset.dreamSkinStyleRevision = STYLE_REVISION;
|
||||
return style;
|
||||
};
|
||||
|
||||
const applyRootState = (root) => {
|
||||
metrics.rootPasses += 1;
|
||||
ensureStyle(root);
|
||||
const shell = resolvedShell();
|
||||
setAttribute(root, SHELL_ATTR, shell);
|
||||
setStyleProperty(root, "--dream-skin-art", `url("${artUrl}")`);
|
||||
applyTheme(root, shell);
|
||||
applyArtMetadata(root);
|
||||
root.classList.add("codex-dream-skin");
|
||||
return shell;
|
||||
};
|
||||
|
||||
const syncRouteState = (shell, { layout = false } = {}) => {
|
||||
metrics.routePasses += 1;
|
||||
const root = document.documentElement;
|
||||
if (!root) return;
|
||||
shell ||= root.getAttribute(SHELL_ATTR) || resolvedShell();
|
||||
const shellMain = document.querySelector("main.main-surface") || document.querySelector("main");
|
||||
const homeIndicator = document.querySelector('[data-testid="home-icon"]');
|
||||
const home = homeIndicator?.closest('[role="main"]') ||
|
||||
[...document.querySelectorAll('[role="main"]')].find((candidate) =>
|
||||
candidate.querySelector('[data-feature="game-source"]') &&
|
||||
candidate.querySelector('[data-codex-composer-root]')) || null;
|
||||
for (const candidate of document.querySelectorAll('[role="main"].dream-skin-home')) {
|
||||
if (candidate !== home) candidate.classList.remove("dream-skin-home");
|
||||
}
|
||||
if (home) home.classList.add("dream-skin-home");
|
||||
const homeUtilityBars = new Set(home
|
||||
? home.querySelectorAll('[class*="_homeUtilityBar_"]')
|
||||
: []);
|
||||
for (const candidate of document.querySelectorAll(".dream-skin-home-utility")) {
|
||||
if (!homeUtilityBars.has(candidate)) candidate.classList.remove("dream-skin-home-utility");
|
||||
}
|
||||
for (const candidate of homeUtilityBars) candidate.classList.add("dream-skin-home-utility");
|
||||
|
||||
if (!shellMain || !document.body) return;
|
||||
if (observedShellMain !== shellMain) {
|
||||
resizeObserver?.disconnect();
|
||||
resizeObserver?.observe(shellMain);
|
||||
observedShellMain = shellMain;
|
||||
layout = true;
|
||||
}
|
||||
shellMain.classList.toggle("dream-skin-home-shell", Boolean(home));
|
||||
let chrome = document.getElementById(CHROME_ID);
|
||||
let created = false;
|
||||
if (!chrome || chrome.parentElement !== document.body) {
|
||||
chrome?.remove();
|
||||
chrome = document.createElement("div");
|
||||
chrome.id = CHROME_ID;
|
||||
chrome.setAttribute("aria-hidden", "true");
|
||||
chrome.innerHTML = `
|
||||
<div class="dream-skin-brand">
|
||||
<span class="dream-skin-portal-mark">◉</span>
|
||||
<span><b></b><small></small></span>
|
||||
</div>
|
||||
<div class="dream-skin-status"><i></i><span></span></div>
|
||||
<div class="dream-skin-quote"></div>
|
||||
<div class="dream-skin-particles"><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i></div>
|
||||
<div class="dream-skin-orbit"></div>`;
|
||||
document.body.appendChild(chrome);
|
||||
created = true;
|
||||
chromeParts = null;
|
||||
}
|
||||
if (!chromeParts || chromeParts.chrome !== chrome) {
|
||||
chromeParts = {
|
||||
chrome,
|
||||
name: chrome.querySelector(".dream-skin-brand b"),
|
||||
subtitle: chrome.querySelector(".dream-skin-brand small"),
|
||||
status: chrome.querySelector(".dream-skin-status span"),
|
||||
quote: chrome.querySelector(".dream-skin-quote"),
|
||||
};
|
||||
}
|
||||
setTextContent(chromeParts.name, THEME.name || "Codex Dream Skin");
|
||||
setTextContent(chromeParts.subtitle, THEME.brandSubtitle || "CODEX DREAM SKIN");
|
||||
setTextContent(chromeParts.status, THEME.statusText || "DREAM SKIN ONLINE");
|
||||
setTextContent(chromeParts.quote, THEME.quote || "MAKE SOMETHING WONDERFUL");
|
||||
if (layout || created) {
|
||||
metrics.layoutReads += 1;
|
||||
const shellBox = shellMain.getBoundingClientRect();
|
||||
setStyleProperty(chrome, "left", `${Math.round(shellBox.left)}px`);
|
||||
setStyleProperty(chrome, "top", `${Math.round(shellBox.top)}px`);
|
||||
setStyleProperty(chrome, "width", `${Math.round(shellBox.width)}px`);
|
||||
setStyleProperty(chrome, "height", `${Math.round(shellBox.height)}px`);
|
||||
}
|
||||
chrome.classList.toggle("dream-skin-home-shell", Boolean(home));
|
||||
if (chrome.dataset.dreamShell !== shell) {
|
||||
chrome.dataset.dreamShell = shell;
|
||||
metrics.attributeWrites += 1;
|
||||
}
|
||||
};
|
||||
|
||||
const ensure = ({ root: rootPass = true, route = true, layout = true } = {}) => {
|
||||
if (window[DISABLED_KEY]) return;
|
||||
const root = document.documentElement;
|
||||
if (!root) return;
|
||||
metrics.ensureCalls += 1;
|
||||
const shell = rootPass ? applyRootState(root) : null;
|
||||
if (route) syncRouteState(shell, { layout });
|
||||
};
|
||||
|
||||
const cleanup = () => {
|
||||
const state = window[STATE_KEY];
|
||||
if (state?.installToken !== installToken) return false;
|
||||
window[DISABLED_KEY] = true;
|
||||
document.documentElement?.classList.remove("codex-dream-skin");
|
||||
document.documentElement?.removeAttribute(SHELL_ATTR);
|
||||
for (const name of ART_ATTRS) document.documentElement?.removeAttribute(name);
|
||||
document.documentElement?.style.removeProperty("--dream-skin-art");
|
||||
for (const name of THEME_VARIABLES) document.documentElement?.style.removeProperty(name);
|
||||
document.querySelectorAll(".dream-skin-home").forEach((node) => node.classList.remove("dream-skin-home"));
|
||||
document.querySelectorAll(".dream-skin-home-shell").forEach((node) => node.classList.remove("dream-skin-home-shell"));
|
||||
document.querySelectorAll(".dream-skin-home-utility").forEach((node) => node.classList.remove("dream-skin-home-utility"));
|
||||
document.getElementById(STYLE_ID)?.remove();
|
||||
document.getElementById(CHROME_ID)?.remove();
|
||||
state?.observer?.disconnect();
|
||||
state?.rootObserver?.disconnect();
|
||||
state?.resizeObserver?.disconnect();
|
||||
if (state?.timer) clearInterval(state.timer);
|
||||
if (state?.scheduler?.timeout) clearTimeout(state.scheduler.timeout);
|
||||
if (state?.scheduler?.frame != null && typeof cancelAnimationFrame === "function") {
|
||||
cancelAnimationFrame(state.scheduler.frame);
|
||||
}
|
||||
if (analysisTimer) clearTimeout(analysisTimer);
|
||||
if (state?.resizeHandler) window.removeEventListener("resize", state.resizeHandler);
|
||||
if (state?.mediaHandler && state?.mediaQuery) {
|
||||
try { state.mediaQuery.removeEventListener("change", state.mediaHandler); } catch {}
|
||||
}
|
||||
if (state?.artUrl) URL.revokeObjectURL(state.artUrl);
|
||||
delete window[STATE_KEY];
|
||||
return true;
|
||||
};
|
||||
|
||||
const scheduler = { timeout: null, frame: null, root: false, route: false, layout: false };
|
||||
const flushScheduledEnsure = () => {
|
||||
if (scheduler.frame !== null && typeof cancelAnimationFrame === "function") {
|
||||
cancelAnimationFrame(scheduler.frame);
|
||||
}
|
||||
if (scheduler.timeout) clearTimeout(scheduler.timeout);
|
||||
scheduler.frame = null;
|
||||
scheduler.timeout = null;
|
||||
const pending = { root: scheduler.root, route: scheduler.route, layout: scheduler.layout };
|
||||
scheduler.root = false;
|
||||
scheduler.route = false;
|
||||
scheduler.layout = false;
|
||||
ensure(pending);
|
||||
};
|
||||
const scheduleEnsure = ({ root = false, route = true, layout = false } = {}) => {
|
||||
scheduler.root ||= root;
|
||||
scheduler.route ||= route;
|
||||
scheduler.layout ||= layout;
|
||||
if (scheduler.timeout || scheduler.frame !== null) return;
|
||||
if (typeof requestAnimationFrame === "function") {
|
||||
scheduler.frame = requestAnimationFrame(flushScheduledEnsure);
|
||||
scheduler.timeout = setTimeout(flushScheduledEnsure, 96);
|
||||
} else {
|
||||
scheduler.timeout = setTimeout(flushScheduledEnsure, 64);
|
||||
}
|
||||
};
|
||||
const observer = new MutationObserver(() => scheduleEnsure({ route: true }));
|
||||
rootObserver = new MutationObserver(() => {
|
||||
if (samplingNativeShell) return;
|
||||
scheduleEnsure({ root: true, route: true });
|
||||
});
|
||||
const resizeHandler = () => scheduleEnsure({ route: true, layout: true });
|
||||
if (typeof ResizeObserver === "function") {
|
||||
resizeObserver = new ResizeObserver(() => scheduleEnsure({ route: true, layout: true }));
|
||||
}
|
||||
|
||||
let mediaQuery = null;
|
||||
let mediaHandler = null;
|
||||
try {
|
||||
mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
mediaHandler = () => scheduleEnsure({ root: true, route: true });
|
||||
} catch {}
|
||||
|
||||
window[STATE_KEY] = {
|
||||
ensure,
|
||||
cleanup,
|
||||
observer,
|
||||
rootObserver,
|
||||
resizeObserver,
|
||||
timer: null,
|
||||
scheduler,
|
||||
resizeHandler,
|
||||
mediaQuery,
|
||||
mediaHandler,
|
||||
artUrl,
|
||||
installToken,
|
||||
analysis: artAnalysis,
|
||||
artMetadata: ART_METADATA,
|
||||
metrics,
|
||||
version: VERSION,
|
||||
themeId: THEME.id || "custom",
|
||||
detectShellMode,
|
||||
};
|
||||
const firstEnsureStartedAt = now();
|
||||
ensure({ layout: !previous || !document.getElementById(CHROME_ID) });
|
||||
metrics.firstEnsureMs = Number((now() - firstEnsureStartedAt).toFixed(3));
|
||||
if (previous?.artUrl && previous.artUrl !== artUrl) URL.revokeObjectURL(previous.artUrl);
|
||||
|
||||
observer.observe(document.documentElement, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
rootObserver.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ["class", "data-theme", "data-appearance", "data-color-mode", "style"],
|
||||
});
|
||||
if (document.body) {
|
||||
rootObserver.observe(document.body, {
|
||||
attributes: true,
|
||||
attributeFilter: ["class", "data-theme", "data-appearance", "data-color-mode", "style"],
|
||||
});
|
||||
}
|
||||
const timer = setInterval(() => ensure(), 4000);
|
||||
window[STATE_KEY].timer = timer;
|
||||
window.addEventListener("resize", resizeHandler, { passive: true });
|
||||
if (mediaHandler && mediaQuery) {
|
||||
mediaQuery.addEventListener("change", mediaHandler);
|
||||
}
|
||||
const analysisPromise = artAnalysis ? Promise.resolve(null) : analyzeArt();
|
||||
window[STATE_KEY].analysisTimer = analysisTimer;
|
||||
analysisPromise.then((analysis) => {
|
||||
const state = window[STATE_KEY];
|
||||
if (!analysis || state?.installToken !== installToken || window[DISABLED_KEY]) return;
|
||||
artAnalysis = analysis;
|
||||
state.analysis = analysis;
|
||||
if (typeof THEME.artKey === "string") {
|
||||
analysisCache.set(THEME.artKey, analysis);
|
||||
while (analysisCache.size > 8) analysisCache.delete(analysisCache.keys().next().value);
|
||||
}
|
||||
ensure({ root: true, route: false, layout: false });
|
||||
}).catch(() => {});
|
||||
return {
|
||||
installed: true,
|
||||
version: VERSION,
|
||||
themeId: THEME.id || "custom",
|
||||
shell: resolvedShell(),
|
||||
analysis: artAnalysis,
|
||||
};
|
||||
})(__DREAM_SKIN_CSS_JSON__, __DREAM_SKIN_ART_JSON__, __DREAM_SKIN_THEME_JSON__)
|
||||
@@ -1,131 +0,0 @@
|
||||
const SOF_MARKERS = new Set([
|
||||
0xc0, 0xc1, 0xc2, 0xc3, 0xc5, 0xc6, 0xc7,
|
||||
0xc9, 0xca, 0xcb, 0xcd, 0xce, 0xcf,
|
||||
]);
|
||||
export const MAX_IMAGE_DIMENSION = 16384;
|
||||
export const MAX_IMAGE_PIXELS = 50_000_000;
|
||||
|
||||
function uint16be(bytes, offset) {
|
||||
return bytes[offset] * 256 + bytes[offset + 1];
|
||||
}
|
||||
|
||||
function uint16le(bytes, offset) {
|
||||
return bytes[offset] + bytes[offset + 1] * 256;
|
||||
}
|
||||
|
||||
function uint24le(bytes, offset) {
|
||||
return bytes[offset] + bytes[offset + 1] * 256 + bytes[offset + 2] * 65536;
|
||||
}
|
||||
|
||||
function uint32be(bytes, offset) {
|
||||
return bytes[offset] * 0x1000000 + bytes[offset + 1] * 0x10000 +
|
||||
bytes[offset + 2] * 0x100 + bytes[offset + 3];
|
||||
}
|
||||
|
||||
function uint32le(bytes, offset) {
|
||||
return bytes[offset] + bytes[offset + 1] * 0x100 + bytes[offset + 2] * 0x10000 +
|
||||
bytes[offset + 3] * 0x1000000;
|
||||
}
|
||||
|
||||
function ascii(bytes, offset, length) {
|
||||
return String.fromCharCode(...bytes.subarray(offset, offset + length));
|
||||
}
|
||||
|
||||
function pngDimensions(bytes) {
|
||||
const signature = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
|
||||
if (bytes.length < 24 || signature.some((value, index) => bytes[index] !== value) ||
|
||||
uint32be(bytes, 8) !== 13 || ascii(bytes, 12, 4) !== "IHDR") return null;
|
||||
const width = uint32be(bytes, 16);
|
||||
const height = uint32be(bytes, 20);
|
||||
return width > 0 && height > 0 ? { width, height } : null;
|
||||
}
|
||||
|
||||
function jpegDimensions(bytes) {
|
||||
if (bytes.length < 12 || bytes[0] !== 0xff || bytes[1] !== 0xd8) return null;
|
||||
let offset = 2;
|
||||
while (offset + 9 < bytes.length) {
|
||||
if (bytes[offset] !== 0xff) {
|
||||
offset += 1;
|
||||
continue;
|
||||
}
|
||||
while (offset < bytes.length && bytes[offset] === 0xff) offset += 1;
|
||||
const marker = bytes[offset++];
|
||||
if (marker === 0xd9 || marker === 0xda) break;
|
||||
if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd8)) continue;
|
||||
if (offset + 2 > bytes.length) break;
|
||||
const length = uint16be(bytes, offset);
|
||||
if (length < 2 || offset + length > bytes.length) break;
|
||||
if (SOF_MARKERS.has(marker) && length >= 7) {
|
||||
const height = uint16be(bytes, offset + 3);
|
||||
const width = uint16be(bytes, offset + 5);
|
||||
return width > 0 && height > 0 ? { width, height } : null;
|
||||
}
|
||||
offset += length;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function webpDimensions(bytes) {
|
||||
if (bytes.length < 20 || ascii(bytes, 0, 4) !== "RIFF" || ascii(bytes, 8, 4) !== "WEBP") {
|
||||
return null;
|
||||
}
|
||||
const riffEnd = Math.min(bytes.length, uint32le(bytes, 4) + 8);
|
||||
let offset = 12;
|
||||
while (offset + 8 <= riffEnd) {
|
||||
const type = ascii(bytes, offset, 4);
|
||||
const size = bytes[offset + 4] + bytes[offset + 5] * 256 +
|
||||
bytes[offset + 6] * 65536 + bytes[offset + 7] * 0x1000000;
|
||||
const data = offset + 8;
|
||||
if (data + size > riffEnd) break;
|
||||
if (type === "VP8X" && size >= 10) {
|
||||
return { width: uint24le(bytes, data + 4) + 1, height: uint24le(bytes, data + 7) + 1 };
|
||||
}
|
||||
if (type === "VP8L" && size >= 5 && bytes[data] === 0x2f) {
|
||||
const width = 1 + bytes[data + 1] + ((bytes[data + 2] & 0x3f) << 8);
|
||||
const height = 1 + (bytes[data + 2] >> 6) + (bytes[data + 3] << 2) +
|
||||
((bytes[data + 4] & 0x0f) << 10);
|
||||
return { width, height };
|
||||
}
|
||||
if (type === "VP8 " && size >= 10 && bytes[data + 3] === 0x9d &&
|
||||
bytes[data + 4] === 0x01 && bytes[data + 5] === 0x2a) {
|
||||
return {
|
||||
width: uint16le(bytes, data + 6) & 0x3fff,
|
||||
height: uint16le(bytes, data + 8) & 0x3fff,
|
||||
};
|
||||
}
|
||||
offset = data + size + (size % 2);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function classifyImageDimensions({ width, height }) {
|
||||
const ratio = width / height;
|
||||
if (
|
||||
!Number.isSafeInteger(width) || !Number.isSafeInteger(height)
|
||||
|| width < 1 || height < 1
|
||||
|| width > MAX_IMAGE_DIMENSION || height > MAX_IMAGE_DIMENSION
|
||||
|| width * height > MAX_IMAGE_PIXELS
|
||||
|| !Number.isFinite(ratio)
|
||||
) return null;
|
||||
const aspect = ratio >= 2.25 ? "ultrawide" : ratio >= 1.45 ? "wide"
|
||||
: ratio >= 1.08 ? "landscape" : ratio >= 0.9 ? "square" : "portrait";
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
ratio,
|
||||
wide: ratio >= 1.75,
|
||||
aspect,
|
||||
taskMode: ratio >= 2.25 ? "banner" : "ambient",
|
||||
};
|
||||
}
|
||||
|
||||
export function readImageMetadata(value, extension = "") {
|
||||
const bytes = value instanceof Uint8Array ? value : new Uint8Array(value);
|
||||
const normalized = extension.toLowerCase();
|
||||
let dimensions = null;
|
||||
if (normalized === ".png" || bytes[0] === 0x89) dimensions = pngDimensions(bytes);
|
||||
else if (normalized === ".jpg" || normalized === ".jpeg" ||
|
||||
(bytes[0] === 0xff && bytes[1] === 0xd8)) dimensions = jpegDimensions(bytes);
|
||||
else if (normalized === ".webp" || ascii(bytes, 8, 4) === "WEBP") dimensions = webpDimensions(bytes);
|
||||
return dimensions ? classifyImageDimensions(dimensions) : null;
|
||||
}
|
||||
@@ -1,942 +0,0 @@
|
||||
import fs from "node:fs/promises";
|
||||
import { constants as fsConstants, watch as watchFs } from "node:fs";
|
||||
import { createHash } from "node:crypto";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { readImageMetadata } from "./image-metadata.mjs";
|
||||
|
||||
const scriptPath = fileURLToPath(import.meta.url);
|
||||
const here = path.dirname(scriptPath);
|
||||
const root = path.resolve(here, "..");
|
||||
const SKIN_VERSION = "1.2.4";
|
||||
const LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "[::1]"]);
|
||||
const CDP_ID_PATTERN = /^[A-Za-z0-9._-]{1,200}$/;
|
||||
const MAX_ART_BYTES = 16 * 1024 * 1024;
|
||||
const CODEX_SURFACE_SELECTORS = Object.freeze({
|
||||
shell: "main.main-surface",
|
||||
sidebar: "aside.app-shell-left-panel",
|
||||
composer: ".composer-surface-chrome",
|
||||
home: '[role="main"]:has([data-testid="home-icon"])',
|
||||
appearance: 'input[name="appearance-theme"]',
|
||||
themePreview: '[data-testid="theme-preview"]',
|
||||
});
|
||||
let staticPayloadAssets = null;
|
||||
|
||||
function parseArgs(argv) {
|
||||
const options = {
|
||||
port: 9341,
|
||||
mode: "watch",
|
||||
timeoutMs: 30000,
|
||||
screenshot: null,
|
||||
reload: false,
|
||||
themeDir: null,
|
||||
};
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const arg = argv[i];
|
||||
if (arg === "--port") options.port = Number(argv[++i]);
|
||||
else if (arg === "--once") options.mode = "once";
|
||||
else if (arg === "--watch") options.mode = "watch";
|
||||
else if (arg === "--verify") options.mode = "verify";
|
||||
else if (arg === "--remove") options.mode = "remove";
|
||||
else if (arg === "--check-payload") options.mode = "check";
|
||||
else if (arg === "--timeout-ms") options.timeoutMs = Number(argv[++i]);
|
||||
else if (arg === "--screenshot") options.screenshot = path.resolve(argv[++i]);
|
||||
else if (arg === "--theme-dir") options.themeDir = path.resolve(argv[++i]);
|
||||
else if (arg === "--reload") options.reload = true;
|
||||
else throw new Error(`Unknown argument: ${arg}`);
|
||||
}
|
||||
if (!Number.isInteger(options.port) || options.port < 1024 || options.port > 65535) {
|
||||
throw new Error(`Invalid port: ${options.port}`);
|
||||
}
|
||||
if (!Number.isFinite(options.timeoutMs) || options.timeoutMs < 250 || options.timeoutMs > 120000) {
|
||||
throw new Error(`Invalid timeout: ${options.timeoutMs}`);
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
function validatedDebuggerUrl(target, port) {
|
||||
const url = new URL(target.webSocketDebuggerUrl);
|
||||
const pathIsValid = /^\/devtools\/page\/[A-Za-z0-9._-]{1,200}$/.test(url.pathname);
|
||||
if (
|
||||
url.protocol !== "ws:" || !LOOPBACK_HOSTS.has(url.hostname) || Number(url.port) !== port
|
||||
|| url.username || url.password || url.search || url.hash || !pathIsValid
|
||||
) {
|
||||
throw new Error("Rejected a CDP WebSocket URL outside the allowed loopback page endpoint shape");
|
||||
}
|
||||
return url.href;
|
||||
}
|
||||
|
||||
function isValidCdpPageTarget(item, port) {
|
||||
if (
|
||||
item?.type !== "page" || !item.url?.startsWith("app://")
|
||||
|| typeof item.id !== "string" || !CDP_ID_PATTERN.test(item.id)
|
||||
|| !item.webSocketDebuggerUrl
|
||||
) return false;
|
||||
try {
|
||||
const debuggerUrl = new URL(validatedDebuggerUrl(item, port));
|
||||
return debuggerUrl.pathname === `/devtools/page/${item.id}`;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
class CdpSession {
|
||||
constructor(target, port) {
|
||||
this.target = target;
|
||||
this.ws = new WebSocket(validatedDebuggerUrl(target, port));
|
||||
this.nextId = 1;
|
||||
this.pending = new Map();
|
||||
this.listeners = new Map();
|
||||
this.closed = false;
|
||||
}
|
||||
|
||||
async open() {
|
||||
await new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
try { this.ws.close(); } catch {}
|
||||
reject(new Error("CDP WebSocket open timed out"));
|
||||
}, 5000);
|
||||
this.ws.addEventListener("open", () => { clearTimeout(timeout); resolve(); }, { once: true });
|
||||
this.ws.addEventListener("error", () => { clearTimeout(timeout); reject(new Error("CDP WebSocket open failed")); }, { once: true });
|
||||
});
|
||||
this.ws.addEventListener("message", (event) => this.onMessage(event));
|
||||
this.ws.addEventListener("error", () => this.close());
|
||||
this.ws.addEventListener("close", () => {
|
||||
this.closed = true;
|
||||
for (const waiter of this.pending.values()) {
|
||||
clearTimeout(waiter.timeout);
|
||||
waiter.reject(new Error("CDP socket closed"));
|
||||
}
|
||||
this.pending.clear();
|
||||
});
|
||||
await this.send("Runtime.enable");
|
||||
await this.send("Page.enable");
|
||||
return this;
|
||||
}
|
||||
|
||||
onMessage(event) {
|
||||
let message;
|
||||
try {
|
||||
message = JSON.parse(String(event.data));
|
||||
} catch {
|
||||
this.close();
|
||||
return;
|
||||
}
|
||||
if (!message || typeof message !== "object") {
|
||||
this.close();
|
||||
return;
|
||||
}
|
||||
if (message.id) {
|
||||
const waiter = this.pending.get(message.id);
|
||||
if (!waiter) return;
|
||||
clearTimeout(waiter.timeout);
|
||||
this.pending.delete(message.id);
|
||||
if (message.error) waiter.reject(new Error(`${message.error.message} (${message.error.code})`));
|
||||
else waiter.resolve(message.result);
|
||||
return;
|
||||
}
|
||||
for (const listener of this.listeners.get(message.method) ?? []) {
|
||||
try { listener(message.params ?? {}); } catch (error) {
|
||||
console.error(`[dream-skin] CDP listener failed: ${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
on(method, listener) {
|
||||
const listeners = this.listeners.get(method) ?? [];
|
||||
listeners.push(listener);
|
||||
this.listeners.set(method, listeners);
|
||||
}
|
||||
|
||||
send(method, params = {}, timeoutMs = 10000) {
|
||||
if (this.closed) return Promise.reject(new Error("CDP session is closed"));
|
||||
return new Promise((resolve, reject) => {
|
||||
const id = this.nextId++;
|
||||
const timeout = setTimeout(() => {
|
||||
this.pending.delete(id);
|
||||
reject(new Error(`CDP command timed out: ${method}`));
|
||||
}, timeoutMs);
|
||||
this.pending.set(id, { resolve, reject, timeout });
|
||||
try {
|
||||
this.ws.send(JSON.stringify({ id, method, params }));
|
||||
} catch (error) {
|
||||
clearTimeout(timeout);
|
||||
this.pending.delete(id);
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async evaluate(expression) {
|
||||
const result = await this.send("Runtime.evaluate", {
|
||||
expression,
|
||||
awaitPromise: true,
|
||||
returnByValue: true,
|
||||
userGesture: false,
|
||||
});
|
||||
if (result.exceptionDetails) {
|
||||
const detail = result.exceptionDetails.exception?.description ?? result.exceptionDetails.text;
|
||||
throw new Error(`Renderer evaluation failed: ${detail}`);
|
||||
}
|
||||
return result.result?.value;
|
||||
}
|
||||
|
||||
close() {
|
||||
for (const waiter of this.pending.values()) {
|
||||
clearTimeout(waiter.timeout);
|
||||
waiter.reject(new Error("CDP session closed"));
|
||||
}
|
||||
this.pending.clear();
|
||||
if (!this.closed) {
|
||||
try { this.ws.close(); } catch {}
|
||||
}
|
||||
this.closed = true;
|
||||
}
|
||||
}
|
||||
|
||||
async function listAppTargets(port) {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 2000);
|
||||
try {
|
||||
const response = await fetch(`http://127.0.0.1:${port}/json/list`, {
|
||||
redirect: "error",
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
const targets = await response.json();
|
||||
if (!Array.isArray(targets)) throw new Error("CDP target list was not an array");
|
||||
return targets.filter((item) => isValidCdpPageTarget(item, port));
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
export async function probeSession(session) {
|
||||
return session.evaluate(`(() => {
|
||||
const markers = {
|
||||
shell: Boolean(document.querySelector(${JSON.stringify(CODEX_SURFACE_SELECTORS.shell)})),
|
||||
sidebar: Boolean(document.querySelector(${JSON.stringify(CODEX_SURFACE_SELECTORS.sidebar)})),
|
||||
composer: Boolean(document.querySelector(${JSON.stringify(CODEX_SURFACE_SELECTORS.composer)})),
|
||||
main: Boolean(document.querySelector(${JSON.stringify(CODEX_SURFACE_SELECTORS.home)})),
|
||||
settings: Boolean(document.querySelector(${JSON.stringify(CODEX_SURFACE_SELECTORS.appearance)})) ||
|
||||
Boolean(document.querySelector(${JSON.stringify(CODEX_SURFACE_SELECTORS.themePreview)})),
|
||||
};
|
||||
return {
|
||||
title: document.title,
|
||||
href: location.href,
|
||||
markers,
|
||||
codex: location.protocol === 'app:' &&
|
||||
((markers.shell && markers.sidebar) || markers.main || markers.settings),
|
||||
};
|
||||
})()`);
|
||||
}
|
||||
|
||||
export async function waitForCodexProbe(session, timeoutMs = 1800) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let probe = null;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
probe = await probeSession(session);
|
||||
if (probe?.codex) return probe;
|
||||
} catch {
|
||||
// Chromium can briefly replace the execution context while Codex boots.
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
return probe;
|
||||
}
|
||||
|
||||
async function connectTarget(target, port) {
|
||||
return new CdpSession(target, port).open();
|
||||
}
|
||||
|
||||
async function connectCodexTargets(port, timeoutMs) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let lastError;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const targets = await listAppTargets(port);
|
||||
const connected = [];
|
||||
for (const target of targets) {
|
||||
let session;
|
||||
try {
|
||||
session = await connectTarget(target, port);
|
||||
const remainingMs = Math.max(50, deadline - Date.now());
|
||||
const probe = await waitForCodexProbe(session, Math.min(1800, remainingMs));
|
||||
if (probe?.codex) connected.push({ target, session, probe });
|
||||
else session.close();
|
||||
} catch (error) {
|
||||
session?.close();
|
||||
lastError = error;
|
||||
}
|
||||
}
|
||||
if (connected.length) return connected;
|
||||
lastError = new Error("No page matched the expected Codex surface markers");
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 350));
|
||||
}
|
||||
throw new Error(`No verified Codex renderer on 127.0.0.1:${port}: ${lastError?.message ?? "timed out"}`);
|
||||
}
|
||||
|
||||
function assertContainedPath(rootPath, candidatePath, label) {
|
||||
const relative = path.relative(rootPath, candidatePath);
|
||||
if (
|
||||
relative === ""
|
||||
|| (!path.isAbsolute(relative) && relative !== ".." && !relative.startsWith(`..${path.sep}`))
|
||||
) return;
|
||||
throw new Error(`${label} must stay inside its theme directory`);
|
||||
}
|
||||
|
||||
async function loadTheme(themeDir) {
|
||||
const requestedRoot = themeDir ?? path.join(root, "assets");
|
||||
const configPath = path.join(requestedRoot, "theme.json");
|
||||
let assetsRoot;
|
||||
let canonicalConfigPath;
|
||||
try {
|
||||
[assetsRoot, canonicalConfigPath] = await Promise.all([
|
||||
fs.realpath(requestedRoot),
|
||||
fs.realpath(configPath),
|
||||
]);
|
||||
} catch (error) {
|
||||
if (themeDir && error.code === "ENOENT") {
|
||||
throw new Error(`Explicit theme directory is missing theme.json: ${configPath}`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
assertContainedPath(assetsRoot, canonicalConfigPath, "Theme config");
|
||||
let config;
|
||||
try {
|
||||
config = await fs.readFile(canonicalConfigPath, "utf8");
|
||||
} catch (error) {
|
||||
if (themeDir && error.code === "ENOENT") {
|
||||
throw new Error(`Explicit theme directory is missing theme.json: ${configPath}`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const raw = JSON.parse(config);
|
||||
if (raw.schemaVersion !== 1 || typeof raw.image !== "string" || !raw.image) {
|
||||
throw new Error(`${configPath} has an unsupported schema or image field`);
|
||||
}
|
||||
if (/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/u.test(raw.image)) {
|
||||
throw new Error(`${configPath} has an invalid image field`);
|
||||
}
|
||||
if (path.basename(raw.image) !== raw.image) throw new Error("Theme image must stay inside its theme directory");
|
||||
const text = (value, fallback, max, name) => {
|
||||
if (value === undefined) return fallback;
|
||||
if (typeof value !== "string" || /[\u0000-\u001f\u007f-\u009f\u2028\u2029]/u.test(value)) {
|
||||
throw new Error(`${configPath} has an invalid ${name} field`);
|
||||
}
|
||||
return value.trim() ? Array.from(value.trim()).slice(0, max).join("") : fallback;
|
||||
};
|
||||
const color = (value, fallback) => {
|
||||
if (typeof value !== "string") return fallback;
|
||||
const normalized = value.trim();
|
||||
return /^#[0-9a-f]{6}$/i.test(normalized) || /^rgba?\([0-9., %]+\)$/i.test(normalized)
|
||||
? normalized
|
||||
: fallback;
|
||||
};
|
||||
const choice = (value, name, choices) => {
|
||||
if (value === undefined) return undefined;
|
||||
if (typeof value !== "string" || !choices.includes(value)) {
|
||||
throw new Error(`${configPath} has an invalid ${name} field`);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
const unit = (value, name) => {
|
||||
if (value === undefined) return undefined;
|
||||
if (typeof value !== "number" || !Number.isFinite(value) || value < 0 || value > 1) {
|
||||
throw new Error(`${configPath} has an invalid ${name} field`);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
const rawColors = raw.colors && typeof raw.colors === "object" && !Array.isArray(raw.colors)
|
||||
? raw.colors : null;
|
||||
const colorKeys = [
|
||||
"background", "panel", "panelAlt", "accent", "accentAlt", "secondary",
|
||||
"highlight", "text", "muted", "line",
|
||||
];
|
||||
const appearance = choice(raw.appearance, "appearance", ["auto", "light", "dark"]);
|
||||
const surfaceOpacity = unit(raw.surfaceOpacity, "surfaceOpacity");
|
||||
if (raw.art !== undefined && (!raw.art || typeof raw.art !== "object" || Array.isArray(raw.art))) {
|
||||
throw new Error(`${configPath} has an invalid art field`);
|
||||
}
|
||||
const rawArt = raw.art || {};
|
||||
const art = {
|
||||
focusX: unit(rawArt.focusX, "art.focusX"),
|
||||
focusY: unit(rawArt.focusY, "art.focusY"),
|
||||
safeArea: choice(rawArt.safeArea, "art.safeArea", ["auto", "left", "right", "center", "none"]),
|
||||
taskMode: choice(rawArt.taskMode, "art.taskMode", ["auto", "ambient", "banner", "off"]),
|
||||
};
|
||||
const theme = {
|
||||
schemaVersion: 1,
|
||||
id: text(raw.id, "custom", 80, "id"),
|
||||
name: text(raw.name, "Codex Dream Skin", 80, "name"),
|
||||
brandSubtitle: text(raw.brandSubtitle, "CODEX DREAM SKIN", 80, "brandSubtitle"),
|
||||
tagline: text(raw.tagline, "Make something wonderful.", 160, "tagline"),
|
||||
projectPrefix: text(raw.projectPrefix, "选择项目 · ", 80, "projectPrefix"),
|
||||
projectLabel: text(raw.projectLabel, "◉ 选择项目", 80, "projectLabel"),
|
||||
statusText: text(raw.statusText, "DREAM SKIN ONLINE", 80, "statusText"),
|
||||
quote: text(raw.quote, "MAKE SOMETHING WONDERFUL", 80, "quote"),
|
||||
image: raw.image,
|
||||
surfaceOpacity: surfaceOpacity ?? 1,
|
||||
colorMode: rawColors ? "explicit" : "auto",
|
||||
explicitColorKeys: rawColors ? colorKeys.filter((key) => Object.hasOwn(rawColors, key)) : [],
|
||||
colors: {
|
||||
background: color(rawColors?.background, "#071116"),
|
||||
panel: color(rawColors?.panel, "#0b1a20"),
|
||||
panelAlt: color(rawColors?.panelAlt, "#10272c"),
|
||||
accent: color(rawColors?.accent, "#7cff46"),
|
||||
accentAlt: color(rawColors?.accentAlt, "#b8ff3d"),
|
||||
secondary: color(rawColors?.secondary, "#36d7e8"),
|
||||
highlight: color(rawColors?.highlight, "#642a8c"),
|
||||
text: color(rawColors?.text, "#e9fff1"),
|
||||
muted: color(rawColors?.muted, "#9ebdb3"),
|
||||
line: color(rawColors?.line, "rgba(124, 255, 70, .28)"),
|
||||
},
|
||||
};
|
||||
if (appearance !== undefined) theme.appearance = appearance;
|
||||
if (Object.values(art).some((value) => value !== undefined)) {
|
||||
theme.art = Object.fromEntries(Object.entries(art).filter(([, value]) => value !== undefined));
|
||||
}
|
||||
const requestedImagePath = path.join(assetsRoot, theme.image);
|
||||
let imagePath;
|
||||
try {
|
||||
imagePath = await fs.realpath(requestedImagePath);
|
||||
} catch (error) {
|
||||
if (error.code === "ENOENT") throw new Error(`Theme image is missing: ${requestedImagePath}`);
|
||||
throw error;
|
||||
}
|
||||
assertContainedPath(assetsRoot, imagePath, "Theme image");
|
||||
const imageStat = await fs.stat(imagePath);
|
||||
const extension = path.extname(theme.image).toLowerCase();
|
||||
if (![".png", ".jpg", ".jpeg", ".webp"].includes(extension)) {
|
||||
throw new Error(`Unsupported theme image format: ${extension || "missing"}`);
|
||||
}
|
||||
let imageHandle;
|
||||
try {
|
||||
imageHandle = await fs.open(imagePath, fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0));
|
||||
} catch (error) {
|
||||
if (error.code === "ELOOP") throw new Error("Theme image changed into a symbolic link while loading");
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
const openedStat = await imageHandle.stat();
|
||||
if (
|
||||
!imageStat.isFile()
|
||||
|| !openedStat.isFile()
|
||||
|| imageStat.dev !== openedStat.dev
|
||||
|| imageStat.ino !== openedStat.ino
|
||||
|| openedStat.size < 1
|
||||
|| openedStat.size > MAX_ART_BYTES
|
||||
) {
|
||||
throw new Error(`Theme image must be a stable non-empty file no larger than ${MAX_ART_BYTES} bytes`);
|
||||
}
|
||||
const art = await imageHandle.readFile();
|
||||
if (art.length < 1 || art.length > MAX_ART_BYTES) {
|
||||
throw new Error(`Theme image must be a non-empty file no larger than ${MAX_ART_BYTES} bytes`);
|
||||
}
|
||||
return { art, assetsRoot, extension, imagePath, theme };
|
||||
} finally {
|
||||
await imageHandle.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function loadStaticPayloadAssets() {
|
||||
const cacheHit = Boolean(staticPayloadAssets);
|
||||
if (!staticPayloadAssets) {
|
||||
staticPayloadAssets = Promise.all([
|
||||
fs.readFile(path.join(root, "assets", "dream-skin.css"), "utf8"),
|
||||
fs.readFile(path.join(root, "assets", "renderer-inject.js"), "utf8"),
|
||||
]).catch((error) => {
|
||||
staticPayloadAssets = null;
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
const [css, template] = await staticPayloadAssets;
|
||||
return { css, template, cacheHit };
|
||||
}
|
||||
|
||||
function invalidateStaticPayloadAssets() {
|
||||
staticPayloadAssets = null;
|
||||
}
|
||||
|
||||
async function loadPayload(themeDir) {
|
||||
const startedAt = performance.now();
|
||||
const [staticAssets, loaded] = await Promise.all([
|
||||
loadStaticPayloadAssets(),
|
||||
loadTheme(themeDir),
|
||||
]);
|
||||
const { css, template } = staticAssets;
|
||||
const { art, extension, theme } = loaded;
|
||||
const styleRevision = createHash("sha256").update(css).digest("hex").slice(0, 20);
|
||||
const artMetadata = readImageMetadata(art, extension);
|
||||
if (!artMetadata) {
|
||||
throw new Error("Theme image metadata is invalid or exceeds the 16384px / 50MP safety limit");
|
||||
}
|
||||
const artKey = createHash("sha256").update(art).digest("hex").slice(0, 20);
|
||||
theme.artMetadata = artMetadata;
|
||||
theme.artKey = artKey;
|
||||
const mime = extension === ".jpg" || extension === ".jpeg" ? "image/jpeg"
|
||||
: extension === ".webp" ? "image/webp" : "image/png";
|
||||
const artDataUrl = `data:${mime};base64,${art.toString("base64")}`;
|
||||
const payload = template
|
||||
.replace("__DREAM_SKIN_CSS_JSON__", JSON.stringify(css))
|
||||
.replace("__DREAM_SKIN_ART_JSON__", JSON.stringify(artDataUrl))
|
||||
.replace("__DREAM_SKIN_THEME_JSON__", JSON.stringify(theme))
|
||||
.replace("__DREAM_SKIN_VERSION_JSON__", JSON.stringify(SKIN_VERSION))
|
||||
.replace("__DREAM_SKIN_STYLE_REVISION_JSON__", JSON.stringify(styleRevision));
|
||||
const revision = createHash("sha256")
|
||||
.update(SKIN_VERSION)
|
||||
.update(css)
|
||||
.update(template)
|
||||
.update(JSON.stringify(theme))
|
||||
.digest("hex")
|
||||
.slice(0, 20);
|
||||
return {
|
||||
imageBytes: art.length,
|
||||
payload,
|
||||
revision,
|
||||
theme,
|
||||
timings: {
|
||||
buildMs: Number((performance.now() - startedAt).toFixed(3)),
|
||||
staticCacheHit: staticAssets.cacheHit,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function applyToSession(session, payload) {
|
||||
return session.evaluate(payload);
|
||||
}
|
||||
|
||||
async function removeFromSession(session) {
|
||||
return session.evaluate(`(() => {
|
||||
window.__CODEX_DREAM_SKIN_DISABLED__ = true;
|
||||
const state = window.__CODEX_DREAM_SKIN_STATE__;
|
||||
if (state?.cleanup) return state.cleanup();
|
||||
document.documentElement?.classList.remove('codex-dream-skin');
|
||||
document.documentElement?.style.removeProperty('--dream-skin-art');
|
||||
document.getElementById('codex-dream-skin-style')?.remove();
|
||||
document.getElementById('codex-dream-skin-chrome')?.remove();
|
||||
delete window.__CODEX_DREAM_SKIN_STATE__;
|
||||
return true;
|
||||
})()`);
|
||||
}
|
||||
|
||||
async function verifyRemovedSession(session) {
|
||||
return session.evaluate(`(() =>
|
||||
!document.documentElement.classList.contains('codex-dream-skin') &&
|
||||
!document.getElementById('codex-dream-skin-style') &&
|
||||
!document.getElementById('codex-dream-skin-chrome') &&
|
||||
!window.__CODEX_DREAM_SKIN_STATE__
|
||||
)()`);
|
||||
}
|
||||
|
||||
export async function verifySession(session) {
|
||||
return session.evaluate(`(() => {
|
||||
const box = (node) => {
|
||||
if (!node) return null;
|
||||
const r = node.getBoundingClientRect();
|
||||
const style = getComputedStyle(node);
|
||||
return {
|
||||
x: Math.round(r.x), y: Math.round(r.y),
|
||||
width: Math.round(r.width), height: Math.round(r.height),
|
||||
visible: r.width > 0 && r.height > 0 && style.display !== 'none' && style.visibility !== 'hidden',
|
||||
};
|
||||
};
|
||||
const homeIndicator = document.querySelector('[data-testid="home-icon"]');
|
||||
const homeSignal = homeIndicator ?? document.querySelector('[data-feature="game-source"]') ??
|
||||
document.querySelector('.group\\\\/home-suggestions');
|
||||
const homeRoute = homeSignal?.closest('[role="main"]') ?? null;
|
||||
const settingsAnchor = document.querySelector(${JSON.stringify(CODEX_SURFACE_SELECTORS.appearance)}) ||
|
||||
document.querySelector(${JSON.stringify(CODEX_SURFACE_SELECTORS.themePreview)});
|
||||
const home = document.querySelector('[role="main"].dream-skin-home');
|
||||
const suggestions = home?.querySelector('.group\\\\/home-suggestions') ?? null;
|
||||
const cardBoxes = suggestions ? [...suggestions.querySelectorAll('button')].map(box) : [];
|
||||
const visibleCards = cardBoxes.filter((item) => item?.visible);
|
||||
const hero = box(home?.firstElementChild?.firstElementChild?.firstElementChild);
|
||||
const projectButton = box(home?.querySelector('.group\\\\/project-selector > button'));
|
||||
const shell = box(document.querySelector('main.main-surface'));
|
||||
const composer = box(document.querySelector('.composer-surface-chrome'));
|
||||
const sidebar = box(document.querySelector('aside.app-shell-left-panel'));
|
||||
const chrome = document.getElementById('codex-dream-skin-chrome');
|
||||
const result = {
|
||||
installed: document.documentElement.classList.contains('codex-dream-skin'),
|
||||
version: window.__CODEX_DREAM_SKIN_STATE__?.version ?? null,
|
||||
stylePresent: Boolean(document.getElementById('codex-dream-skin-style')),
|
||||
chromePresent: Boolean(chrome),
|
||||
chromePointerEvents: getComputedStyle(chrome || document.body).pointerEvents,
|
||||
homeRoute: Boolean(homeRoute),
|
||||
homeRouteBox: box(homeRoute),
|
||||
homePresent: Boolean(home),
|
||||
settingsRoute: Boolean(settingsAnchor),
|
||||
settingsAnchor: box(settingsAnchor),
|
||||
hero,
|
||||
cards: cardBoxes,
|
||||
visibleCardCount: visibleCards.length,
|
||||
projectButton,
|
||||
shell,
|
||||
composer,
|
||||
sidebar,
|
||||
viewport: { width: innerWidth, height: innerHeight },
|
||||
documentOverflow: {
|
||||
x: document.documentElement.scrollWidth > document.documentElement.clientWidth,
|
||||
y: document.documentElement.scrollHeight > document.documentElement.clientHeight,
|
||||
},
|
||||
};
|
||||
const standardSurface = Boolean(result.shell?.visible && result.sidebar?.visible);
|
||||
const routeSurface = Boolean(result.homeRouteBox?.visible || result.settingsAnchor?.visible);
|
||||
const chromePass = result.settingsRoute ||
|
||||
(result.chromePresent && result.chromePointerEvents === 'none');
|
||||
const basePass = result.installed && result.version === ${JSON.stringify(SKIN_VERSION)} &&
|
||||
result.stylePresent && chromePass && (standardSurface || routeSurface) &&
|
||||
!result.documentOverflow.x;
|
||||
// Project selector markup varies across Codex builds — soft requirement.
|
||||
const homePass = !result.homeRoute || (
|
||||
result.homePresent && result.hero?.visible && result.hero.width >= 280 && result.hero.height >= 120
|
||||
);
|
||||
result.pass = Boolean(basePass && homePass);
|
||||
result.softNotes = {
|
||||
projectButtonOptional: !result.projectButton?.visible,
|
||||
composerOptionalOnNonTaskRoutes: !result.composer?.visible,
|
||||
suggestionCardsOptional: result.homeRoute && result.visibleCardCount === 0,
|
||||
};
|
||||
return result;
|
||||
})()`);
|
||||
}
|
||||
|
||||
async function waitForVerifiedSession(session, timeoutMs) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let lastResult;
|
||||
while (Date.now() < deadline) {
|
||||
lastResult = await verifySession(session);
|
||||
if (lastResult.pass) return lastResult;
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
}
|
||||
return lastResult;
|
||||
}
|
||||
|
||||
async function capture(session, outputPath) {
|
||||
await fs.mkdir(path.dirname(outputPath), { recursive: true });
|
||||
const bestEffortInput = async (method, params) => {
|
||||
try {
|
||||
await session.send(method, params, 750);
|
||||
} catch {
|
||||
// Screenshot capture is still valid when a renderer omits the Input domain.
|
||||
}
|
||||
};
|
||||
await bestEffortInput("Input.dispatchKeyEvent", {
|
||||
type: "keyDown", key: "Escape", code: "Escape", windowsVirtualKeyCode: 27,
|
||||
});
|
||||
await bestEffortInput("Input.dispatchKeyEvent", {
|
||||
type: "keyUp", key: "Escape", code: "Escape", windowsVirtualKeyCode: 27,
|
||||
});
|
||||
const viewport = await session.evaluate("({ width: innerWidth, height: innerHeight })");
|
||||
await bestEffortInput("Input.dispatchMouseEvent", {
|
||||
type: "mouseMoved",
|
||||
x: Math.round(viewport.width * 0.64),
|
||||
y: Math.round(viewport.height * 0.62),
|
||||
button: "none",
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
const result = await session.send("Page.captureScreenshot", {
|
||||
format: "png",
|
||||
fromSurface: true,
|
||||
captureBeyondViewport: false,
|
||||
});
|
||||
await fs.writeFile(outputPath, Buffer.from(result.data, "base64"));
|
||||
}
|
||||
|
||||
async function runOneShot(options) {
|
||||
const connected = await connectCodexTargets(options.port, options.timeoutMs);
|
||||
const loaded = (options.mode === "once" || options.reload) ? await loadPayload(options.themeDir) : null;
|
||||
const payload = loaded?.payload ?? null;
|
||||
const results = [];
|
||||
let screenshotCaptured = false;
|
||||
|
||||
for (const { target, session, probe } of connected) {
|
||||
try {
|
||||
if (options.mode === "remove") await removeFromSession(session);
|
||||
else if (options.mode === "once") await applyToSession(session, payload);
|
||||
|
||||
if (options.reload) {
|
||||
await session.send("Page.reload", { ignoreCache: true });
|
||||
await new Promise((resolve) => setTimeout(resolve, 1600));
|
||||
if (options.mode !== "remove") await applyToSession(session, payload);
|
||||
}
|
||||
|
||||
const result = options.mode === "remove"
|
||||
? await verifyRemovedSession(session)
|
||||
: await waitForVerifiedSession(session, options.timeoutMs);
|
||||
results.push({ targetId: target.id, title: target.title, url: target.url, probe, result });
|
||||
|
||||
if (options.screenshot && !screenshotCaptured) {
|
||||
await capture(session, options.screenshot);
|
||||
screenshotCaptured = true;
|
||||
}
|
||||
} finally {
|
||||
session.close();
|
||||
}
|
||||
}
|
||||
|
||||
console.log(JSON.stringify({ mode: options.mode, version: SKIN_VERSION, port: options.port, targets: results }, null, 2));
|
||||
const failed = results.length === 0 || results.some((item) => options.mode === "remove" ? item.result !== true : !item.result?.pass);
|
||||
if (failed) process.exitCode = 2;
|
||||
}
|
||||
|
||||
async function flushStandardStreams() {
|
||||
const flush = (stream) => new Promise((resolve) => {
|
||||
if (!stream?.writable || stream.destroyed) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
stream.write("", resolve);
|
||||
});
|
||||
await Promise.all([flush(process.stdout), flush(process.stderr)]);
|
||||
}
|
||||
|
||||
export function earlyPayloadFor(payload, revision) {
|
||||
return `(() => {
|
||||
const generationKey = "__CODEX_DREAM_SKIN_EARLY_GENERATION__";
|
||||
const appliedKey = "__CODEX_DREAM_SKIN_EARLY_APPLIED__";
|
||||
const generation = ${JSON.stringify(revision)};
|
||||
window[generationKey] = generation;
|
||||
let observer = null;
|
||||
let timeout = null;
|
||||
const stop = () => {
|
||||
observer?.disconnect();
|
||||
observer = null;
|
||||
if (timeout) clearTimeout(timeout);
|
||||
timeout = null;
|
||||
};
|
||||
const install = () => {
|
||||
if (window[generationKey] !== generation) { stop(); return true; }
|
||||
if (!document.documentElement) return false;
|
||||
if (location.protocol !== "app:") return false;
|
||||
const shell = document.querySelector(${JSON.stringify(CODEX_SURFACE_SELECTORS.shell)});
|
||||
const sidebar = document.querySelector(${JSON.stringify(CODEX_SURFACE_SELECTORS.sidebar)});
|
||||
const home = document.querySelector(${JSON.stringify(CODEX_SURFACE_SELECTORS.home)});
|
||||
const settings = document.querySelector(${JSON.stringify(CODEX_SURFACE_SELECTORS.appearance)}) ||
|
||||
document.querySelector(${JSON.stringify(CODEX_SURFACE_SELECTORS.themePreview)});
|
||||
if (!(shell && sidebar) && !home && !settings) return false;
|
||||
stop();
|
||||
${payload};
|
||||
window[appliedKey] = generation;
|
||||
return true;
|
||||
};
|
||||
if (install()) return;
|
||||
if (typeof MutationObserver === "function" && document.documentElement) {
|
||||
observer = new MutationObserver(install);
|
||||
observer.observe(document.documentElement, { childList: true, subtree: true });
|
||||
}
|
||||
timeout = setTimeout(stop, 10000);
|
||||
})()`;
|
||||
}
|
||||
|
||||
function watchPayloadSources(themeDir, onDirty) {
|
||||
const assetsRoot = path.join(root, "assets");
|
||||
const themeRoot = themeDir ?? assetsRoot;
|
||||
const watchers = [];
|
||||
const add = (directory, kind) => {
|
||||
let watcher;
|
||||
try {
|
||||
watcher = watchFs(directory, { persistent: false }, (_event, filename) => {
|
||||
const name = filename ? String(filename) : "";
|
||||
const staticChanged = directory === assetsRoot &&
|
||||
(!name || name === "dream-skin.css" || name === "renderer-inject.js");
|
||||
if (kind === "static" && !staticChanged) return;
|
||||
onDirty({ staticChanged });
|
||||
});
|
||||
watcher.on("error", (error) => {
|
||||
console.error(`[dream-skin] file watch unavailable for ${directory}: ${error.message}`);
|
||||
});
|
||||
watchers.push(watcher);
|
||||
} catch (error) {
|
||||
console.error(`[dream-skin] file watch unavailable for ${directory}: ${error.message}`);
|
||||
}
|
||||
};
|
||||
add(themeRoot, "theme");
|
||||
if (themeRoot !== assetsRoot) add(assetsRoot, "static");
|
||||
return () => watchers.forEach((watcher) => watcher.close());
|
||||
}
|
||||
|
||||
async function runWatch(options) {
|
||||
let current = await loadPayload(options.themeDir);
|
||||
const sessions = new Map();
|
||||
const rejected = new Set();
|
||||
let stopping = false;
|
||||
let reloadTimer = null;
|
||||
let reloadChain = Promise.resolve();
|
||||
let discoveryDelayMs = 100;
|
||||
let lastListErrorAt = 0;
|
||||
const stop = () => { stopping = true; };
|
||||
process.on("SIGINT", stop);
|
||||
process.on("SIGTERM", stop);
|
||||
|
||||
const registerEarly = async (session, payload, revision) => {
|
||||
const result = await session.send("Page.addScriptToEvaluateOnNewDocument", {
|
||||
source: earlyPayloadFor(payload, revision),
|
||||
});
|
||||
return result.identifier ?? null;
|
||||
};
|
||||
|
||||
const removeEarly = async (record) => {
|
||||
if (!record.earlyScriptId || record.session.closed) return;
|
||||
const identifier = record.earlyScriptId;
|
||||
record.earlyScriptId = null;
|
||||
await record.session.send("Page.removeScriptToEvaluateOnNewDocument", { identifier }).catch(() => {});
|
||||
};
|
||||
|
||||
const refreshPayload = async () => {
|
||||
const next = await loadPayload(options.themeDir);
|
||||
if (next.revision === current.revision) return;
|
||||
current = next;
|
||||
for (const record of sessions.values()) {
|
||||
const { session } = record;
|
||||
if (session.closed) continue;
|
||||
try {
|
||||
const nextIdentifier = await registerEarly(session, current.payload, current.revision);
|
||||
if (record.earlyScriptId) {
|
||||
await session.send("Page.removeScriptToEvaluateOnNewDocument", {
|
||||
identifier: record.earlyScriptId,
|
||||
}).catch(() => {});
|
||||
}
|
||||
record.earlyScriptId = nextIdentifier;
|
||||
record.needsLoadFallback = !nextIdentifier;
|
||||
await applyToSession(session, current.payload);
|
||||
} catch (error) {
|
||||
record.needsLoadFallback = true;
|
||||
console.error(`[dream-skin] theme refresh failed: ${error.message}`);
|
||||
}
|
||||
}
|
||||
console.log(`[dream-skin] refreshed theme ${current.theme.id} (${current.timings.buildMs}ms)`);
|
||||
};
|
||||
|
||||
const queuePayloadRefresh = ({ staticChanged = false } = {}) => {
|
||||
if (staticChanged) invalidateStaticPayloadAssets();
|
||||
if (reloadTimer) clearTimeout(reloadTimer);
|
||||
reloadTimer = setTimeout(() => {
|
||||
reloadTimer = null;
|
||||
reloadChain = reloadChain.then(refreshPayload).catch((error) => {
|
||||
console.error(`[dream-skin] theme reload failed: ${error.message}`);
|
||||
});
|
||||
}, 45);
|
||||
};
|
||||
const closePayloadWatchers = watchPayloadSources(options.themeDir, queuePayloadRefresh);
|
||||
|
||||
try {
|
||||
while (!stopping) {
|
||||
let targets = [];
|
||||
try {
|
||||
targets = await listAppTargets(options.port);
|
||||
discoveryDelayMs = 100;
|
||||
} catch (error) {
|
||||
if (Date.now() - lastListErrorAt >= 2000) {
|
||||
console.error(`[dream-skin] ${new Date().toISOString()} ${error.message}`);
|
||||
lastListErrorAt = Date.now();
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, discoveryDelayMs));
|
||||
discoveryDelayMs = Math.min(500, Math.round(discoveryDelayMs * 1.6));
|
||||
continue;
|
||||
}
|
||||
|
||||
const activeIds = new Set(targets.map((target) => target.id));
|
||||
for (const [id, record] of sessions) {
|
||||
if (!activeIds.has(id) || record.session.closed) {
|
||||
record.session.close();
|
||||
sessions.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
for (const target of targets) {
|
||||
if (sessions.has(target.id)) continue;
|
||||
let session;
|
||||
let record;
|
||||
try {
|
||||
session = await connectTarget(target, options.port);
|
||||
record = { session, earlyScriptId: null, needsLoadFallback: false };
|
||||
try {
|
||||
record.earlyScriptId = await registerEarly(session, current.payload, current.revision);
|
||||
await session.evaluate(earlyPayloadFor(current.payload, current.revision));
|
||||
} catch (error) {
|
||||
record.needsLoadFallback = true;
|
||||
console.error(`[dream-skin] early injection unavailable: ${error.message}`);
|
||||
}
|
||||
const probe = await waitForCodexProbe(session);
|
||||
if (!probe?.codex) {
|
||||
await removeEarly(record);
|
||||
session.close();
|
||||
if (!rejected.has(target.id)) {
|
||||
console.error(`[dream-skin] rejected non-Codex app target ${target.id}`);
|
||||
rejected.add(target.id);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
rejected.delete(target.id);
|
||||
session.on("Page.loadEventFired", () => {
|
||||
if (!record.needsLoadFallback) return;
|
||||
setTimeout(() => applyToSession(session, current.payload).catch((error) => {
|
||||
console.error(`[dream-skin] fallback reinject failed: ${error.message}`);
|
||||
}), 0);
|
||||
});
|
||||
const earlyApplied = await session.evaluate(
|
||||
`window.__CODEX_DREAM_SKIN_EARLY_APPLIED__ === ${JSON.stringify(current.revision)}`,
|
||||
);
|
||||
if (!earlyApplied) {
|
||||
await session.evaluate(
|
||||
`window.__CODEX_DREAM_SKIN_EARLY_GENERATION__ = ${JSON.stringify(`fallback:${current.revision}`)}`,
|
||||
);
|
||||
await applyToSession(session, current.payload);
|
||||
}
|
||||
sessions.set(target.id, record);
|
||||
console.log(`[dream-skin] injected verified Codex target ${target.id} (${target.title || target.url})`);
|
||||
} catch (error) {
|
||||
if (record) await removeEarly(record);
|
||||
session?.close();
|
||||
console.error(`[dream-skin] inject failed for ${target.id}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
const pollDelay = sessions.size ? 800 : (targets.length ? 250 : 100);
|
||||
await new Promise((resolve) => setTimeout(resolve, pollDelay));
|
||||
}
|
||||
} finally {
|
||||
if (reloadTimer) clearTimeout(reloadTimer);
|
||||
closePayloadWatchers();
|
||||
await reloadChain.catch(() => {});
|
||||
await Promise.all([...sessions.values()].map((record) => removeEarly(record)));
|
||||
for (const record of sessions.values()) record.session.close();
|
||||
}
|
||||
}
|
||||
|
||||
if (path.resolve(process.argv[1] || "") === path.resolve(scriptPath)) {
|
||||
let options = null;
|
||||
try {
|
||||
options = parseArgs(process.argv.slice(2));
|
||||
if (options.mode === "check") {
|
||||
const loaded = await loadPayload(options.themeDir);
|
||||
console.log(JSON.stringify({
|
||||
pass: true,
|
||||
version: SKIN_VERSION,
|
||||
themeId: loaded.theme.id,
|
||||
themeName: loaded.theme.name,
|
||||
imageBytes: loaded.imageBytes,
|
||||
payloadBytes: Buffer.byteLength(loaded.payload),
|
||||
artMetadata: loaded.theme.artMetadata ?? null,
|
||||
timings: loaded.timings,
|
||||
}, null, 2));
|
||||
} else if (options.mode === "watch") await runWatch(options);
|
||||
else {
|
||||
await runOneShot(options);
|
||||
await flushStandardStreams();
|
||||
process.exit(process.exitCode ?? 0);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[dream-skin] ${error.stack || error.message}`);
|
||||
process.exitCode = 1;
|
||||
if (options?.mode && options.mode !== "watch" && options.mode !== "check") {
|
||||
await flushStandardStreams();
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
import fs from "node:fs/promises";
|
||||
import { constants as fsConstants } from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const [sourceDirArg, stageDirArg] = process.argv.slice(2);
|
||||
if (!sourceDirArg || !stageDirArg) {
|
||||
throw new Error("Usage: stage-theme.mjs <source-theme-dir> <stage-dir>");
|
||||
}
|
||||
|
||||
const MAX_CONFIG_BYTES = 1024 * 1024;
|
||||
const MAX_IMAGE_BYTES = 16 * 1024 * 1024;
|
||||
const OPEN_FLAGS = fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0);
|
||||
|
||||
function assertContained(rootPath, candidatePath, label) {
|
||||
const relative = path.relative(rootPath, candidatePath);
|
||||
if (
|
||||
relative === ""
|
||||
|| (!path.isAbsolute(relative) && relative !== ".." && !relative.startsWith(`..${path.sep}`))
|
||||
) return;
|
||||
throw new Error(`${label} must stay inside its theme directory`);
|
||||
}
|
||||
|
||||
function sameStat(left, right) {
|
||||
return left.isFile() && right.isFile()
|
||||
&& left.dev === right.dev
|
||||
&& left.ino === right.ino
|
||||
&& left.size === right.size
|
||||
&& left.mtimeMs === right.mtimeMs
|
||||
&& left.ctimeMs === right.ctimeMs;
|
||||
}
|
||||
|
||||
async function readStableFile(filePath, label, maxBytes) {
|
||||
let handle;
|
||||
try {
|
||||
handle = await fs.open(filePath, OPEN_FLAGS);
|
||||
} catch (error) {
|
||||
if (error.code === "ELOOP") throw new Error(`${label} must not be a symbolic link`);
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
const before = await handle.stat();
|
||||
if (!before.isFile()) throw new Error(`${label} must be a regular file`);
|
||||
if (before.size > maxBytes) throw new Error(`${label} is larger than ${maxBytes} bytes`);
|
||||
const bytes = await handle.readFile();
|
||||
const after = await handle.stat();
|
||||
if (!sameStat(before, after)) {
|
||||
throw new Error(`${label} changed while it was being staged`);
|
||||
}
|
||||
if (bytes.length > maxBytes) throw new Error(`${label} is larger than ${maxBytes} bytes`);
|
||||
return { bytes, stat: after };
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
|
||||
function decodeJson(bytes, label) {
|
||||
const text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
||||
if (text.includes("\0")) throw new Error(`${label} contains NUL characters`);
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
throw new Error(`${label} is not valid JSON`);
|
||||
}
|
||||
}
|
||||
|
||||
async function writeExclusive(filePath, bytes) {
|
||||
const temporary = `${filePath}.${process.pid}.tmp`;
|
||||
try {
|
||||
await fs.writeFile(temporary, bytes, { flag: "wx", mode: 0o600 });
|
||||
await fs.rename(temporary, filePath);
|
||||
} finally {
|
||||
await fs.rm(temporary, { force: true }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const sourceRoot = await fs.realpath(sourceDirArg);
|
||||
const sourceStat = await fs.stat(sourceRoot);
|
||||
if (!sourceStat.isDirectory()) throw new Error("Theme source must be a directory");
|
||||
|
||||
const configPath = path.join(sourceRoot, "theme.json");
|
||||
const config = await readStableFile(configPath, "Theme config", MAX_CONFIG_BYTES);
|
||||
const theme = decodeJson(config.bytes, "Theme config");
|
||||
if (theme?.schemaVersion !== 1 || typeof theme.image !== "string" || !theme.image) {
|
||||
throw new Error("Theme config has an unsupported schema or image field");
|
||||
}
|
||||
if (path.basename(theme.image) !== theme.image) {
|
||||
throw new Error("Theme image must stay inside its theme directory");
|
||||
}
|
||||
if (theme.image === "theme.json") {
|
||||
throw new Error("Theme image must not replace theme.json");
|
||||
}
|
||||
if (/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/u.test(theme.image)) {
|
||||
throw new Error("Theme image contains control characters");
|
||||
}
|
||||
|
||||
const imagePath = path.resolve(sourceRoot, theme.image);
|
||||
assertContained(sourceRoot, imagePath, "Theme image");
|
||||
const image = await readStableFile(imagePath, "Theme image", MAX_IMAGE_BYTES);
|
||||
if (image.bytes.length < 1) throw new Error("Theme image is empty");
|
||||
|
||||
const stageRoot = await fs.realpath(stageDirArg);
|
||||
const stageStat = await fs.stat(stageRoot);
|
||||
if (!stageStat.isDirectory()) throw new Error("Theme stage must be a directory");
|
||||
assertContained(stageRoot, path.join(stageRoot, "theme.json"), "Staged theme config");
|
||||
assertContained(stageRoot, path.join(stageRoot, theme.image), "Staged theme image");
|
||||
|
||||
// Write both files from the already-open, stable descriptors. The caller
|
||||
// publishes the image first and theme.json last, so the watcher only ever
|
||||
// observes a complete pair; subsequent source edits cannot race the copy.
|
||||
await writeExclusive(path.join(stageRoot, theme.image), image.bytes);
|
||||
await writeExclusive(path.join(stageRoot, "theme.json"), config.bytes);
|
||||
process.stdout.write(theme.image);
|
||||
}
|
||||
|
||||
await main();
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 117 KiB |
@@ -1,92 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
MAX_IMAGE_DIMENSION,
|
||||
MAX_IMAGE_PIXELS,
|
||||
classifyImageDimensions,
|
||||
readImageMetadata,
|
||||
} from "../scripts/image-metadata.mjs";
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const macosRoot = path.resolve(here, "..");
|
||||
|
||||
const fixture = await fs.readFile(path.join(macosRoot, "tests", "fixtures", "background.jpg"));
|
||||
assert.deepEqual(readImageMetadata(fixture, ".jpg"), {
|
||||
width: 1920,
|
||||
height: 1200,
|
||||
ratio: 1.6,
|
||||
wide: false,
|
||||
aspect: "wide",
|
||||
taskMode: "ambient",
|
||||
});
|
||||
const malformedJpeg = Buffer.from(fixture);
|
||||
malformedJpeg[0] = 0;
|
||||
assert.equal(readImageMetadata(malformedJpeg, ".jpg"), null);
|
||||
|
||||
assert.deepEqual(classifyImageDimensions({ width: 2400, height: 1350 }), {
|
||||
width: 2400,
|
||||
height: 1350,
|
||||
ratio: 2400 / 1350,
|
||||
wide: true,
|
||||
aspect: "wide",
|
||||
taskMode: "ambient",
|
||||
});
|
||||
assert.equal(MAX_IMAGE_DIMENSION, 16384);
|
||||
assert.equal(MAX_IMAGE_PIXELS, 50_000_000);
|
||||
assert.equal(classifyImageDimensions({ width: 10000, height: 6000 }), null);
|
||||
assert.equal(classifyImageDimensions({ width: 20000, height: 1 }), null);
|
||||
assert.equal(classifyImageDimensions({ width: 2560.5, height: 1440 }), null);
|
||||
|
||||
const writeAscii = (bytes, offset, value) => {
|
||||
for (let index = 0; index < value.length; index += 1) bytes[offset + index] = value.charCodeAt(index);
|
||||
};
|
||||
const writeUint32Le = (bytes, offset, value) => {
|
||||
bytes[offset] = value & 0xff;
|
||||
bytes[offset + 1] = (value >>> 8) & 0xff;
|
||||
bytes[offset + 2] = (value >>> 16) & 0xff;
|
||||
bytes[offset + 3] = (value >>> 24) & 0xff;
|
||||
};
|
||||
const writeUint24Le = (bytes, offset, value) => {
|
||||
bytes[offset] = value & 0xff;
|
||||
bytes[offset + 1] = (value >>> 8) & 0xff;
|
||||
bytes[offset + 2] = (value >>> 16) & 0xff;
|
||||
};
|
||||
|
||||
const vp8l = new Uint8Array(26);
|
||||
writeAscii(vp8l, 0, "RIFF");
|
||||
writeUint32Le(vp8l, 4, vp8l.length - 8);
|
||||
writeAscii(vp8l, 8, "WEBP");
|
||||
writeAscii(vp8l, 12, "VP8L");
|
||||
writeUint32Le(vp8l, 16, 5);
|
||||
vp8l.set([0x2f, 0x7f, 0xc2, 0x59, 0x00], 20);
|
||||
assert.deepEqual(readImageMetadata(vp8l, ".webp"), {
|
||||
width: 640,
|
||||
height: 360,
|
||||
ratio: 640 / 360,
|
||||
wide: true,
|
||||
aspect: "wide",
|
||||
taskMode: "ambient",
|
||||
});
|
||||
|
||||
const vp8x = new Uint8Array(30);
|
||||
writeAscii(vp8x, 0, "RIFF");
|
||||
writeUint32Le(vp8x, 4, vp8x.length - 8);
|
||||
writeAscii(vp8x, 8, "WEBP");
|
||||
writeAscii(vp8x, 12, "VP8X");
|
||||
writeUint32Le(vp8x, 16, 10);
|
||||
writeUint24Le(vp8x, 24, 2559);
|
||||
writeUint24Le(vp8x, 27, 1439);
|
||||
assert.deepEqual(readImageMetadata(vp8x, ".webp"), {
|
||||
width: 2560,
|
||||
height: 1440,
|
||||
ratio: 2560 / 1440,
|
||||
wide: true,
|
||||
aspect: "wide",
|
||||
taskMode: "ambient",
|
||||
});
|
||||
|
||||
assert.equal(readImageMetadata(new Uint8Array([0, 1, 2, 3]), ".png"), null);
|
||||
|
||||
console.log("PASS: image dimensions strictly classify PNG, JPEG, VP8L, and VP8X profiles.");
|
||||
@@ -1,228 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import vm from "node:vm";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
earlyPayloadFor,
|
||||
probeSession,
|
||||
verifySession,
|
||||
waitForCodexProbe,
|
||||
} from "../scripts/injector.mjs";
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const injectorPath = path.resolve(here, "../scripts/injector.mjs");
|
||||
const source = await fs.readFile(injectorPath, "utf8");
|
||||
|
||||
function createFixture(protocol = "app:") {
|
||||
const observers = [];
|
||||
const timers = new Map();
|
||||
let nextTimer = 1;
|
||||
const markers = { shell: false, sidebar: false, home: false, settings: false, themePreview: false };
|
||||
const context = {
|
||||
window: { installs: [] },
|
||||
location: { protocol },
|
||||
document: {
|
||||
documentElement: {},
|
||||
querySelector(selector) {
|
||||
if (selector === "main.main-surface") return markers.shell ? {} : null;
|
||||
if (selector === "aside.app-shell-left-panel") return markers.sidebar ? {} : null;
|
||||
if (selector === '[role="main"]:has([data-testid="home-icon"])') return markers.home ? {} : null;
|
||||
if (selector === 'input[name="appearance-theme"]') return markers.settings ? {} : null;
|
||||
if (selector === '[data-testid="theme-preview"]') return markers.themePreview ? {} : null;
|
||||
return null;
|
||||
},
|
||||
},
|
||||
MutationObserver: class {
|
||||
constructor(callback) {
|
||||
this.callback = callback;
|
||||
this.connected = true;
|
||||
observers.push(this);
|
||||
}
|
||||
observe() {}
|
||||
disconnect() { this.connected = false; }
|
||||
},
|
||||
setTimeout(callback) {
|
||||
const id = nextTimer++;
|
||||
timers.set(id, callback);
|
||||
return id;
|
||||
},
|
||||
clearTimeout(id) { timers.delete(id); },
|
||||
};
|
||||
return { context, markers, observers };
|
||||
}
|
||||
|
||||
const guarded = createFixture();
|
||||
vm.runInNewContext(earlyPayloadFor('window.installs.push("guarded")', "guarded"), guarded.context);
|
||||
assert.deepEqual(guarded.context.window.installs, [], "Auxiliary app targets must remain untouched.");
|
||||
guarded.markers.shell = true;
|
||||
guarded.observers[0].callback([]);
|
||||
assert.deepEqual(guarded.context.window.installs, [], "A main surface without the Codex sidebar is not sufficient.");
|
||||
|
||||
const home = createFixture();
|
||||
home.markers.home = true;
|
||||
vm.runInNewContext(earlyPayloadFor('window.installs.push("home")', "home"), home.context);
|
||||
assert.deepEqual(home.context.window.installs, ["home"], "The explicit Codex home route must support early injection.");
|
||||
|
||||
const settings = createFixture();
|
||||
settings.markers.settings = true;
|
||||
vm.runInNewContext(earlyPayloadFor('window.installs.push("settings")', "settings"), settings.context);
|
||||
assert.deepEqual(
|
||||
settings.context.window.installs,
|
||||
["settings"],
|
||||
"The Codex appearance route must support early injection.",
|
||||
);
|
||||
|
||||
const web = createFixture("https:");
|
||||
web.markers.shell = true;
|
||||
web.markers.sidebar = true;
|
||||
vm.runInNewContext(earlyPayloadFor('window.installs.push("web")', "web"), web.context);
|
||||
assert.deepEqual(web.context.window.installs, [], "Web pages must remain untouched even with spoofed shell markers.");
|
||||
|
||||
const generations = createFixture();
|
||||
vm.runInNewContext(earlyPayloadFor('window.installs.push("old")', "old"), generations.context);
|
||||
vm.runInNewContext(earlyPayloadFor('window.installs.push("new")', "new"), generations.context);
|
||||
generations.markers.shell = true;
|
||||
generations.markers.sidebar = true;
|
||||
for (const observer of generations.observers) observer.callback([]);
|
||||
assert.deepEqual(
|
||||
generations.context.window.installs,
|
||||
["new"],
|
||||
"A stale early script must yield to the newest watcher generation.",
|
||||
);
|
||||
assert.equal(generations.context.window.__CODEX_DREAM_SKIN_EARLY_APPLIED__, "new");
|
||||
|
||||
function createProbeSession(frames) {
|
||||
let callCount = 0;
|
||||
return {
|
||||
get callCount() { return callCount; },
|
||||
async evaluate(expression) {
|
||||
const frame = frames[Math.min(callCount, frames.length - 1)];
|
||||
callCount += 1;
|
||||
if (frame instanceof Error) throw frame;
|
||||
const marker = (key) => frame[key] ? {} : null;
|
||||
return vm.runInNewContext(expression, {
|
||||
location: { protocol: frame.protocol ?? "app:", href: `${frame.protocol ?? "app:"}//codex` },
|
||||
document: {
|
||||
title: "Codex",
|
||||
querySelector(selector) {
|
||||
if (selector === "main.main-surface") return marker("shell");
|
||||
if (selector === "aside.app-shell-left-panel") return marker("sidebar");
|
||||
if (selector === ".composer-surface-chrome") return marker("composer");
|
||||
if (selector === '[role="main"]:has([data-testid="home-icon"])') return marker("home");
|
||||
if (selector === 'input[name="appearance-theme"]') return marker("settings");
|
||||
if (selector === '[data-testid="theme-preview"]') return marker("themePreview");
|
||||
return null;
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
assert.equal((await probeSession(createProbeSession([{ shell: true, sidebar: true }]))).codex, true);
|
||||
assert.equal((await probeSession(createProbeSession([{ home: true }]))).codex, true);
|
||||
assert.equal((await probeSession(createProbeSession([{ settings: true }]))).codex, true);
|
||||
assert.equal((await probeSession(createProbeSession([{}]))).codex, false, "An unmarked app page must be rejected.");
|
||||
assert.equal(
|
||||
(await probeSession(createProbeSession([{ protocol: "https:", shell: true, sidebar: true }]))).codex,
|
||||
false,
|
||||
"A non-app page must be rejected even when it spoofs the standard shell.",
|
||||
);
|
||||
|
||||
const delayed = createProbeSession([
|
||||
new Error("Execution context was destroyed"),
|
||||
{},
|
||||
{ home: true },
|
||||
]);
|
||||
assert.equal((await waitForCodexProbe(delayed, 300))?.codex, true);
|
||||
assert.equal(delayed.callCount, 3, "Probe waiting must survive document replacement and delayed DOM markers.");
|
||||
|
||||
function createVerificationSession(route) {
|
||||
const node = (width = 640, height = 480) => ({
|
||||
getBoundingClientRect: () => ({ x: 0, y: 0, width, height }),
|
||||
querySelector: () => null,
|
||||
});
|
||||
const body = node();
|
||||
const homeHero = node(560, 240);
|
||||
const home = node();
|
||||
home.firstElementChild = { firstElementChild: { firstElementChild: homeHero } };
|
||||
const homeIndicator = { closest: () => home };
|
||||
const settingsAnchor = node(320, 48);
|
||||
const chrome = node();
|
||||
return {
|
||||
async evaluate(expression) {
|
||||
return vm.runInNewContext(expression, {
|
||||
innerWidth: 1280,
|
||||
innerHeight: 800,
|
||||
window: { __CODEX_DREAM_SKIN_STATE__: { version: "1.2.4" } },
|
||||
getComputedStyle(target) {
|
||||
return { display: "block", visibility: "visible", pointerEvents: target === chrome ? "none" : "auto" };
|
||||
},
|
||||
document: {
|
||||
body,
|
||||
documentElement: {
|
||||
classList: { contains: () => true },
|
||||
scrollWidth: 1280,
|
||||
clientWidth: 1280,
|
||||
scrollHeight: 800,
|
||||
clientHeight: 800,
|
||||
},
|
||||
getElementById(id) {
|
||||
if (id === "codex-dream-skin-style") return {};
|
||||
if (id === "codex-dream-skin-chrome" && route === "home") return chrome;
|
||||
return null;
|
||||
},
|
||||
querySelector(selector) {
|
||||
if (selector === '[data-testid="home-icon"]' && route === "home") return homeIndicator;
|
||||
if (selector === '[role="main"].dream-skin-home' && route === "home") return home;
|
||||
if (selector === 'input[name="appearance-theme"]' && route === "settings") return settingsAnchor;
|
||||
return null;
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
assert.equal(
|
||||
(await verifySession(createVerificationSession("home"))).pass,
|
||||
true,
|
||||
"A verified Codex home route must not require the legacy shell/sidebar pair.",
|
||||
);
|
||||
assert.equal(
|
||||
(await verifySession(createVerificationSession("settings"))).pass,
|
||||
true,
|
||||
"A verified appearance route must accept the installed root theme before the main shell mounts.",
|
||||
);
|
||||
assert.equal(
|
||||
(await verifySession(createVerificationSession("auxiliary"))).pass,
|
||||
false,
|
||||
"An auxiliary app page must not pass post-install verification.",
|
||||
);
|
||||
|
||||
const discoveryStart = source.indexOf("record.earlyScriptId = await registerEarly");
|
||||
const probeStart = source.indexOf("const probe = await waitForCodexProbe", discoveryStart);
|
||||
assert.ok(discoveryStart >= 0 && probeStart > discoveryStart, "Early registration must happen before full shell probing.");
|
||||
assert.match(
|
||||
source,
|
||||
/connectCodexTargets[\s\S]*const probe = await waitForCodexProbe/,
|
||||
"One-shot discovery must wait for a progressively loaded Codex renderer.",
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/finally\s*\{[\s\S]*Promise\.all\(\[\.\.\.sessions\.values\(\)\][\s\S]*removeEarly\(record\)/,
|
||||
"Watcher shutdown must unregister persistent Page scripts before closing CDP sessions.",
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/const earlyApplied = await session\.evaluate\([\s\S]*if \(!earlyApplied\) \{[\s\S]*applyToSession/,
|
||||
"The watcher must not run the full payload twice after a successful early install.",
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/await runOneShot\(options\);[\s\S]{0,160}await flushStandardStreams\(\);[\s\S]{0,100}process\.exit\(process\.exitCode \?\? 0\)/,
|
||||
"One-shot commands must exit after flushing output so a lingering CDP close handshake cannot block Codex-X.",
|
||||
);
|
||||
|
||||
console.log("PASS: renderer probing is route-aware, delayed-load safe, generation-safe, and guarded.");
|
||||
@@ -1,613 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import vm from "node:vm";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const macosRoot = path.resolve(here, "..");
|
||||
const template = await fs.readFile(path.join(macosRoot, "assets", "renderer-inject.js"), "utf8");
|
||||
const css = await fs.readFile(path.join(macosRoot, "assets", "dream-skin.css"), "utf8");
|
||||
|
||||
assert.doesNotMatch(
|
||||
css,
|
||||
/background-image:\s*var\(--dream-skin-art\),\s*var\(--dream-skin-art\)/,
|
||||
"The home hero must not stack duplicate copies of the selected image.",
|
||||
);
|
||||
assert.match(
|
||||
css,
|
||||
/data-dream-art-safe="left"[\s\S]{0,140}--ds-art-position:\s*100% var\(--ds-focus-y\);/,
|
||||
"A left text-safe image must preserve its right-side subject on narrower windows.",
|
||||
);
|
||||
assert.match(
|
||||
css,
|
||||
/\.dream-skin-home > div:has\(\[data-feature="game-source"\]\) > div:first-child > div:first-child\s*\{[\s\S]{0,700}background-position:\s*var\(--ds-home-art-position\) !important;/,
|
||||
"The home hero must use its route-specific focal point.",
|
||||
);
|
||||
assert.match(
|
||||
css,
|
||||
/\.dream-skin-home > div:has\(\[data-feature="game-source"\]\)\s*\{[\s\S]{0,120}min-height:\s*100% !important;/,
|
||||
"The home layout must target the content container instead of an inserted banner sibling.",
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
css,
|
||||
/\.dream-skin-home > div:first-child\s*\{[\s\S]{0,120}min-height:\s*100% !important;/,
|
||||
"An empty home banner container must not push the composer below the viewport.",
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
css,
|
||||
/background-size:\s*auto 100% !important;/,
|
||||
"Wide home artwork must not leave an unpainted half-card by fitting only to height.",
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
css,
|
||||
/background-size:\s*100% 100%,\s*100% 100%,\s*100% auto;/,
|
||||
"Wide task artwork must cover the full route instead of ending above the composer.",
|
||||
);
|
||||
assert.match(
|
||||
css,
|
||||
/data-dream-art-task-mode="ambient"[\s\S]{0,500}body\s*\{[\s\S]{0,500}background-image:\s*var\(--dream-skin-art\) !important;[\s\S]{0,200}background-size:\s*cover !important;/,
|
||||
"Wide ambient task artwork should cover the full application window.",
|
||||
);
|
||||
assert.match(
|
||||
css,
|
||||
/data-dream-task-mode="banner"[\s\S]{0,900}body\s*\{[\s\S]{0,500}background-image:\s*var\(--dream-skin-art\) !important;[\s\S]{0,200}background-size:\s*cover !important;/,
|
||||
"Wide banner task artwork should use the same full-window wallpaper contract as ambient routes.",
|
||||
);
|
||||
assert.match(
|
||||
css,
|
||||
/data-dream-art-wide="true"\]:has\(main\.main-surface\.dream-skin-home-shell\)[\s\S]{0,100}body\s*\{[\s\S]{0,300}background-image:\s*var\(--dream-skin-art\) !important;/,
|
||||
"Wide home artwork should use the same full-window image as utility routes.",
|
||||
);
|
||||
assert.match(
|
||||
css,
|
||||
/data-dream-art-wide="true"\]:has\(main\.main-surface\.dream-skin-home-shell\)[\s\S]{0,120}body\s*\{[\s\S]{0,260}background-position:\s*var\(--ds-art-position\) !important;/,
|
||||
"Wide home artwork must honor the configured focal point instead of forcing a centered crop.",
|
||||
);
|
||||
assert.match(
|
||||
css,
|
||||
/data-dream-art-task-mode="ambient"[\s\S]{0,260}data-dream-art-wide="true"\]:has\(main\.main-surface:not\(\.dream-skin-home-shell\)\)[\s\S]{0,120}body\s*\{[\s\S]{0,260}background-position:\s*var\(--ds-art-position\) !important;/,
|
||||
"Wide task artwork must retain the same focal point as the home route.",
|
||||
);
|
||||
assert.match(
|
||||
css,
|
||||
/data-dream-art-wide="true"\]\s+\.composer-surface-chrome\s*\{[\s\S]{0,500}backdrop-filter:\s*none !important;/,
|
||||
"Wide artwork should use one uniform composer surface without a split blur layer.",
|
||||
);
|
||||
assert.match(
|
||||
css,
|
||||
/--ds-immersive-composer-solid:\s*rgb\(var\(--ds-panel-rgb\) \/ \.94\);/,
|
||||
"The light composer should remain opaque enough to prevent thread text from showing through.",
|
||||
);
|
||||
assert.match(
|
||||
css,
|
||||
/--ds-task-immersive-edge:\s*rgb\(var\(--ds-panel-rgb\) \/ calc\(\.86 \* var\(--ds-surface-opacity\)\)\);/,
|
||||
"Wide task surfaces should expose the wallpaper according to the configured opacity.",
|
||||
);
|
||||
assert.match(
|
||||
css,
|
||||
/--ds-task-fade:\s*linear-gradient\(180deg,[\s\S]{0,400}var\(--ds-surface-opacity\)/,
|
||||
"Regular landscape artwork should apply the configured opacity to its task scrim.",
|
||||
);
|
||||
assert.match(
|
||||
css,
|
||||
/main\.main-surface:not\(\.dream-skin-home-shell\)::before\s*\{[\s\S]{0,420}opacity:\s*1;/,
|
||||
"The wallpaper layer must keep its original colors instead of blending through a white base.",
|
||||
);
|
||||
const homeSuggestionRuleStart = css.indexOf(
|
||||
".dream-skin-home .group\\/home-suggestions button {",
|
||||
);
|
||||
assert.notEqual(homeSuggestionRuleStart, -1, "The home suggestion card rule must exist.");
|
||||
const homeSuggestionRuleEnd = css.indexOf("}", homeSuggestionRuleStart);
|
||||
const homeSuggestionRule = css.slice(homeSuggestionRuleStart, homeSuggestionRuleEnd);
|
||||
assert.match(
|
||||
homeSuggestionRule,
|
||||
/background:\s*rgb\(var\(--ds-panel-rgb\) \/ calc\(\.56 \* var\(--ds-surface-opacity\)\)\) !important;/,
|
||||
"Home suggestion cards must use the shared surface opacity instead of a fixed white layer.",
|
||||
);
|
||||
assert.match(
|
||||
css,
|
||||
/--ds-composer-surface:\s*rgb\(var\(--ds-panel-rgb\) \/ calc\(\.82 \+ \(\.12 \* var\(--ds-surface-opacity\)\)\)\);/,
|
||||
"The composer must keep a readable minimum opacity while responding to the skin setting.",
|
||||
);
|
||||
assert.match(
|
||||
css,
|
||||
/html\.codex-dream-skin \.composer-surface-chrome\s*\{[\s\S]{0,100}overflow:\s*hidden !important;/,
|
||||
"Composer internals must be clipped to the rounded input surface.",
|
||||
);
|
||||
assert.match(
|
||||
css,
|
||||
/data-dream-shell="light"\]\[data-dream-art-wide="true"\][\s\S]{0,100}\.composer-surface-chrome\s*\{[\s\S]{0,400}backdrop-filter:\s*blur\(8px\) saturate\(102%\) !important;/,
|
||||
"The translucent light composer should softly separate text from detailed artwork.",
|
||||
);
|
||||
assert.match(
|
||||
template,
|
||||
/\[class\*="_homeUtilityBar_"\][\s\S]{0,500}dream-skin-home-utility/,
|
||||
"The renderer should give the current native home utility bar a stable theme class.",
|
||||
);
|
||||
assert.match(
|
||||
template,
|
||||
/\[data-feature="game-source"\][\s\S]{0,180}\[data-codex-composer-root\]/,
|
||||
"Home detection should use stable semantic markers instead of optional suggestion cards.",
|
||||
);
|
||||
assert.match(
|
||||
css,
|
||||
/\.dream-skin-home:has\(\.dream-skin-home-utility\)[\s\S]{0,120}\.composer-surface-chrome\s*\{[\s\S]{0,180}border-radius:\s*0 0 22px 22px !important;/,
|
||||
"The home utility bar and composer should render as one continuous control.",
|
||||
);
|
||||
assert.match(
|
||||
css,
|
||||
/\.composer-surface-chrome button:not\(\[class~="bg-token-foreground"\]\)[\s\S]{0,100}color:\s*var\(--ds-muted\) !important;/,
|
||||
"Composer controls must remain readable when Codex native tokens lag behind a forced dark appearance.",
|
||||
);
|
||||
assert.match(
|
||||
css,
|
||||
/\.composer-surface-chrome button:not\(\[class~="bg-token-foreground"\]\) \*\s*\{[\s\S]{0,80}color:\s*currentColor !important;/,
|
||||
"Nested labels inside composer controls must inherit the corrected theme color.",
|
||||
);
|
||||
assert.match(
|
||||
css,
|
||||
/\.composer-surface-chrome p\.placeholder::after\s*\{[\s\S]{0,120}color:\s*rgb\(var\(--ds-muted-rgb\) \/ \.82\) !important;[\s\S]{0,80}opacity:\s*1 !important;/,
|
||||
"Composer placeholder text must not inherit a stale native color with double opacity.",
|
||||
);
|
||||
assert.match(
|
||||
css,
|
||||
/header\.app-header-tint\s*\{[\s\S]{0,180}background:\s*transparent !important;/,
|
||||
"Wide artwork should not paint a separate opaque header band.",
|
||||
);
|
||||
assert.match(
|
||||
css,
|
||||
/\.thread-scroll-container \.bg-gradient-to-t\.from-token-main-surface-primary\s*\{[\s\S]{0,100}background:\s*transparent !important;/,
|
||||
"Wide artwork should remove the native opaque fade behind the sticky composer.",
|
||||
);
|
||||
assert.match(
|
||||
css,
|
||||
/div\.sticky:has\(input\[type="text"\]\)[\s\S]{0,100}background:\s*transparent !important;/,
|
||||
"Search routes should not retain the native opaque sticky band.",
|
||||
);
|
||||
assert.match(
|
||||
css,
|
||||
/\[class~="bg-token-main-surface-primary"\]\[class~="h-full"\]\[class~="w-full"\][\s\S]{0,100}background:\s*transparent !important;/,
|
||||
"Full-size utility route wrappers should not hide the selected artwork.",
|
||||
);
|
||||
|
||||
function createStyleDeclaration() {
|
||||
const values = new Map();
|
||||
return {
|
||||
values,
|
||||
getPropertyValue(name) { return values.get(name) ?? ""; },
|
||||
setProperty(name, value) { values.set(name, value); },
|
||||
removeProperty(name) { values.delete(name); },
|
||||
};
|
||||
}
|
||||
|
||||
function createClassList(initial = []) {
|
||||
const values = new Set(initial);
|
||||
return {
|
||||
values,
|
||||
add(...names) { for (const name of names) values.add(name); },
|
||||
remove(...names) { for (const name of names) values.delete(name); },
|
||||
contains(name) { return values.has(name); },
|
||||
toggle(name, enabled) {
|
||||
if (enabled) values.add(name);
|
||||
else values.delete(name);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createFixture(theme, {
|
||||
nativeShell = "light",
|
||||
analysisFixture = null,
|
||||
analysisCache = null,
|
||||
} = {}) {
|
||||
let fixtureShell = nativeShell;
|
||||
const nodes = new Map();
|
||||
const attributes = new Map();
|
||||
const bodyAttributes = new Map();
|
||||
const observers = [];
|
||||
const resizeObservers = [];
|
||||
const timers = new Map();
|
||||
let nextTimer = 1;
|
||||
let nextBlob = 1;
|
||||
const rootStyle = createStyleDeclaration();
|
||||
const root = {
|
||||
className: nativeShell === "dark" ? "electron-dark" : "electron-light",
|
||||
classList: createClassList(),
|
||||
style: rootStyle,
|
||||
appendChild(node) {
|
||||
node.parentElement = root;
|
||||
if (node.id) nodes.set(node.id, node);
|
||||
},
|
||||
getAttribute(name) { return attributes.get(name) ?? null; },
|
||||
setAttribute(name, value) { attributes.set(name, String(value)); },
|
||||
removeAttribute(name) { attributes.delete(name); },
|
||||
};
|
||||
const body = {
|
||||
className: "",
|
||||
appendChild(node) {
|
||||
node.parentElement = body;
|
||||
if (node.id) nodes.set(node.id, node);
|
||||
},
|
||||
getAttribute(name) { return bodyAttributes.get(name) ?? null; },
|
||||
setAttribute(name, value) { bodyAttributes.set(name, String(value)); },
|
||||
};
|
||||
const shellBox = { left: 280, top: 36, width: 1000, height: 764 };
|
||||
const shellMain = {
|
||||
classList: createClassList(),
|
||||
getBoundingClientRect() {
|
||||
return { ...shellBox };
|
||||
},
|
||||
};
|
||||
|
||||
const createElement = (tagName) => {
|
||||
if (tagName === "canvas" && analysisFixture) {
|
||||
return {
|
||||
width: 0,
|
||||
height: 0,
|
||||
getContext() {
|
||||
return {
|
||||
drawImage() {},
|
||||
getImageData() { return { data: analysisFixture.pixels }; },
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
const childNodes = new Map();
|
||||
const element = {
|
||||
id: "",
|
||||
dataset: {},
|
||||
style: createStyleDeclaration(),
|
||||
classList: createClassList(),
|
||||
parentElement: null,
|
||||
textContent: "",
|
||||
innerHTML: "",
|
||||
setAttribute() {},
|
||||
querySelector(selector) {
|
||||
if (!childNodes.has(selector)) childNodes.set(selector, { textContent: "" });
|
||||
return childNodes.get(selector);
|
||||
},
|
||||
remove() { if (element.id) nodes.delete(element.id); },
|
||||
};
|
||||
return element;
|
||||
};
|
||||
|
||||
const document = {
|
||||
documentElement: root,
|
||||
head: root,
|
||||
body,
|
||||
createElement,
|
||||
getElementById(id) { return nodes.get(id) ?? null; },
|
||||
querySelector(selector) {
|
||||
if (selector === "main.main-surface" || selector === "main") return shellMain;
|
||||
return null;
|
||||
},
|
||||
querySelectorAll() { return []; },
|
||||
};
|
||||
const mediaQuery = {
|
||||
matches: false,
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
};
|
||||
const revokedUrls = [];
|
||||
const window = {
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
matchMedia() {
|
||||
mediaQuery.matches = fixtureShell === "dark";
|
||||
return mediaQuery;
|
||||
},
|
||||
};
|
||||
if (analysisCache) window.__CODEX_DREAM_SKIN_ANALYSIS_CACHE__ = analysisCache;
|
||||
if (analysisFixture) {
|
||||
window.Image = class {
|
||||
naturalWidth = analysisFixture.naturalWidth;
|
||||
naturalHeight = analysisFixture.naturalHeight;
|
||||
set src(_) { this.onload(); }
|
||||
};
|
||||
}
|
||||
const context = {
|
||||
window,
|
||||
document,
|
||||
MutationObserver: class {
|
||||
constructor(callback) {
|
||||
this.callback = callback;
|
||||
observers.push(this);
|
||||
}
|
||||
observe() {}
|
||||
disconnect() {}
|
||||
},
|
||||
ResizeObserver: class {
|
||||
constructor(callback) {
|
||||
this.callback = callback;
|
||||
this.target = null;
|
||||
resizeObservers.push(this);
|
||||
}
|
||||
observe(target) { this.target = target; }
|
||||
disconnect() { this.target = null; }
|
||||
},
|
||||
URL: {
|
||||
createObjectURL() { return `blob:fixture-${nextBlob++}`; },
|
||||
revokeObjectURL(value) { revokedUrls.push(value); },
|
||||
},
|
||||
Blob,
|
||||
Uint8Array,
|
||||
atob,
|
||||
getComputedStyle() {
|
||||
const skinShell = root.classList.contains("codex-dream-skin")
|
||||
? (attributes.get("data-dream-shell") || "dark") : fixtureShell;
|
||||
return {
|
||||
colorScheme: skinShell,
|
||||
backgroundColor: fixtureShell === "dark" ? "rgb(24, 24, 27)" : "rgb(250, 250, 250)",
|
||||
};
|
||||
},
|
||||
setInterval: () => 1,
|
||||
clearInterval() {},
|
||||
setTimeout(callback, delay) {
|
||||
const id = ++nextTimer;
|
||||
timers.set(id, { callback, delay });
|
||||
return id;
|
||||
},
|
||||
clearTimeout(id) { timers.delete(id); },
|
||||
cancelAnimationFrame() {},
|
||||
};
|
||||
const payloadFor = (nextTheme, cssText = ".fixture { color: blue; }") => template
|
||||
.replace("__DREAM_SKIN_CSS_JSON__", JSON.stringify(cssText))
|
||||
.replace("__DREAM_SKIN_ART_JSON__", JSON.stringify("data:image/png;base64,AA=="))
|
||||
.replace("__DREAM_SKIN_THEME_JSON__", JSON.stringify(nextTheme))
|
||||
.replace("__DREAM_SKIN_VERSION_JSON__", JSON.stringify("test"))
|
||||
.replace("__DREAM_SKIN_STYLE_REVISION_JSON__", JSON.stringify(cssText));
|
||||
const flushTimers = (maximumDelay = Infinity) => {
|
||||
const pending = [...timers.entries()].filter(([, timer]) => timer.delay <= maximumDelay);
|
||||
for (const [id, timer] of pending) {
|
||||
timers.delete(id);
|
||||
timer.callback();
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
attributes,
|
||||
body,
|
||||
bodyAttributes,
|
||||
context,
|
||||
flushTimers,
|
||||
nodes,
|
||||
observers,
|
||||
payload: payloadFor(theme),
|
||||
payloadFor,
|
||||
revokedUrls,
|
||||
resizeObservers,
|
||||
root,
|
||||
rootStyle,
|
||||
shellBox,
|
||||
timers,
|
||||
window,
|
||||
setNativeShell(value) { fixtureShell = value; },
|
||||
};
|
||||
}
|
||||
|
||||
const defaults = createFixture({
|
||||
id: "default-contract",
|
||||
appearance: "auto",
|
||||
art: { safeArea: "auto", taskMode: "auto" },
|
||||
});
|
||||
const defaultResult = vm.runInNewContext(defaults.payload, defaults.context);
|
||||
assert.equal(defaultResult.installed, true);
|
||||
assert.equal(defaults.attributes.get("data-dream-shell"), "light");
|
||||
assert.equal(defaults.attributes.get("data-dream-art-safe-area"), "center");
|
||||
assert.equal(defaults.attributes.get("data-dream-art-task-mode"), "ambient");
|
||||
assert.equal(defaults.attributes.get("data-dream-art-ready"), "false");
|
||||
assert.equal(defaults.rootStyle.values.get("--dream-art-position"), "50.00% 50.00%");
|
||||
assert.equal(defaults.rootStyle.values.get("--ds-surface-opacity"), "1.00");
|
||||
const defaultMetrics = defaults.window.__CODEX_DREAM_SKIN_STATE__.metrics;
|
||||
assert.equal(defaultMetrics.rootPasses, 1);
|
||||
assert.equal(defaultMetrics.routePasses, 1);
|
||||
assert.equal(defaultMetrics.layoutReads, 1);
|
||||
for (let index = 0; index < 50; index += 1) defaults.observers[0].callback([]);
|
||||
assert.equal(defaults.timers.size, 1, "Mutation bursts should coalesce into one scheduled ensure.");
|
||||
defaults.flushTimers(64);
|
||||
assert.equal(defaultMetrics.rootPasses, 1, "Subtree mutations must not recompute root theme tokens.");
|
||||
assert.equal(defaultMetrics.routePasses, 2);
|
||||
assert.equal(defaultMetrics.layoutReads, 1, "Subtree mutations must not force shell layout reads.");
|
||||
assert.equal(defaults.resizeObservers.length, 1);
|
||||
assert.ok(defaults.resizeObservers[0].target);
|
||||
defaults.shellBox.left = 196;
|
||||
defaults.shellBox.width = 1084;
|
||||
defaults.resizeObservers[0].callback([]);
|
||||
defaults.flushTimers(64);
|
||||
assert.equal(defaultMetrics.layoutReads, 2, "Shell ResizeObserver changes must refresh chrome geometry.");
|
||||
const defaultChrome = defaults.nodes.get("codex-dream-skin-chrome");
|
||||
assert.equal(defaultChrome.style.values.get("left"), "196px");
|
||||
assert.equal(defaultChrome.style.values.get("width"), "1084px");
|
||||
|
||||
// Auto appearance must continue following the native shell after the skin is
|
||||
// already installed. The fixture makes the injected root color-scheme win
|
||||
// whenever our class remains on <html>, so a temporary native probe is needed
|
||||
// for each light → dark → light transition.
|
||||
const shellFollow = createFixture({
|
||||
id: "shell-follow",
|
||||
appearance: "auto",
|
||||
art: { safeArea: "auto", taskMode: "auto" },
|
||||
});
|
||||
shellFollow.root.className = "";
|
||||
vm.runInNewContext(shellFollow.payload, shellFollow.context);
|
||||
assert.equal(shellFollow.attributes.get("data-dream-shell"), "light");
|
||||
shellFollow.setNativeShell("dark");
|
||||
shellFollow.window.__CODEX_DREAM_SKIN_STATE__.ensure();
|
||||
assert.equal(shellFollow.attributes.get("data-dream-shell"), "dark");
|
||||
shellFollow.setNativeShell("light");
|
||||
shellFollow.window.__CODEX_DREAM_SKIN_STATE__.ensure();
|
||||
assert.equal(shellFollow.attributes.get("data-dream-shell"), "light");
|
||||
|
||||
defaults.root.className = "";
|
||||
defaults.body.setAttribute("data-theme", "dark");
|
||||
defaults.observers[1].callback([{ type: "attributes", target: defaults.body }]);
|
||||
defaults.flushTimers(64);
|
||||
assert.equal(defaults.attributes.get("data-dream-shell"), "dark", "Body theme changes must apply without the fallback interval.");
|
||||
|
||||
const synchronousWide = createFixture({
|
||||
id: "synchronous-wide",
|
||||
appearance: "auto",
|
||||
art: { safeArea: "auto", taskMode: "auto" },
|
||||
artKey: "wide-art",
|
||||
artMetadata: {
|
||||
width: 2400,
|
||||
height: 1350,
|
||||
ratio: 2400 / 1350,
|
||||
wide: true,
|
||||
aspect: "wide",
|
||||
taskMode: "ambient",
|
||||
},
|
||||
});
|
||||
vm.runInNewContext(synchronousWide.payload, synchronousWide.context);
|
||||
assert.equal(synchronousWide.attributes.get("data-dream-art-wide"), "true");
|
||||
assert.equal(synchronousWide.attributes.get("data-dream-art-aspect"), "wide");
|
||||
assert.equal(synchronousWide.attributes.get("data-dream-art-task-mode"), "ambient");
|
||||
assert.equal(synchronousWide.attributes.get("data-dream-art-ready"), "false");
|
||||
|
||||
const autoLandscape = createFixture({
|
||||
id: "auto-landscape-focus",
|
||||
appearance: "auto",
|
||||
art: { safeArea: "auto", taskMode: "auto" },
|
||||
artMetadata: {
|
||||
width: 1586,
|
||||
height: 992,
|
||||
ratio: 1586 / 992,
|
||||
wide: false,
|
||||
aspect: "wide",
|
||||
taskMode: "ambient",
|
||||
},
|
||||
});
|
||||
vm.runInNewContext(autoLandscape.payload, autoLandscape.context);
|
||||
assert.equal(autoLandscape.rootStyle.values.get("--dream-art-position"), "50.00% 50.00%");
|
||||
assert.equal(
|
||||
autoLandscape.rootStyle.values.get("--dream-skin-home-art-position"),
|
||||
"50.00% 28.00%",
|
||||
"An unconfigured landscape should keep upper subjects visible in the much wider home hero.",
|
||||
);
|
||||
|
||||
const cachedAnalysis = {
|
||||
width: 2400,
|
||||
height: 1350,
|
||||
ratio: 2400 / 1350,
|
||||
wide: true,
|
||||
aspect: "wide",
|
||||
taskMode: "ambient",
|
||||
safeArea: "left",
|
||||
focusX: 0.72,
|
||||
focusY: 0.48,
|
||||
accentRgb: { r: 180, g: 90, b: 110 },
|
||||
};
|
||||
const cached = createFixture({
|
||||
id: "cached-wide",
|
||||
appearance: "auto",
|
||||
art: { safeArea: "auto", taskMode: "auto" },
|
||||
artKey: "cached-art",
|
||||
artMetadata: synchronousWide.window.__CODEX_DREAM_SKIN_STATE__.artMetadata,
|
||||
}, { analysisCache: new Map([["cached-art", cachedAnalysis]]) });
|
||||
vm.runInNewContext(cached.payload, cached.context);
|
||||
assert.equal(cached.attributes.get("data-dream-art-ready"), "true");
|
||||
assert.equal(cached.attributes.get("data-dream-art-safe-area"), "left");
|
||||
assert.equal(cached.window.__CODEX_DREAM_SKIN_STATE__.metrics.analysisCacheHits, 1);
|
||||
assert.equal(cached.window.__CODEX_DREAM_SKIN_STATE__.metrics.analysisRuns, 0);
|
||||
|
||||
const previousWideState = synchronousWide.window.__CODEX_DREAM_SKIN_STATE__;
|
||||
const stableStyle = synchronousWide.nodes.get("codex-dream-skin-style");
|
||||
vm.runInNewContext(synchronousWide.payloadFor({
|
||||
id: "switched-wide",
|
||||
appearance: "dark",
|
||||
art: { safeArea: "right", taskMode: "ambient" },
|
||||
artKey: "switched-art",
|
||||
artMetadata: {
|
||||
width: 2400,
|
||||
height: 1350,
|
||||
ratio: 2400 / 1350,
|
||||
wide: true,
|
||||
aspect: "wide",
|
||||
taskMode: "ambient",
|
||||
},
|
||||
}, ".fixture { color: red; }"), synchronousWide.context);
|
||||
assert.equal(synchronousWide.nodes.get("codex-dream-skin-style"), stableStyle);
|
||||
assert.equal(stableStyle.textContent, ".fixture { color: red; }");
|
||||
assert.equal(stableStyle.dataset.dreamSkinVersion, "test");
|
||||
assert.equal(synchronousWide.rootStyle.values.get("--dream-skin-art"), 'url("blob:fixture-2")');
|
||||
assert.deepEqual(synchronousWide.revokedUrls, ["blob:fixture-1"]);
|
||||
assert.equal(previousWideState.cleanup(), false, "An old async cleanup must not remove the new theme.");
|
||||
|
||||
const brightPixels = new Uint8ClampedArray(96 * 32 * 4);
|
||||
for (let offset = 0; offset < brightPixels.length; offset += 4) {
|
||||
brightPixels[offset] = 245;
|
||||
brightPixels[offset + 1] = 224;
|
||||
brightPixels[offset + 2] = 224;
|
||||
brightPixels[offset + 3] = 255;
|
||||
}
|
||||
const nativeDark = createFixture({
|
||||
id: "native-dark-contract",
|
||||
appearance: "auto",
|
||||
art: { safeArea: "auto", taskMode: "auto" },
|
||||
}, {
|
||||
nativeShell: "dark",
|
||||
analysisFixture: { naturalWidth: 2400, naturalHeight: 800, pixels: brightPixels },
|
||||
});
|
||||
vm.runInNewContext(nativeDark.payload, nativeDark.context);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
nativeDark.window.__CODEX_DREAM_SKIN_STATE__.ensure();
|
||||
assert.equal(nativeDark.window.__CODEX_DREAM_SKIN_STATE__.analysis.shell, "light");
|
||||
assert.equal(nativeDark.attributes.get("data-dream-shell"), "dark");
|
||||
assert.match(nativeDark.rootStyle.values.get("--ds-bg"), /^#[0-9a-f]{6}$/);
|
||||
assert.ok(Number.parseInt(nativeDark.rootStyle.values.get("--ds-bg").slice(1), 16) < 0x303030);
|
||||
|
||||
const explicit = createFixture({
|
||||
id: "explicit-contract",
|
||||
appearance: "dark",
|
||||
surfaceOpacity: 0.55,
|
||||
art: { focusX: 0.15, focusY: 0.8, safeArea: "none", taskMode: "off" },
|
||||
});
|
||||
const explicitResult = vm.runInNewContext(explicit.payload, explicit.context);
|
||||
assert.equal(explicitResult.shell, "dark");
|
||||
assert.equal(explicit.attributes.get("data-dream-shell"), "dark");
|
||||
assert.equal(explicit.attributes.get("data-dream-art-safe-area"), "none");
|
||||
assert.equal(explicit.attributes.get("data-dream-art-safe"), "none");
|
||||
assert.equal(explicit.attributes.get("data-dream-art-task-mode"), "off");
|
||||
assert.equal(explicit.rootStyle.values.get("--dream-art-position"), "15.00% 80.00%");
|
||||
assert.equal(explicit.rootStyle.values.get("--dream-skin-home-art-position"), "15.00% 80.00%");
|
||||
assert.equal(explicit.rootStyle.values.get("--ds-surface-opacity"), "0.55");
|
||||
assert.equal(explicit.window.__CODEX_DREAM_SKIN_STATE__.analysis, null);
|
||||
|
||||
const banner = createFixture({
|
||||
id: "banner-contract",
|
||||
appearance: "auto",
|
||||
art: { safeArea: "left", taskMode: "banner" },
|
||||
artMetadata: {
|
||||
width: 2560,
|
||||
height: 1440,
|
||||
ratio: 2560 / 1440,
|
||||
wide: true,
|
||||
aspect: "ultrawide",
|
||||
taskMode: "banner",
|
||||
safeArea: "left",
|
||||
focusX: 0.72,
|
||||
focusY: 0.44,
|
||||
},
|
||||
});
|
||||
vm.runInNewContext(banner.payload, banner.context);
|
||||
assert.equal(banner.attributes.get("data-dream-art-wide"), "true");
|
||||
assert.equal(banner.attributes.get("data-dream-art-task-mode"), "banner");
|
||||
assert.equal(banner.attributes.get("data-dream-task-mode"), "banner");
|
||||
|
||||
assert.equal(explicit.window.__CODEX_DREAM_SKIN_STATE__.cleanup(), true);
|
||||
assert.equal(explicit.root.classList.contains("codex-dream-skin"), false);
|
||||
assert.equal(explicit.attributes.has("data-dream-shell"), false);
|
||||
assert.equal(explicit.attributes.has("data-dream-art-safe-area"), false);
|
||||
assert.equal(explicit.attributes.has("data-dream-art-task-mode"), false);
|
||||
assert.equal(explicit.rootStyle.values.has("--dream-art-position"), false);
|
||||
assert.equal(explicit.nodes.has("codex-dream-skin-style"), false);
|
||||
assert.equal(explicit.nodes.has("codex-dream-skin-chrome"), false);
|
||||
assert.deepEqual(explicit.revokedUrls, ["blob:fixture-1"]);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
assert.equal(explicit.root.classList.contains("codex-dream-skin"), false);
|
||||
assert.equal(explicit.nodes.has("codex-dream-skin-style"), false);
|
||||
assert.equal(explicit.window.__CODEX_DREAM_SKIN_STATE__, undefined);
|
||||
|
||||
console.log("PASS: renderer honors adaptive art metadata, fallback, and cleanup behavior.");
|
||||
@@ -1,4 +0,0 @@
|
||||
await import("./image-metadata.test.mjs");
|
||||
await import("./injector-bootstrap.test.mjs");
|
||||
await import("./renderer-inject.test.mjs");
|
||||
await import("./theme-stage.test.mjs");
|
||||
@@ -1,85 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { spawn } from "node:child_process";
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const macosRoot = path.resolve(here, "..");
|
||||
const stageScript = path.join(macosRoot, "scripts", "stage-theme.mjs");
|
||||
const fixtureAsset = path.join(macosRoot, "tests", "fixtures", "background.jpg");
|
||||
const tempRoot = await fs.mkdtemp(path.join("/tmp", "codex-dream-skin-stage-"));
|
||||
|
||||
function runStage(source, stage) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(process.execPath, [stageScript, source, stage], {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
child.stdout.on("data", (chunk) => { stdout += chunk; });
|
||||
child.stderr.on("data", (chunk) => { stderr += chunk; });
|
||||
child.once("error", reject);
|
||||
child.once("close", (code) => {
|
||||
if (code === 0) resolve(stdout);
|
||||
else reject(new Error(stderr || `stage-theme exited with ${code}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const source = path.join(tempRoot, "themes", "preset-race");
|
||||
const stage = path.join(tempRoot, "stage");
|
||||
await fs.mkdir(source, { recursive: true });
|
||||
await fs.mkdir(stage);
|
||||
await fs.copyFile(fixtureAsset, path.join(source, "background-a.png"));
|
||||
await fs.writeFile(
|
||||
path.join(source, "theme.json"),
|
||||
`${JSON.stringify({ schemaVersion: 1, id: "preset-race", name: "A", image: "background-a.png" })}\n`,
|
||||
);
|
||||
|
||||
const imageName = await runStage(source, stage);
|
||||
assert.equal(imageName, "background-a.png");
|
||||
const stagedConfig = JSON.parse(await fs.readFile(path.join(stage, "theme.json"), "utf8"));
|
||||
assert.equal(stagedConfig.image, "background-a.png");
|
||||
const stagedBeforeMutation = await fs.readFile(path.join(stage, "background-a.png"));
|
||||
|
||||
// A source edit after staging must not change the pair that is about to be
|
||||
// published. This is the regression for switch-theme's old copy-after-
|
||||
// validation TOCTOU window.
|
||||
await fs.copyFile(fixtureAsset, path.join(source, "background-b.png"));
|
||||
await fs.writeFile(
|
||||
path.join(source, "theme.json"),
|
||||
`${JSON.stringify({ schemaVersion: 1, id: "preset-race", name: "B", image: "background-b.png" })}\n`,
|
||||
);
|
||||
await fs.writeFile(path.join(source, "background-a.png"), Buffer.from("changed-after-stage"));
|
||||
assert.deepEqual(await fs.readFile(path.join(stage, "background-a.png")), stagedBeforeMutation);
|
||||
assert.equal(JSON.parse(await fs.readFile(path.join(stage, "theme.json"), "utf8")).name, "A");
|
||||
|
||||
const outside = path.join(tempRoot, "outside.png");
|
||||
await fs.copyFile(fixtureAsset, outside);
|
||||
const traversal = path.join(tempRoot, "traversal");
|
||||
await fs.mkdir(traversal);
|
||||
await fs.writeFile(
|
||||
path.join(traversal, "theme.json"),
|
||||
`${JSON.stringify({ schemaVersion: 1, id: "bad", image: "../outside.png" })}\n`,
|
||||
);
|
||||
const traversalStage = path.join(tempRoot, "traversal-stage");
|
||||
await fs.mkdir(traversalStage);
|
||||
await assert.rejects(runStage(traversal, traversalStage), /inside its theme directory/);
|
||||
|
||||
const symlink = path.join(tempRoot, "symlink");
|
||||
await fs.mkdir(symlink);
|
||||
await fs.symlink(outside, path.join(symlink, "background.png"));
|
||||
await fs.writeFile(
|
||||
path.join(symlink, "theme.json"),
|
||||
`${JSON.stringify({ schemaVersion: 1, id: "bad-link", image: "background.png" })}\n`,
|
||||
);
|
||||
const symlinkStage = path.join(tempRoot, "symlink-stage");
|
||||
await fs.mkdir(symlinkStage);
|
||||
await assert.rejects(runStage(symlink, symlinkStage), /symbolic link/);
|
||||
|
||||
console.log("PASS: theme staging snapshots a matched, contained config/image pair.");
|
||||
} finally {
|
||||
await fs.rm(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
@@ -1,186 +0,0 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[ValidateSet('inspect', 'selectPort', 'launch', 'stopCodex', 'launchNormal', 'verifyPort', 'processInfo', 'injectorStatus', 'stopInjector')]
|
||||
[string]$Action,
|
||||
[int]$Port = 9341,
|
||||
[int]$TargetPid = 0,
|
||||
[string]$StatePath
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$ProgressPreference = 'SilentlyContinue'
|
||||
. (Join-Path $PSScriptRoot 'common-windows.ps1')
|
||||
|
||||
function Write-CodexxJson {
|
||||
param([Parameter(Mandatory = $true)][object]$Value)
|
||||
$Value | ConvertTo-Json -Depth 8 -Compress
|
||||
}
|
||||
|
||||
function Get-CodexxState {
|
||||
if (-not $StatePath -or -not (Test-Path -LiteralPath $StatePath -PathType Leaf)) {
|
||||
throw 'Codex-X skin state file is missing.'
|
||||
}
|
||||
return Get-Content -LiteralPath $StatePath -Raw -Encoding UTF8 | ConvertFrom-Json -ErrorAction Stop
|
||||
}
|
||||
|
||||
function Get-CodexxDebugIdentity {
|
||||
param([Parameter(Mandatory = $true)][object]$Codex)
|
||||
foreach ($process in @(Get-DreamSkinCodexProcesses -Codex $Codex)) {
|
||||
$commandLine = "$($process.CommandLine)"
|
||||
foreach ($match in [regex]::Matches($commandLine, '(?i)(?:^|\s)--remote-debugging-port(?:=|\s+)(?<port>[0-9]{4,5})(?=$|\s)')) {
|
||||
$candidate = [int]$match.Groups['port'].Value
|
||||
if ($candidate -lt 1024 -or $candidate -gt 65535) { continue }
|
||||
$identity = Get-DreamSkinVerifiedCdpIdentity -Port $candidate -Codex $Codex
|
||||
if ($null -ne $identity) {
|
||||
return [pscustomobject]@{ Port = $candidate; Identity = $identity }
|
||||
}
|
||||
}
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
function Test-CodexxInjectorState {
|
||||
param([Parameter(Mandatory = $true)][object]$State)
|
||||
if (-not $State.injectorPid -or -not $State.injectorStartedAt -or
|
||||
-not $State.injectorPath -or -not $State.nodePath -or -not $State.port) {
|
||||
return $false
|
||||
}
|
||||
$processId = [int]$State.injectorPid
|
||||
$process = Get-CimInstance Win32_Process -Filter "ProcessId = $processId" -ErrorAction SilentlyContinue
|
||||
if ($null -eq $process) { return $false }
|
||||
$processPath = Get-DreamSkinProcessExecutablePath -ProcessInfo $process
|
||||
$commandLine = "$($process.CommandLine)"
|
||||
$startedAt = Get-DreamSkinProcessStartedAt -ProcessId $processId
|
||||
$portPattern = '(?i)(?:^|\s)--port(?:=|\s+)' + [regex]::Escape("$($State.port)") + '(?=$|\s)'
|
||||
return [bool](
|
||||
$processPath -and
|
||||
([System.IO.Path]::GetFileName($processPath) -ieq 'node.exe') -and
|
||||
(Test-DreamSkinPathEqual -Left $processPath -Right "$($State.nodePath)") -and
|
||||
(Test-DreamSkinCommandLineToken -CommandLine $commandLine -Token "$($State.injectorPath)") -and
|
||||
(Test-DreamSkinCommandLineToken -CommandLine $commandLine -Token '--watch') -and
|
||||
[regex]::IsMatch($commandLine, $portPattern) -and
|
||||
$startedAt -eq "$($State.injectorStartedAt)"
|
||||
)
|
||||
}
|
||||
|
||||
$operationLock = Enter-DreamSkinOperationLock
|
||||
try {
|
||||
switch ($Action) {
|
||||
'inspect' {
|
||||
$codex = Get-DreamSkinCodexInstall
|
||||
$processes = @(Get-DreamSkinCodexProcesses -Codex $codex)
|
||||
$debug = Get-CodexxDebugIdentity -Codex $codex
|
||||
$watcherPids = @(Get-CimInstance Win32_Process -Filter "Name = 'node.exe'" -ErrorAction SilentlyContinue |
|
||||
Where-Object {
|
||||
$_.CommandLine -and
|
||||
[regex]::IsMatch("$($_.CommandLine)", '(?i)(?:^|\s)--watch(?=$|\s)') -and
|
||||
"$($_.CommandLine)".IndexOf('injector.mjs', [System.StringComparison]::OrdinalIgnoreCase) -ge 0
|
||||
} | ForEach-Object { [int]$_.ProcessId })
|
||||
Write-CodexxJson ([pscustomobject]@{
|
||||
packageRoot = "$($codex.PackageRoot)"
|
||||
executable = "$($codex.Executable)"
|
||||
version = "$($codex.Version)"
|
||||
packageFullName = "$($codex.PackageFullName)"
|
||||
packageFamilyName = "$($codex.PackageFamilyName)"
|
||||
appUserModelId = "$($codex.AppUserModelId)"
|
||||
running = $processes.Count -gt 0
|
||||
debugPort = if ($null -eq $debug) { $null } else { [int]$debug.Port }
|
||||
browserId = if ($null -eq $debug) { $null } else { "$($debug.Identity.BrowserId)" }
|
||||
watcherPids = $watcherPids
|
||||
})
|
||||
break
|
||||
}
|
||||
'selectPort' {
|
||||
Assert-DreamSkinPort -Port $Port
|
||||
Write-CodexxJson ([pscustomobject]@{ port = (Select-DreamSkinPort -PreferredPort $Port) })
|
||||
break
|
||||
}
|
||||
'launch' {
|
||||
Assert-DreamSkinPort -Port $Port
|
||||
$codex = Get-DreamSkinCodexInstall
|
||||
$preserved = @(Get-DreamSkinCodexProcesses -Codex $codex | ForEach-Object { [int]$_.ProcessId })
|
||||
$arguments = @('--remote-debugging-address=127.0.0.1', "--remote-debugging-port=$Port")
|
||||
try {
|
||||
$launch = Start-DreamSkinCodexForDebugging -Codex $codex -Arguments $arguments `
|
||||
-Port $Port -PreserveProcessIds $preserved
|
||||
$deadline = (Get-Date).AddSeconds(45)
|
||||
$identity = Get-DreamSkinVerifiedCdpIdentity -Port $Port -Codex $codex
|
||||
while ($null -eq $identity -and (Get-Date) -lt $deadline) {
|
||||
Start-Sleep -Milliseconds 350
|
||||
$identity = Get-DreamSkinVerifiedCdpIdentity -Port $Port -Codex $codex
|
||||
}
|
||||
if ($null -eq $identity) {
|
||||
throw "Codex did not expose a verified loopback CDP endpoint on port $Port within 45 seconds."
|
||||
}
|
||||
Write-CodexxJson ([pscustomobject]@{
|
||||
port = $Port
|
||||
browserId = "$($identity.BrowserId)"
|
||||
strategy = "$($launch.Strategy)"
|
||||
})
|
||||
} catch {
|
||||
try { Stop-DreamSkinCodex -Codex $codex -PreserveProcessIds $preserved -AllowForce } catch {}
|
||||
if ($preserved.Count -eq 0 -and (Get-DreamSkinCodexProcesses -Codex $codex).Count -eq 0) {
|
||||
try { $null = Start-DreamSkinCodex -Codex $codex } catch {}
|
||||
}
|
||||
throw
|
||||
}
|
||||
break
|
||||
}
|
||||
'stopCodex' {
|
||||
$codex = Get-DreamSkinCodexInstall
|
||||
Stop-DreamSkinCodex -Codex $codex -AllowForce
|
||||
Write-CodexxJson ([pscustomobject]@{ stopped = $true })
|
||||
break
|
||||
}
|
||||
'launchNormal' {
|
||||
$codex = Get-DreamSkinCodexInstall
|
||||
$processId = Start-DreamSkinCodex -Codex $codex
|
||||
Write-CodexxJson ([pscustomobject]@{ processId = [int]$processId })
|
||||
break
|
||||
}
|
||||
'verifyPort' {
|
||||
Assert-DreamSkinPort -Port $Port
|
||||
$codex = Get-DreamSkinCodexInstall
|
||||
$identity = Get-DreamSkinVerifiedCdpIdentity -Port $Port -Codex $codex
|
||||
Write-CodexxJson ([pscustomobject]@{
|
||||
verified = $null -ne $identity
|
||||
browserId = if ($null -eq $identity) { $null } else { "$($identity.BrowserId)" }
|
||||
})
|
||||
break
|
||||
}
|
||||
'processInfo' {
|
||||
if ($TargetPid -le 0) { throw 'Target PID must be positive.' }
|
||||
$process = Get-CimInstance Win32_Process -Filter "ProcessId = $TargetPid" -ErrorAction SilentlyContinue
|
||||
if ($null -eq $process) {
|
||||
Write-CodexxJson ([pscustomobject]@{ alive = $false })
|
||||
break
|
||||
}
|
||||
Write-CodexxJson ([pscustomobject]@{
|
||||
alive = $true
|
||||
path = (Get-DreamSkinProcessExecutablePath -ProcessInfo $process)
|
||||
commandLine = "$($process.CommandLine)"
|
||||
startedAt = (Get-DreamSkinProcessStartedAt -ProcessId $TargetPid)
|
||||
})
|
||||
break
|
||||
}
|
||||
'injectorStatus' {
|
||||
$state = Get-CodexxState
|
||||
Write-CodexxJson ([pscustomobject]@{ active = (Test-CodexxInjectorState -State $state) })
|
||||
break
|
||||
}
|
||||
'stopInjector' {
|
||||
$state = Get-CodexxState
|
||||
if (-not (Test-CodexxInjectorState -State $state)) {
|
||||
$process = Get-Process -Id ([int]$state.injectorPid) -ErrorAction SilentlyContinue
|
||||
if ($null -ne $process) { throw 'The recorded skin injector identity does not match; it was not stopped.' }
|
||||
} else {
|
||||
$null = Stop-DreamSkinRecordedInjector -State $state
|
||||
}
|
||||
Write-CodexxJson ([pscustomobject]@{ stopped = $true })
|
||||
break
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
Exit-DreamSkinOperationLock -Mutex $operationLock
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,602 +0,0 @@
|
||||
$script:DreamSkinUtf8NoBom = [System.Text.UTF8Encoding]::new($false, $true)
|
||||
$script:DreamSkinLegacyAppearanceTheme = 'appearanceTheme = "light"'
|
||||
$script:DreamSkinManagedLightCodeTheme = 'appearanceLightCodeThemeId = "codex"'
|
||||
$script:DreamSkinManagedLightChromeTheme = 'appearanceLightChromeTheme = { accent = "#B65CFF", contrast = 64, fonts = { code = "Cascadia Code", ui = "Microsoft YaHei UI" }, ink = "#4A235F", opaqueWindows = true, semanticColors = { diffAdded = "#BCE8CF", diffRemoved = "#F7B8CE", skill = "#C47BFF" }, surface = "#FFF4FA" }'
|
||||
|
||||
function ConvertFrom-DreamSkinUtf8Bytes {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][AllowEmptyCollection()][byte[]]$Bytes,
|
||||
[Parameter(Mandatory = $true)][string]$Path
|
||||
)
|
||||
|
||||
try {
|
||||
$offset = if ($Bytes.Length -ge 3 -and $Bytes[0] -eq 0xEF -and $Bytes[1] -eq 0xBB -and $Bytes[2] -eq 0xBF) { 3 } else { 0 }
|
||||
$content = $script:DreamSkinUtf8NoBom.GetString($Bytes, $offset, $Bytes.Length - $offset)
|
||||
if ($content.IndexOf([char]0) -ge 0) {
|
||||
throw "Refusing to rewrite a config file containing NUL characters (possibly BOM-less UTF-16): $Path"
|
||||
}
|
||||
return $content
|
||||
} catch [System.Text.DecoderFallbackException] {
|
||||
throw "Refusing to rewrite a config file that is not valid UTF-8: $Path"
|
||||
}
|
||||
}
|
||||
|
||||
function Test-DreamSkinBytesEqual {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][AllowEmptyCollection()][byte[]]$Left,
|
||||
[Parameter(Mandatory = $true)][AllowEmptyCollection()][byte[]]$Right
|
||||
)
|
||||
if ($Left.Length -ne $Right.Length) { return $false }
|
||||
for ($index = 0; $index -lt $Left.Length; $index++) {
|
||||
if ($Left[$index] -ne $Right[$index]) { return $false }
|
||||
}
|
||||
return $true
|
||||
}
|
||||
|
||||
function Assert-DreamSkinFileUnchanged {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Path,
|
||||
[AllowNull()][byte[]]$ExpectedBytes
|
||||
)
|
||||
if ($null -eq $ExpectedBytes) {
|
||||
if (Test-Path -LiteralPath $Path) { throw "File changed during the operation; retry without other writers: $Path" }
|
||||
return
|
||||
}
|
||||
if (-not (Test-Path -LiteralPath $Path)) { throw "File disappeared during the operation; retry: $Path" }
|
||||
$currentBytes = [System.IO.File]::ReadAllBytes($Path)
|
||||
if (-not (Test-DreamSkinBytesEqual -Left $ExpectedBytes -Right $currentBytes)) {
|
||||
throw "File changed during the operation; retry without other writers: $Path"
|
||||
}
|
||||
}
|
||||
|
||||
function Get-DreamSkinNewLine {
|
||||
param([Parameter(Mandatory = $true)][AllowEmptyString()][string]$Content)
|
||||
if ($Content.Contains("`r`n")) { return "`r`n" }
|
||||
return "`n"
|
||||
}
|
||||
|
||||
function Read-DreamSkinUtf8File {
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Path
|
||||
)
|
||||
|
||||
$bytes = [System.IO.File]::ReadAllBytes($Path)
|
||||
return (ConvertFrom-DreamSkinUtf8Bytes -Bytes $bytes -Path $Path)
|
||||
}
|
||||
|
||||
function Write-DreamSkinUtf8FileAtomically {
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Path,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[AllowEmptyString()]
|
||||
[string]$Content,
|
||||
|
||||
[AllowNull()]
|
||||
[byte[]]$ExpectedBytes
|
||||
)
|
||||
|
||||
$bytes = $script:DreamSkinUtf8NoBom.GetBytes($Content)
|
||||
if ($PSBoundParameters.ContainsKey('ExpectedBytes')) {
|
||||
Write-DreamSkinBytesAtomically -Path $Path -Bytes $bytes -ExpectedBytes $ExpectedBytes
|
||||
} else {
|
||||
Write-DreamSkinBytesAtomically -Path $Path -Bytes $bytes
|
||||
}
|
||||
}
|
||||
|
||||
function Remove-DreamSkinAtomicArtifact {
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Path
|
||||
)
|
||||
|
||||
if ([System.IO.File]::Exists($Path)) {
|
||||
[System.IO.File]::Delete($Path)
|
||||
}
|
||||
}
|
||||
|
||||
function Write-DreamSkinBytesAtomically {
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Path,
|
||||
[Parameter(Mandatory = $true)][AllowEmptyCollection()][byte[]]$Bytes,
|
||||
[AllowNull()][byte[]]$ExpectedBytes
|
||||
)
|
||||
|
||||
$fullPath = [System.IO.Path]::GetFullPath($Path)
|
||||
$directory = [System.IO.Path]::GetDirectoryName($fullPath)
|
||||
if (-not [System.IO.Directory]::Exists($directory)) {
|
||||
[System.IO.Directory]::CreateDirectory($directory) | Out-Null
|
||||
}
|
||||
$fileName = [System.IO.Path]::GetFileName($fullPath)
|
||||
$operationId = "$PID.$([guid]::NewGuid().ToString('N'))"
|
||||
$temporary = Join-Path $directory ".$fileName.$operationId.tmp"
|
||||
$replacementBackup = Join-Path $directory ".$fileName.$operationId.replace-backup"
|
||||
|
||||
try {
|
||||
[System.IO.File]::WriteAllBytes($temporary, $Bytes)
|
||||
if ($PSBoundParameters.ContainsKey('ExpectedBytes')) {
|
||||
Assert-DreamSkinFileUnchanged -Path $fullPath -ExpectedBytes $ExpectedBytes
|
||||
}
|
||||
if ([System.IO.File]::Exists($fullPath)) {
|
||||
[System.IO.File]::Replace($temporary, $fullPath, $replacementBackup)
|
||||
} else {
|
||||
[System.IO.File]::Move($temporary, $fullPath)
|
||||
}
|
||||
} finally {
|
||||
foreach ($artifact in @($temporary, $replacementBackup)) {
|
||||
try {
|
||||
Remove-DreamSkinAtomicArtifact -Path $artifact
|
||||
} catch {
|
||||
try {
|
||||
Write-Warning "Could not remove temporary atomic config artifact '$artifact': $($_.Exception.Message)"
|
||||
} catch {
|
||||
# Cleanup must never mask the result of the atomic write.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Get-DreamSkinTomlKeyTokenPattern {
|
||||
param([Parameter(Mandatory = $true)][string]$Key)
|
||||
$bare = [regex]::Escape($Key)
|
||||
$doubleQuoted = [regex]::Escape('"' + $Key + '"')
|
||||
$singleQuoted = [regex]::Escape("'" + $Key + "'")
|
||||
return "(?:$bare|$doubleQuoted|$singleQuoted)"
|
||||
}
|
||||
|
||||
function ConvertTo-DreamSkinTomlAsciiEscapeProbe {
|
||||
param([Parameter(Mandatory = $true)][AllowEmptyString()][string]$Value)
|
||||
|
||||
$result = $Value
|
||||
$characters = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-'.ToCharArray()
|
||||
foreach ($character in $characters) {
|
||||
$code = ([int][char]$character).ToString('x2')
|
||||
$pattern = '(?i)\\(?:u00' + $code + '|U000000' + $code + ')'
|
||||
$result = [regex]::Replace($result, $pattern, [string]$character)
|
||||
}
|
||||
return $result
|
||||
}
|
||||
|
||||
function Get-DreamSkinTomlArrayBracketBalance {
|
||||
param([Parameter(Mandatory = $true)][AllowEmptyString()][string]$Line)
|
||||
|
||||
$quote = $null
|
||||
$escaped = $false
|
||||
$balance = 0
|
||||
for ($index = 0; $index -lt $Line.Length; $index++) {
|
||||
$character = $Line[$index]
|
||||
if ($null -eq $quote) {
|
||||
if ($character -eq '#') { break }
|
||||
if ($character -eq '"' -or $character -eq "'") { $quote = $character }
|
||||
elseif ($character -eq '[') { $balance++ }
|
||||
elseif ($character -eq ']') { $balance-- }
|
||||
continue
|
||||
}
|
||||
if ($quote -eq '"') {
|
||||
if ($escaped) { $escaped = $false; continue }
|
||||
if ($character -eq '\') { $escaped = $true; continue }
|
||||
}
|
||||
if ($character -eq $quote) { $quote = $null }
|
||||
}
|
||||
return $balance
|
||||
}
|
||||
|
||||
function Assert-DreamSkinTomlLineEditingSafe {
|
||||
param([Parameter(Mandatory = $true)][AllowEmptyString()][string]$Content)
|
||||
|
||||
if ($Content.Contains('"""') -or $Content.Contains("'''")) {
|
||||
throw 'Refusing to rewrite TOML containing multiline strings; use single-line values before installing Dream Skin.'
|
||||
}
|
||||
foreach ($match in [regex]::Matches($Content, '(?m)^[^\r\n]*=[\t ]*\[[^\r\n]*\r?$')) {
|
||||
if ((Get-DreamSkinTomlArrayBracketBalance -Line $match.Value) -ne 0) {
|
||||
throw 'Refusing to rewrite TOML containing multiline arrays; use single-line arrays before installing Dream Skin.'
|
||||
}
|
||||
}
|
||||
|
||||
$probe = ConvertTo-DreamSkinTomlAsciiEscapeProbe -Value $Content
|
||||
if ($probe -cne $Content) {
|
||||
$desktopToken = Get-DreamSkinTomlKeyTokenPattern -Key 'desktop'
|
||||
$desktopShape = "(?m)^[\t ]*(?:\[\[?[\t ]*$desktopToken[\t ]*(?:\]|\.)|$desktopToken[\t ]*(?:\.|=))"
|
||||
$rawDesktopShapes = [regex]::Matches($Content, $desktopShape).Count
|
||||
$probedDesktopShapes = [regex]::Matches($probe, $desktopShape).Count
|
||||
if ($probedDesktopShapes -gt $rawDesktopShapes) {
|
||||
throw 'Refusing to rewrite an escaped TOML key equivalent to desktop; normalize the key spelling first.'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Get-DreamSkinDesktopSectionPattern {
|
||||
$desktopToken = Get-DreamSkinTomlKeyTokenPattern -Key 'desktop'
|
||||
return "(?ms)^[\t ]*\[[\t ]*$desktopToken[\t ]*\][\t ]*(?:#[^\r\n]*)?(?:\r?\n|(?=\z))(?<body>.*?)(?=^[\t ]*\[\[?|\z)"
|
||||
}
|
||||
|
||||
function Test-DreamSkinDesktopNestedTable {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][AllowEmptyString()][string]$Content,
|
||||
[Parameter(Mandatory = $true)][string]$Key
|
||||
)
|
||||
|
||||
$desktopToken = Get-DreamSkinTomlKeyTokenPattern -Key 'desktop'
|
||||
$keyToken = Get-DreamSkinTomlKeyTokenPattern -Key $Key
|
||||
return [regex]::IsMatch(
|
||||
$Content,
|
||||
"(?m)^[\t ]*\[[\t ]*$desktopToken[\t ]*\.[\t ]*$keyToken[\t ]*(?:\]|\.)"
|
||||
)
|
||||
}
|
||||
|
||||
function Assert-DreamSkinDesktopShapeSupported {
|
||||
param([Parameter(Mandatory = $true)][AllowEmptyString()][string]$Content)
|
||||
|
||||
Assert-DreamSkinTomlLineEditingSafe -Content $Content
|
||||
$sectionPattern = Get-DreamSkinDesktopSectionPattern
|
||||
if ([regex]::Matches($Content, $sectionPattern).Count -gt 1) {
|
||||
throw 'Refusing to rewrite multiple equivalent [desktop] tables.'
|
||||
}
|
||||
|
||||
$desktopToken = Get-DreamSkinTomlKeyTokenPattern -Key 'desktop'
|
||||
if ([regex]::IsMatch($Content, "(?m)^[\t ]*\[\[[\t ]*$desktopToken[\t ]*(?:\]\]|\.)")) {
|
||||
throw 'Refusing to rewrite a config that represents desktop as an array of tables.'
|
||||
}
|
||||
foreach ($key in @('appearanceTheme', 'appearanceLightCodeThemeId')) {
|
||||
if (Test-DreamSkinDesktopNestedTable -Content $Content -Key $key) {
|
||||
throw "Refusing to replace '$key' because it is represented as a nested desktop table."
|
||||
}
|
||||
}
|
||||
|
||||
$firstTable = [regex]::Match($Content, '(?m)^[\t ]*\[\[?')
|
||||
$rootContent = if ($firstTable.Success) { $Content.Substring(0, $firstTable.Index) } else { $Content }
|
||||
if ([regex]::IsMatch($rootContent, "(?m)^[\t ]*$desktopToken[\t ]*(?:\.|=)")) {
|
||||
throw 'Refusing to rewrite root dotted or inline desktop keys; normalize them to a [desktop] table first.'
|
||||
}
|
||||
|
||||
$desktop = Get-DreamSkinDesktopSection -Content $Content
|
||||
if ($null -ne $desktop) {
|
||||
$bodyProbe = ConvertTo-DreamSkinTomlAsciiEscapeProbe -Value $desktop.Body
|
||||
foreach ($key in @('appearanceTheme', 'appearanceLightCodeThemeId', 'appearanceLightChromeTheme')) {
|
||||
$keyToken = Get-DreamSkinTomlKeyTokenPattern -Key $key
|
||||
$settingShape = "(?m)^[\t ]*$keyToken[\t ]*(?:\.|=)"
|
||||
if ($key -eq 'appearanceLightChromeTheme' -and
|
||||
(Test-DreamSkinDesktopNestedTable -Content $Content -Key $key) -and
|
||||
[regex]::IsMatch($desktop.Body, $settingShape)) {
|
||||
throw "Refusing to rewrite '$key' because both a scalar and nested table are present."
|
||||
}
|
||||
if ([regex]::Matches($bodyProbe, $settingShape).Count -gt
|
||||
[regex]::Matches($desktop.Body, $settingShape).Count) {
|
||||
throw "Refusing to rewrite an escaped TOML key equivalent to '$key'."
|
||||
}
|
||||
if ([regex]::IsMatch($desktop.Body, "(?m)^[\t ]*$keyToken[\t ]*\.")) {
|
||||
throw "Refusing to replace dotted '$key' keys in the [desktop] table."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Get-DreamSkinDesktopSection {
|
||||
param([Parameter(Mandatory = $true)][AllowEmptyString()][string]$Content)
|
||||
|
||||
$match = [regex]::Match($Content, (Get-DreamSkinDesktopSectionPattern))
|
||||
if (-not $match.Success) { return $null }
|
||||
return [pscustomobject]@{
|
||||
Body = $match.Groups['body'].Value
|
||||
BodyStart = $match.Groups['body'].Index
|
||||
BodyLength = $match.Groups['body'].Length
|
||||
SectionStart = $match.Index
|
||||
SectionLength = $match.Length
|
||||
}
|
||||
}
|
||||
|
||||
function Add-DreamSkinDesktopSection {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][AllowEmptyString()][string]$Content,
|
||||
[Parameter(Mandatory = $true)][string]$NewLine
|
||||
)
|
||||
|
||||
if ($Content.Length -eq 0) { return "[desktop]$NewLine" }
|
||||
$separator = if ($Content.EndsWith("`n")) { $NewLine } else { $NewLine + $NewLine }
|
||||
return $Content + $separator + "[desktop]$NewLine"
|
||||
}
|
||||
|
||||
function Set-DreamSkinSectionSetting {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][AllowEmptyString()][string]$Body,
|
||||
[Parameter(Mandatory = $true)][string]$Key,
|
||||
[AllowNull()][object]$Line,
|
||||
[Parameter(Mandatory = $true)][string]$NewLine
|
||||
)
|
||||
|
||||
$keyToken = Get-DreamSkinTomlKeyTokenPattern -Key $Key
|
||||
$pattern = "(?m)^[\t ]*$keyToken[\t ]*=[^\r\n]*(?:\r?\n|(?=\z))"
|
||||
$matcher = [regex]::new($pattern)
|
||||
if ($matcher.Matches($Body).Count -gt 1) {
|
||||
throw "Refusing to rewrite duplicate '$Key' entries in the [desktop] section."
|
||||
}
|
||||
if ($null -eq $Line) { return $matcher.Replace($Body, '', 1) }
|
||||
$normalizedLine = $Line.TrimEnd("`r", "`n") + $NewLine
|
||||
if ($matcher.IsMatch($Body)) {
|
||||
$literalReplacement = $normalizedLine.Replace('$', '$$')
|
||||
return $matcher.Replace($Body, $literalReplacement, 1)
|
||||
}
|
||||
$separator = if ($Body.Length -eq 0 -or $Body.EndsWith("`n")) { '' } else { $NewLine }
|
||||
return $Body + $separator + $normalizedLine
|
||||
}
|
||||
|
||||
function Get-DreamSkinSectionSettingLine {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][AllowEmptyString()][string]$Body,
|
||||
[Parameter(Mandatory = $true)][string]$Key
|
||||
)
|
||||
$keyToken = Get-DreamSkinTomlKeyTokenPattern -Key $Key
|
||||
$matches = [regex]::Matches($Body, "(?m)^[\t ]*$keyToken[\t ]*=.*$")
|
||||
if ($matches.Count -gt 1) { throw "Refusing to inspect duplicate '$Key' entries in the [desktop] section." }
|
||||
if ($matches.Count -eq 0) { return $null }
|
||||
return $matches[0].Value.Trim()
|
||||
}
|
||||
|
||||
function Test-DreamSkinLegacyManagedLightTrio {
|
||||
param([Parameter(Mandatory = $true)][AllowEmptyString()][string]$Content)
|
||||
$desktop = Get-DreamSkinDesktopSection -Content $Content
|
||||
if ($null -eq $desktop) { return $false }
|
||||
return (
|
||||
(Get-DreamSkinSectionSettingLine -Body $desktop.Body -Key 'appearanceTheme') -ceq
|
||||
$script:DreamSkinLegacyAppearanceTheme -and
|
||||
(Get-DreamSkinSectionSettingLine -Body $desktop.Body -Key 'appearanceLightCodeThemeId') -ceq
|
||||
$script:DreamSkinManagedLightCodeTheme -and
|
||||
(Get-DreamSkinSectionSettingLine -Body $desktop.Body -Key 'appearanceLightChromeTheme') -ceq
|
||||
$script:DreamSkinManagedLightChromeTheme
|
||||
)
|
||||
}
|
||||
|
||||
function Get-DreamSkinAppearanceMarkerPath {
|
||||
param([Parameter(Mandatory = $true)][string]$BackupPath)
|
||||
return "$BackupPath.appearance.json"
|
||||
}
|
||||
|
||||
function Read-DreamSkinAppearanceMarker {
|
||||
param([Parameter(Mandatory = $true)][string]$BackupPath)
|
||||
$markerPath = Get-DreamSkinAppearanceMarkerPath -BackupPath $BackupPath
|
||||
if (-not (Test-Path -LiteralPath $markerPath)) { return $null }
|
||||
try {
|
||||
$marker = (Read-DreamSkinUtf8File -Path $markerPath) | ConvertFrom-Json -ErrorAction Stop
|
||||
} catch {
|
||||
throw "Dream Skin appearance marker is unreadable; config was preserved: $markerPath"
|
||||
}
|
||||
if ($null -eq $marker -or $marker -is [string] -or $marker -is [array]) {
|
||||
throw "Dream Skin appearance marker is invalid; config was preserved: $markerPath"
|
||||
}
|
||||
$schemaVersion = 0
|
||||
try { $schemaVersion = [int]$marker.schemaVersion } catch { $schemaVersion = 0 }
|
||||
# v1 markers are always unmanaged; v2 markers may pin appearanceTheme.
|
||||
$validUnmanagedV1 = $schemaVersion -eq 1 -and $marker.appearanceThemeManaged -is [bool] -and
|
||||
-not [bool]$marker.appearanceThemeManaged
|
||||
$validV2 = $schemaVersion -eq 2 -and $marker.appearanceThemeManaged -is [bool]
|
||||
if (-not ($validUnmanagedV1 -or $validV2)) {
|
||||
throw "Dream Skin appearance marker is invalid; config was preserved: $markerPath"
|
||||
}
|
||||
return $marker
|
||||
}
|
||||
|
||||
function Write-DreamSkinAppearanceMarker {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$BackupPath,
|
||||
[bool]$Managed = $false
|
||||
)
|
||||
$markerPath = Get-DreamSkinAppearanceMarkerPath -BackupPath $BackupPath
|
||||
if (Get-Command Assert-DreamSkinNoReparseComponents -ErrorAction SilentlyContinue) {
|
||||
Assert-DreamSkinNoReparseComponents -Path $markerPath
|
||||
}
|
||||
# Unmanaged markers keep the v1 shape older engines accept; managed pins use
|
||||
# schemaVersion 2, which older engines conservatively refuse to act on.
|
||||
$schemaVersion = 1
|
||||
if ($Managed) { $schemaVersion = 2 }
|
||||
$marker = [ordered]@{
|
||||
schemaVersion = $schemaVersion
|
||||
appearanceThemeManaged = $Managed
|
||||
} | ConvertTo-Json
|
||||
Write-DreamSkinUtf8FileAtomically -Path $markerPath -Content ($marker + "`r`n")
|
||||
}
|
||||
|
||||
function Install-DreamSkinBaseTheme {
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$ConfigPath,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$BackupPath,
|
||||
|
||||
[ValidateSet('auto', 'light', 'dark')]
|
||||
[string]$AppearanceTheme = 'auto'
|
||||
)
|
||||
|
||||
if (-not (Test-Path -LiteralPath $ConfigPath)) { throw "Codex config not found: $ConfigPath" }
|
||||
if (Get-Command Assert-DreamSkinNoReparseComponents -ErrorAction SilentlyContinue) {
|
||||
Assert-DreamSkinNoReparseComponents -Path $BackupPath
|
||||
Assert-DreamSkinNoReparseComponents -Path (Get-DreamSkinAppearanceMarkerPath -BackupPath $BackupPath)
|
||||
}
|
||||
$originalBytes = [System.IO.File]::ReadAllBytes($ConfigPath)
|
||||
$content = ConvertFrom-DreamSkinUtf8Bytes -Bytes $originalBytes -Path $ConfigPath
|
||||
$appearanceMarker = Read-DreamSkinAppearanceMarker -BackupPath $BackupPath
|
||||
$appearanceMarkerPath = Get-DreamSkinAppearanceMarkerPath -BackupPath $BackupPath
|
||||
$appearanceMarkerExisted = Test-Path -LiteralPath $appearanceMarkerPath -PathType Leaf
|
||||
$backupCreated = $false
|
||||
if (-not (Test-Path -LiteralPath $BackupPath)) {
|
||||
Write-DreamSkinBytesAtomically -Path $BackupPath -Bytes $originalBytes -ExpectedBytes $null
|
||||
$backupCreated = $true
|
||||
}
|
||||
|
||||
$writeCompleted = $false
|
||||
try {
|
||||
Assert-DreamSkinDesktopShapeSupported -Content $content
|
||||
$newLine = Get-DreamSkinNewLine -Content $content
|
||||
$desktop = Get-DreamSkinDesktopSection -Content $content
|
||||
if ($null -eq $desktop) {
|
||||
$content = Add-DreamSkinDesktopSection -Content $content -NewLine $newLine
|
||||
$desktop = Get-DreamSkinDesktopSection -Content $content
|
||||
}
|
||||
|
||||
$body = $desktop.Body
|
||||
$backupContent = $null
|
||||
$pinnedAppearance = $AppearanceTheme -ne 'auto'
|
||||
$managedByMarker = $null -ne $appearanceMarker -and [bool]$appearanceMarker.appearanceThemeManaged
|
||||
$legacyMigration = $null -eq $appearanceMarker -and (Test-Path -LiteralPath $BackupPath) -and
|
||||
(Test-DreamSkinLegacyManagedLightTrio -Content $content)
|
||||
# Put the pre-install appearanceTheme back whenever we stop managing it:
|
||||
# either migrating away from the legacy forced-light trio, or un-pinning
|
||||
# after a fixed-appearance theme is replaced by an auto one.
|
||||
if (-not $pinnedAppearance -and ($legacyMigration -or $managedByMarker)) {
|
||||
$backupContent = ConvertFrom-DreamSkinUtf8Bytes -Bytes ([System.IO.File]::ReadAllBytes($BackupPath)) -Path $BackupPath
|
||||
Assert-DreamSkinDesktopShapeSupported -Content $backupContent
|
||||
$backupDesktop = Get-DreamSkinDesktopSection -Content $backupContent
|
||||
$savedAppearance = if ($null -ne $backupDesktop) {
|
||||
Get-DreamSkinSectionSettingLine -Body $backupDesktop.Body -Key 'appearanceTheme'
|
||||
} else { $null }
|
||||
$body = Set-DreamSkinSectionSetting -Body $body -Key 'appearanceTheme' -Line $savedAppearance -NewLine $newLine
|
||||
}
|
||||
if ($pinnedAppearance) {
|
||||
# Native token surfaces (dropdowns/popovers) follow appearanceTheme, so a
|
||||
# fixed-appearance theme pins it to match; Restore puts the original back.
|
||||
$body = Set-DreamSkinSectionSetting -Body $body -Key 'appearanceTheme' `
|
||||
-Line ('appearanceTheme = "{0}"' -f $AppearanceTheme) -NewLine $newLine
|
||||
}
|
||||
$settings = [ordered]@{
|
||||
appearanceLightCodeThemeId = $script:DreamSkinManagedLightCodeTheme
|
||||
appearanceLightChromeTheme = $script:DreamSkinManagedLightChromeTheme
|
||||
}
|
||||
$hasNestedLightChromeTheme = Test-DreamSkinDesktopNestedTable `
|
||||
-Content $content -Key 'appearanceLightChromeTheme'
|
||||
foreach ($key in $settings.Keys) {
|
||||
if ($key -eq 'appearanceLightChromeTheme' -and $hasNestedLightChromeTheme) { continue }
|
||||
$body = Set-DreamSkinSectionSetting -Body $body -Key $key -Line $settings[$key] -NewLine $newLine
|
||||
}
|
||||
|
||||
$content = $content.Substring(0, $desktop.BodyStart) + $body +
|
||||
$content.Substring($desktop.BodyStart + $desktop.BodyLength)
|
||||
# Commit the metadata first. A config commit must never exist without the
|
||||
# marker that tells restore exactly which appearance keys we own.
|
||||
Write-DreamSkinAppearanceMarker -BackupPath $BackupPath -Managed $pinnedAppearance
|
||||
Write-DreamSkinUtf8FileAtomically -Path $ConfigPath -Content $content -ExpectedBytes $originalBytes
|
||||
$writeCompleted = $true
|
||||
} catch {
|
||||
if (-not $writeCompleted) {
|
||||
$configUnchanged = $false
|
||||
try {
|
||||
$configUnchanged = (Test-Path -LiteralPath $ConfigPath -PathType Leaf) -and
|
||||
(Test-DreamSkinBytesEqual -Left $originalBytes -Right ([System.IO.File]::ReadAllBytes($ConfigPath)))
|
||||
} catch {
|
||||
$configUnchanged = $false
|
||||
}
|
||||
if ($configUnchanged) {
|
||||
$markerCleanupSucceeded = $true
|
||||
if (-not $appearanceMarkerExisted -and (Test-Path -LiteralPath $appearanceMarkerPath)) {
|
||||
try {
|
||||
Remove-Item -LiteralPath $appearanceMarkerPath -Force -ErrorAction Stop
|
||||
} catch {
|
||||
$markerCleanupSucceeded = $false
|
||||
}
|
||||
}
|
||||
if ($markerCleanupSucceeded -and $backupCreated) {
|
||||
Remove-Item -LiteralPath $BackupPath -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
}
|
||||
throw
|
||||
}
|
||||
}
|
||||
|
||||
function Restore-DreamSkinBaseTheme {
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$ConfigPath,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$BackupPath
|
||||
)
|
||||
|
||||
if (-not (Test-Path -LiteralPath $BackupPath)) { throw 'No pre-install config backup is available.' }
|
||||
if (Get-Command Assert-DreamSkinNoReparseComponents -ErrorAction SilentlyContinue) {
|
||||
Assert-DreamSkinNoReparseComponents -Path $BackupPath
|
||||
Assert-DreamSkinNoReparseComponents -Path (Get-DreamSkinAppearanceMarkerPath -BackupPath $BackupPath)
|
||||
}
|
||||
$backupBytes = [System.IO.File]::ReadAllBytes($BackupPath)
|
||||
$backupContent = ConvertFrom-DreamSkinUtf8Bytes -Bytes $backupBytes -Path $BackupPath
|
||||
$currentBytes = [System.IO.File]::ReadAllBytes($ConfigPath)
|
||||
$currentContent = ConvertFrom-DreamSkinUtf8Bytes -Bytes $currentBytes -Path $ConfigPath
|
||||
Assert-DreamSkinDesktopShapeSupported -Content $backupContent
|
||||
Assert-DreamSkinDesktopShapeSupported -Content $currentContent
|
||||
$newLine = Get-DreamSkinNewLine -Content $currentContent
|
||||
$backupDesktop = Get-DreamSkinDesktopSection -Content $backupContent
|
||||
$currentDesktop = Get-DreamSkinDesktopSection -Content $currentContent
|
||||
if ($null -eq $currentDesktop) {
|
||||
$currentContent = Add-DreamSkinDesktopSection -Content $currentContent -NewLine $newLine
|
||||
$currentDesktop = Get-DreamSkinDesktopSection -Content $currentContent
|
||||
}
|
||||
|
||||
$body = $currentDesktop.Body
|
||||
$appearanceMarker = Read-DreamSkinAppearanceMarker -BackupPath $BackupPath
|
||||
$restoreLegacyAppearance = $null -eq $appearanceMarker -and
|
||||
(Test-DreamSkinLegacyManagedLightTrio -Content $currentContent)
|
||||
$restoreManagedAppearance = $null -ne $appearanceMarker -and
|
||||
[bool]$appearanceMarker.appearanceThemeManaged
|
||||
$restoreKeys = @('appearanceLightCodeThemeId', 'appearanceLightChromeTheme')
|
||||
if ($restoreLegacyAppearance -or $restoreManagedAppearance) {
|
||||
$restoreKeys = @('appearanceTheme') + $restoreKeys
|
||||
}
|
||||
$hasNestedLightChromeTheme = Test-DreamSkinDesktopNestedTable `
|
||||
-Content $currentContent -Key 'appearanceLightChromeTheme'
|
||||
foreach ($key in $restoreKeys) {
|
||||
if ($key -eq 'appearanceLightChromeTheme' -and $hasNestedLightChromeTheme) { continue }
|
||||
$keyToken = Get-DreamSkinTomlKeyTokenPattern -Key $key
|
||||
$pattern = "(?m)^[\t ]*$keyToken[\t ]*=[^\r\n]*(?:\r?\n|(?=\z))"
|
||||
$saved = if ($null -ne $backupDesktop) { [regex]::Match($backupDesktop.Body, $pattern) } else { $null }
|
||||
$line = if ($null -ne $saved -and $saved.Success) { $saved.Value } else { $null }
|
||||
$body = Set-DreamSkinSectionSetting -Body $body -Key $key -Line $line -NewLine $newLine
|
||||
}
|
||||
if ($null -eq $backupDesktop -and [string]::IsNullOrWhiteSpace($body)) {
|
||||
$currentContent = $currentContent.Remove($currentDesktop.SectionStart, $currentDesktop.SectionLength)
|
||||
} else {
|
||||
$currentContent = $currentContent.Substring(0, $currentDesktop.BodyStart) + $body +
|
||||
$currentContent.Substring($currentDesktop.BodyStart + $currentDesktop.BodyLength)
|
||||
}
|
||||
Write-DreamSkinUtf8FileAtomically -Path $ConfigPath -Content $currentContent -ExpectedBytes $currentBytes
|
||||
}
|
||||
|
||||
function Restore-DreamSkinConfigBackup {
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$ConfigPath,
|
||||
[Parameter(Mandatory = $true)][string]$BackupPath,
|
||||
[Parameter(Mandatory = $true)][string]$RecoveryBackupPath
|
||||
)
|
||||
|
||||
if (-not (Test-Path -LiteralPath $BackupPath)) { throw 'No pre-install config backup is available.' }
|
||||
$backupBytes = [System.IO.File]::ReadAllBytes($BackupPath)
|
||||
$null = ConvertFrom-DreamSkinUtf8Bytes -Bytes $backupBytes -Path $BackupPath
|
||||
$currentBytes = $null
|
||||
if (Test-Path -LiteralPath $ConfigPath) {
|
||||
$currentBytes = [System.IO.File]::ReadAllBytes($ConfigPath)
|
||||
Write-DreamSkinBytesAtomically -Path $RecoveryBackupPath -Bytes $currentBytes -ExpectedBytes $null
|
||||
}
|
||||
|
||||
Write-DreamSkinBytesAtomically -Path $ConfigPath -Bytes $backupBytes -ExpectedBytes $currentBytes
|
||||
}
|
||||
|
||||
function Archive-DreamSkinConfigBackup {
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$BackupPath,
|
||||
[Parameter(Mandatory = $true)][string]$ArchivePath
|
||||
)
|
||||
|
||||
if (-not (Test-Path -LiteralPath $BackupPath)) { return }
|
||||
if (Test-Path -LiteralPath $ArchivePath) { throw "Config backup archive already exists: $ArchivePath" }
|
||||
Move-Item -LiteralPath $BackupPath -Destination $ArchivePath -ErrorAction Stop
|
||||
Remove-Item -LiteralPath (Get-DreamSkinAppearanceMarkerPath -BackupPath $BackupPath) -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
The official Windows release workflow places the pinned Node.js runtime here.
|
||||
|
||||
Version: 22.23.1
|
||||
Archive: node-v22.23.1-win-x64.zip
|
||||
SHA-256: 7df0bc9375723f4a86b3aa1b7cc73342423d9677a8df4538aca31a049e309c29
|
||||
Source: https://nodejs.org/dist/v22.23.1/node-v22.23.1-win-x64.zip
|
||||
|
||||
Local source builds may use Node.js 22 or newer from PATH instead.
|
||||
@@ -3,9 +3,101 @@ use crate::file_io::ensure_directory;
|
||||
use crate::paths::app_home;
|
||||
use crate::sqlite_utils::table_column_set;
|
||||
use rusqlite::Connection;
|
||||
use std::path::PathBuf;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
use std::time::Duration;
|
||||
|
||||
const APP_DB_SCHEMA_VERSION: i64 = 1;
|
||||
|
||||
struct DatabaseInitializer {
|
||||
migration_lock: Mutex<()>,
|
||||
}
|
||||
|
||||
impl DatabaseInitializer {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
migration_lock: Mutex::new(()),
|
||||
}
|
||||
}
|
||||
|
||||
fn open_at(&self, path: &Path) -> Result<Connection> {
|
||||
self.open_at_with(path, initialize_schema)
|
||||
}
|
||||
|
||||
fn open_at_with(
|
||||
&self,
|
||||
path: &Path,
|
||||
initialize: impl FnOnce(&Connection) -> Result<()>,
|
||||
) -> Result<Connection> {
|
||||
if let Some(parent) = path.parent() {
|
||||
ensure_directory(parent)?;
|
||||
}
|
||||
let conn = Connection::open(path).map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
conn.busy_timeout(Duration::from_secs(5))
|
||||
.map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
|
||||
if schema_is_current(&conn)? {
|
||||
return Ok(conn);
|
||||
}
|
||||
|
||||
// Serialize first-time migrations inside this process. The persistent
|
||||
// schema version also prevents repeated migrations across launches and
|
||||
// detects a database replaced at the same path.
|
||||
let _migration_guard = self
|
||||
.migration_lock
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
if !schema_is_current(&conn)? {
|
||||
migrate_schema(&conn, initialize)?;
|
||||
}
|
||||
Ok(conn)
|
||||
}
|
||||
}
|
||||
|
||||
fn schema_version(conn: &Connection) -> Result<i64> {
|
||||
conn.pragma_query_value(None, "user_version", |row| row.get(0))
|
||||
.map_err(|error| CodexxError::Database(error.to_string()))
|
||||
}
|
||||
|
||||
fn schema_is_current(conn: &Connection) -> Result<bool> {
|
||||
Ok(schema_version(conn)? >= APP_DB_SCHEMA_VERSION)
|
||||
}
|
||||
|
||||
fn migrate_schema(
|
||||
conn: &Connection,
|
||||
initialize: impl FnOnce(&Connection) -> Result<()>,
|
||||
) -> Result<()> {
|
||||
conn.execute_batch("BEGIN IMMEDIATE")
|
||||
.map_err(|error| CodexxError::Database(error.to_string()))?;
|
||||
|
||||
let migration = (|| {
|
||||
// Another Codex-X process may have completed the migration while this
|
||||
// connection waited for SQLite's write lock.
|
||||
if !schema_is_current(conn)? {
|
||||
initialize(conn)?;
|
||||
conn.pragma_update(None, "user_version", APP_DB_SCHEMA_VERSION)
|
||||
.map_err(|error| CodexxError::Database(error.to_string()))?;
|
||||
}
|
||||
conn.execute_batch("COMMIT")
|
||||
.map_err(|error| CodexxError::Database(error.to_string()))
|
||||
})();
|
||||
|
||||
match migration {
|
||||
Ok(()) => Ok(()),
|
||||
Err(error) => match conn.execute_batch("ROLLBACK") {
|
||||
Ok(()) => Err(error),
|
||||
Err(rollback_error) => Err(CodexxError::Database(format!(
|
||||
"{error}; app database migration rollback failed: {rollback_error}"
|
||||
))),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn database_initializer() -> &'static DatabaseInitializer {
|
||||
static INITIALIZER: OnceLock<DatabaseInitializer> = OnceLock::new();
|
||||
INITIALIZER.get_or_init(DatabaseInitializer::new)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn test_db_guard() -> std::sync::MutexGuard<'static, ()> {
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
@@ -45,14 +137,7 @@ fn ensure_sqlite_column(
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn open() -> Result<Connection> {
|
||||
let path = db_path()?;
|
||||
if let Some(parent) = path.parent() {
|
||||
ensure_directory(parent)?;
|
||||
}
|
||||
let conn = Connection::open(&path).map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
conn.busy_timeout(Duration::from_secs(5))
|
||||
.map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
fn initialize_schema(conn: &Connection) -> Result<()> {
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE IF NOT EXISTS providers (
|
||||
id TEXT PRIMARY KEY,
|
||||
@@ -112,19 +197,19 @@ pub(crate) fn open() -> Result<Connection> {
|
||||
)
|
||||
.map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
ensure_sqlite_column(
|
||||
&conn,
|
||||
conn,
|
||||
"providers",
|
||||
"toml_config",
|
||||
"ALTER TABLE providers ADD COLUMN toml_config TEXT",
|
||||
)?;
|
||||
ensure_sqlite_column(
|
||||
&conn,
|
||||
conn,
|
||||
"providers",
|
||||
"source",
|
||||
"ALTER TABLE providers ADD COLUMN source TEXT NOT NULL DEFAULT 'manual'",
|
||||
)?;
|
||||
ensure_sqlite_column(
|
||||
&conn,
|
||||
conn,
|
||||
"providers",
|
||||
"source_id",
|
||||
"ALTER TABLE providers ADD COLUMN source_id TEXT",
|
||||
@@ -170,5 +255,183 @@ pub(crate) fn open() -> Result<Connection> {
|
||||
[],
|
||||
)
|
||||
.map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
Ok(conn)
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn open() -> Result<Connection> {
|
||||
database_initializer().open_at(&db_path()?)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, Barrier};
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
fn test_db_path(name: &str) -> PathBuf {
|
||||
static COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
let suffix = COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||
std::env::temp_dir()
|
||||
.join(format!(
|
||||
"codex-x-app-db-{name}-{}-{suffix}",
|
||||
std::process::id()
|
||||
))
|
||||
.join("codexx.db")
|
||||
}
|
||||
|
||||
fn remove_test_db(path: &Path) {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::remove_dir_all(parent).expect("remove app database test directory");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_opens_do_not_rerun_legacy_cleanup() {
|
||||
let path = test_db_path("cleanup-once");
|
||||
let initializer = DatabaseInitializer::new();
|
||||
let conn = initializer.open_at(&path).expect("initialize database");
|
||||
conn.execute_batch(
|
||||
"INSERT INTO prompts (id, title, filename, content, created_at, updated_at)
|
||||
VALUES
|
||||
('kept', 'Kept', 'same.md', 'same', '1', '1'),
|
||||
('external-duplicate', 'Duplicate', 'same.md', 'same', '2', '2');",
|
||||
)
|
||||
.expect("seed a post-migration duplicate");
|
||||
drop(conn);
|
||||
|
||||
let reopened_initializer = DatabaseInitializer::new();
|
||||
let conn = reopened_initializer
|
||||
.open_at(&path)
|
||||
.expect("reopen database in a new process lifecycle");
|
||||
let count: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM prompts", [], |row| row.get(0))
|
||||
.expect("count prompts after reopen");
|
||||
assert_eq!(count, 2, "reopen must not rerun migration cleanup");
|
||||
drop(conn);
|
||||
remove_test_db(&path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn database_replaced_at_the_same_path_is_initialized_again() {
|
||||
let path = test_db_path("replace-database");
|
||||
let initializer = DatabaseInitializer::new();
|
||||
let conn = initializer.open_at(&path).expect("initialize database");
|
||||
assert_eq!(schema_version(&conn).expect("read schema version"), 1);
|
||||
drop(conn);
|
||||
|
||||
fs::remove_file(&path).expect("replace initialized database");
|
||||
let conn = initializer
|
||||
.open_at(&path)
|
||||
.expect("initialize replacement database");
|
||||
let provider_count: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM providers", [], |row| row.get(0))
|
||||
.expect("query replacement database schema");
|
||||
assert_eq!(provider_count, 0);
|
||||
assert_eq!(schema_version(&conn).expect("read replacement version"), 1);
|
||||
drop(conn);
|
||||
remove_test_db(&path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn concurrent_first_opens_initialize_once() {
|
||||
let path = test_db_path("concurrent-init");
|
||||
let attempts = Arc::new(AtomicUsize::new(0));
|
||||
let barrier = Arc::new(Barrier::new(3));
|
||||
let mut workers = Vec::new();
|
||||
|
||||
for _ in 0..2 {
|
||||
let path = path.clone();
|
||||
// Separate initializers model independent Codex-X processes; the
|
||||
// SQLite transaction and persistent version still permit one run.
|
||||
let initializer = DatabaseInitializer::new();
|
||||
let attempts = Arc::clone(&attempts);
|
||||
let barrier = Arc::clone(&barrier);
|
||||
workers.push(thread::spawn(move || {
|
||||
barrier.wait();
|
||||
initializer.open_at_with(&path, |conn| {
|
||||
attempts.fetch_add(1, Ordering::SeqCst);
|
||||
thread::sleep(Duration::from_millis(25));
|
||||
conn.execute_batch("CREATE TABLE initialized_once (id INTEGER PRIMARY KEY);")
|
||||
.map_err(|error| CodexxError::Database(error.to_string()))
|
||||
})
|
||||
}));
|
||||
}
|
||||
|
||||
barrier.wait();
|
||||
for worker in workers {
|
||||
drop(
|
||||
worker
|
||||
.join()
|
||||
.expect("join concurrent database opener")
|
||||
.expect("open database concurrently"),
|
||||
);
|
||||
}
|
||||
assert_eq!(attempts.load(Ordering::SeqCst), 1);
|
||||
remove_test_db(&path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_initialization_can_retry() {
|
||||
let path = test_db_path("retry-init");
|
||||
let initializer = DatabaseInitializer::new();
|
||||
let attempts = AtomicUsize::new(0);
|
||||
|
||||
let error = initializer
|
||||
.open_at_with(&path, |_| {
|
||||
attempts.fetch_add(1, Ordering::SeqCst);
|
||||
Err(CodexxError::Database(
|
||||
"injected transient initialization failure".to_string(),
|
||||
))
|
||||
})
|
||||
.expect_err("first initialization must fail");
|
||||
assert!(error
|
||||
.to_string()
|
||||
.contains("transient initialization failure"));
|
||||
|
||||
let conn = initializer
|
||||
.open_at_with(&path, |conn| {
|
||||
attempts.fetch_add(1, Ordering::SeqCst);
|
||||
conn.execute_batch("CREATE TABLE retry_succeeded (id INTEGER PRIMARY KEY);")
|
||||
.map_err(|error| CodexxError::Database(error.to_string()))
|
||||
})
|
||||
.expect("retry initialization");
|
||||
drop(conn);
|
||||
let conn = initializer
|
||||
.open_at_with(&path, |_| {
|
||||
attempts.fetch_add(1, Ordering::SeqCst);
|
||||
Ok(())
|
||||
})
|
||||
.expect("open initialized database");
|
||||
assert_eq!(attempts.load(Ordering::SeqCst), 2);
|
||||
drop(conn);
|
||||
remove_test_db(&path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn initialized_read_connection_does_not_need_a_write_lock() {
|
||||
let path = test_db_path("read-with-writer");
|
||||
let initializer = DatabaseInitializer::new();
|
||||
let writer = initializer.open_at(&path).expect("initialize database");
|
||||
writer
|
||||
.execute_batch("BEGIN IMMEDIATE")
|
||||
.expect("hold database write reservation");
|
||||
|
||||
let reader = initializer
|
||||
.open_at(&path)
|
||||
.expect("open reader while write reservation is held");
|
||||
let count: i64 = reader
|
||||
.query_row("SELECT COUNT(*) FROM providers", [], |row| row.get(0))
|
||||
.expect("read while another connection holds write reservation");
|
||||
assert_eq!(count, 0);
|
||||
|
||||
drop(reader);
|
||||
writer
|
||||
.execute_batch("ROLLBACK")
|
||||
.expect("release write lock");
|
||||
drop(writer);
|
||||
remove_test_db(&path);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -216,7 +216,3 @@ pub(crate) fn backups() -> Result<Vec<BackupEntry>> {
|
||||
entries.sort_by(|a, b| b.created_at.cmp(&a.created_at));
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
pub(crate) fn latest_backup() -> Result<Option<BackupEntry>> {
|
||||
Ok(backups()?.into_iter().next())
|
||||
}
|
||||
|
||||
@@ -2,9 +2,9 @@ use crate::backups::create_backup;
|
||||
use crate::config_path;
|
||||
use crate::error::Result;
|
||||
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,
|
||||
};
|
||||
#[cfg(test)]
|
||||
use crate::live_config::acquire_live_config_lock;
|
||||
use crate::live_config::{atomic_write_if_unchanged, read_file_snapshot, text_from_snapshot};
|
||||
use std::path::Path;
|
||||
use toml_edit::{DocumentMut, Item, Table};
|
||||
|
||||
@@ -31,6 +31,7 @@ fn remove_markdown_path(table: &mut Table, key: &str) -> Option<Item> {
|
||||
|
||||
/// Repairs prompt paths appended by older Codex-X versions after a `[tui]`
|
||||
/// header. Only the exact legacy keys with Markdown path values are touched.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn migrate_legacy_prompt_config(codex_dir: &Path) -> Result<bool> {
|
||||
let _lock = acquire_live_config_lock(codex_dir)?;
|
||||
migrate_legacy_prompt_config_locked(codex_dir)
|
||||
|
||||
@@ -108,6 +108,7 @@ pub(crate) fn read_to_string_if_exists(path: &Path) -> Result<String> {
|
||||
fs::read_to_string(path).map_err(|e| io_err(path, e))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn harden_sensitive_file_permissions(path: &Path) -> Result<()> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
|
||||
@@ -29,9 +29,6 @@ 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;
|
||||
@@ -85,7 +82,7 @@ use providers::{
|
||||
upsert_provider_on_connection, CcSwitchCodexRow, ProviderUpsertKind, ProviderUpsertMode,
|
||||
};
|
||||
use providers::{
|
||||
capture_live_chatgpt_config, delete_saved_provider_inner, fetch_provider_models_inner,
|
||||
build_provider_toml_draft_inner, 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,
|
||||
@@ -116,17 +113,11 @@ 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, build_state_after_migration, ActionResult, CodexState,
|
||||
};
|
||||
#[cfg(test)]
|
||||
use state::build_state;
|
||||
use state::{auth_has_material, build_state_after_migration, ActionResult, CodexState};
|
||||
use toml_edit::{value, DocumentMut};
|
||||
pub(crate) use toml_utils::string_value;
|
||||
use updates::check_app_update;
|
||||
@@ -192,7 +183,7 @@ pub(crate) fn now_rfc3339() -> String {
|
||||
|
||||
fn active_remote_builtin_prompt_id(config_dir: Option<String>) -> Option<String> {
|
||||
let codex_dir = resolve_codex_dir(config_dir).ok()?;
|
||||
let state = build_state(codex_dir).ok()?;
|
||||
let state = build_state_after_migration(codex_dir).ok()?;
|
||||
let template_key = state.instruction_template_key.as_deref()?;
|
||||
let id = template_key.strip_prefix("builtin:")?.trim();
|
||||
if id.is_empty() || bundled_prompt_meta(id).is_some() {
|
||||
@@ -495,74 +486,6 @@ 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))
|
||||
@@ -929,6 +852,18 @@ async fn list_saved_providers() -> Result<Vec<SavedProvider>> {
|
||||
.map_err(|e| CodexxError::Config(format!("读取供应商列表失败: {e}")))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn build_provider_toml_draft(
|
||||
provider: SavedProvider,
|
||||
config_dir: Option<String>,
|
||||
) -> Result<String> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
build_provider_toml_draft_inner(provider, config_dir)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| CodexxError::Config(format!("生成供应商 TOML 失败: {e}")))?
|
||||
}
|
||||
|
||||
fn save_provider_command_inner(provider: SavedProvider) -> Result<SavedProvider> {
|
||||
save_provider_inner(provider)
|
||||
}
|
||||
@@ -966,12 +901,6 @@ async fn get_codex_state(config_dir: Option<String>) -> Result<CodexState> {
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -1001,7 +930,7 @@ async fn restore_official_provider(config_dir: Option<String>) -> Result<ActionR
|
||||
#[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)
|
||||
reset_official_provider_inner(input.config_dir, input.model, input.config_text)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| CodexxError::Config(format!("新建官方配置失败: {e}")))?
|
||||
@@ -1010,7 +939,12 @@ async fn reset_official_provider(input: OfficialConfigInput) -> Result<ActionRes
|
||||
#[tauri::command]
|
||||
async fn save_official_config(input: OfficialConfigInput) -> Result<ActionResult> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
save_official_config_inner(input.config_dir, input.model, input.auth_json)
|
||||
save_official_config_inner(
|
||||
input.config_dir,
|
||||
input.model,
|
||||
input.auth_json,
|
||||
input.config_text,
|
||||
)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| CodexxError::Config(format!("保存官方配置失败: {e}")))?
|
||||
@@ -1316,6 +1250,10 @@ fn restore_backup_inner(config_dir: Option<String>, backup_id: String) -> Result
|
||||
None => None,
|
||||
};
|
||||
let backup_auth = declared_backup_file_snapshot(&backup_auth, backup_meta.had_auth)?;
|
||||
if let Some(bytes) = backup_auth.as_deref() {
|
||||
serde_json::from_slice::<serde_json::Value>(bytes)
|
||||
.map_err(|error| file_io::json_err(&dir.join("auth.json"), error))?;
|
||||
}
|
||||
let backup_agents = declared_backup_file_snapshot(&backup_agents, backup_meta.had_agents)?;
|
||||
|
||||
ensure_directory(&codex_dir)?;
|
||||
@@ -1394,7 +1332,6 @@ pub fn run() {
|
||||
.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| {
|
||||
@@ -1411,14 +1348,6 @@ 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,
|
||||
@@ -1436,6 +1365,7 @@ pub fn run() {
|
||||
delete_saved_prompt,
|
||||
enable_saved_prompt,
|
||||
list_saved_providers,
|
||||
build_provider_toml_draft,
|
||||
save_provider,
|
||||
save_active_provider,
|
||||
delete_saved_provider,
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
use std::collections::HashSet;
|
||||
use std::fs;
|
||||
use std::io::Read;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Output};
|
||||
use std::process::{Child, Command, Output, Stdio};
|
||||
use std::sync::{mpsc, OnceLock};
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
use std::env;
|
||||
@@ -12,6 +16,13 @@ const WINDOWS_CODEX_PACKAGE_IDENTITIES: &[&str] =
|
||||
#[cfg(target_os = "windows")]
|
||||
const WINDOWS_CODEX_EXECUTABLES: &[&str] = &["ChatGPT.exe", "Codex.exe", "codex.exe"];
|
||||
|
||||
const CODEX_VERSION_PROBE_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
const PROGRAM_TIMEOUT: Duration = Duration::from_secs(2);
|
||||
const PROGRAM_POLL_INTERVAL: Duration = Duration::from_millis(10);
|
||||
const CHILD_TERMINATION_GRACE: Duration = Duration::from_millis(250);
|
||||
|
||||
static CODEX_VERSION: OnceLock<String> = OnceLock::new();
|
||||
|
||||
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| {
|
||||
@@ -79,14 +90,142 @@ pub fn program_command(program: &Path, args: &[&str]) -> Command {
|
||||
command
|
||||
}
|
||||
|
||||
fn run_program(program: &Path, args: &[&str]) -> Option<Output> {
|
||||
program_command(program, args).output().ok()
|
||||
fn remaining_timeout(deadline: Option<Instant>, maximum: Duration) -> Option<Duration> {
|
||||
let remaining = deadline
|
||||
.map(|deadline| deadline.saturating_duration_since(Instant::now()))
|
||||
.unwrap_or(maximum)
|
||||
.min(maximum);
|
||||
(!remaining.is_zero()).then_some(remaining)
|
||||
}
|
||||
|
||||
fn command_version(program: &Path) -> Option<String> {
|
||||
run_program(program, &["--version"])
|
||||
fn deadline_expired(deadline: Option<Instant>) -> bool {
|
||||
deadline.is_some_and(|deadline| Instant::now() >= deadline)
|
||||
}
|
||||
|
||||
fn wait_for_child_exit(child: &mut Child, deadline: Instant) {
|
||||
loop {
|
||||
match child.try_wait() {
|
||||
Ok(Some(_)) | Err(_) => return,
|
||||
Ok(None) if Instant::now() < deadline => {
|
||||
let remaining = deadline.saturating_duration_since(Instant::now());
|
||||
thread::sleep(remaining.min(PROGRAM_POLL_INTERVAL));
|
||||
}
|
||||
Ok(None) => return,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn terminate_child(child: &mut Child) {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
|
||||
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||
let pid = child.id().to_string();
|
||||
let mut taskkill = Command::new("taskkill.exe");
|
||||
taskkill
|
||||
.args(["/PID", pid.as_str(), "/T", "/F"])
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.creation_flags(CREATE_NO_WINDOW);
|
||||
if let Ok(mut killer) = taskkill.spawn() {
|
||||
let deadline = Instant::now() + CHILD_TERMINATION_GRACE;
|
||||
wait_for_child_exit(&mut killer, deadline);
|
||||
let _ = killer.kill();
|
||||
}
|
||||
}
|
||||
|
||||
let _ = child.kill();
|
||||
wait_for_child_exit(child, Instant::now() + CHILD_TERMINATION_GRACE);
|
||||
}
|
||||
|
||||
fn output_reader<R>(mut stream: R) -> Option<mpsc::Receiver<Option<Vec<u8>>>>
|
||||
where
|
||||
R: Read + Send + 'static,
|
||||
{
|
||||
let (sender, receiver) = mpsc::channel();
|
||||
thread::Builder::new()
|
||||
.name("codex-version-output".to_string())
|
||||
.spawn(move || {
|
||||
let mut output = Vec::new();
|
||||
let result = stream.read_to_end(&mut output).ok().map(|_| output);
|
||||
let _ = sender.send(result);
|
||||
})
|
||||
.ok()?;
|
||||
Some(receiver)
|
||||
}
|
||||
|
||||
fn receive_output(
|
||||
receiver: &mpsc::Receiver<Option<Vec<u8>>>,
|
||||
deadline: Instant,
|
||||
) -> Option<Vec<u8>> {
|
||||
match receiver.try_recv() {
|
||||
Ok(output) => output,
|
||||
Err(mpsc::TryRecvError::Disconnected) => None,
|
||||
Err(mpsc::TryRecvError::Empty) => {
|
||||
let remaining = deadline.saturating_duration_since(Instant::now());
|
||||
(!remaining.is_zero())
|
||||
.then(|| receiver.recv_timeout(remaining).ok().flatten())
|
||||
.flatten()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn run_program(program: &Path, args: &[&str], deadline: Option<Instant>) -> Option<Output> {
|
||||
let timeout = remaining_timeout(deadline, PROGRAM_TIMEOUT)?;
|
||||
let command_deadline = Instant::now().checked_add(timeout)?;
|
||||
let mut command = program_command(program, args);
|
||||
command
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
let mut child = command.spawn().ok()?;
|
||||
|
||||
let Some(stdout) = child.stdout.take() else {
|
||||
terminate_child(&mut child);
|
||||
return None;
|
||||
};
|
||||
let Some(stderr) = child.stderr.take() else {
|
||||
terminate_child(&mut child);
|
||||
return None;
|
||||
};
|
||||
// Drain both pipes while polling so verbose commands cannot block on a full pipe buffer.
|
||||
let Some(stdout_receiver) = output_reader(stdout) else {
|
||||
terminate_child(&mut child);
|
||||
return None;
|
||||
};
|
||||
let Some(stderr_receiver) = output_reader(stderr) else {
|
||||
terminate_child(&mut child);
|
||||
return None;
|
||||
};
|
||||
|
||||
let status = loop {
|
||||
match child.try_wait() {
|
||||
Ok(Some(status)) => break status,
|
||||
Ok(None) if Instant::now() < command_deadline => {
|
||||
let remaining = command_deadline.saturating_duration_since(Instant::now());
|
||||
thread::sleep(remaining.min(PROGRAM_POLL_INTERVAL));
|
||||
}
|
||||
Ok(None) | Err(_) => {
|
||||
terminate_child(&mut child);
|
||||
return None;
|
||||
}
|
||||
}
|
||||
};
|
||||
let stdout = receive_output(&stdout_receiver, command_deadline)?;
|
||||
let stderr = receive_output(&stderr_receiver, command_deadline)?;
|
||||
Some(Output {
|
||||
status,
|
||||
stdout,
|
||||
stderr,
|
||||
})
|
||||
}
|
||||
|
||||
fn command_version(program: &Path, deadline: Option<Instant>) -> Option<String> {
|
||||
run_program(program, &["--version"], deadline)
|
||||
.and_then(version_from_output)
|
||||
.or_else(|| run_program(program, &["-V"]).and_then(version_from_output))
|
||||
.or_else(|| run_program(program, &["-V"], deadline).and_then(version_from_output))
|
||||
}
|
||||
|
||||
fn candidate_key(path: &Path) -> String {
|
||||
@@ -143,34 +282,59 @@ fn latest_windows_package_version<'a>(
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn windows_store_app_version_from_roots(roots: &[PathBuf]) -> Option<String> {
|
||||
fn windows_store_app_version_from_roots(
|
||||
roots: &[PathBuf],
|
||||
deadline: Option<Instant>,
|
||||
) -> Option<String> {
|
||||
let mut package_names = Vec::new();
|
||||
for root in roots {
|
||||
if deadline_expired(deadline) {
|
||||
break;
|
||||
}
|
||||
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())
|
||||
}));
|
||||
for entry in entries.flatten() {
|
||||
if deadline_expired(deadline) {
|
||||
break;
|
||||
}
|
||||
if entry.path().is_dir() {
|
||||
package_names.push(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;
|
||||
fn visit_named_files<F>(
|
||||
root: &Path,
|
||||
names: &[&str],
|
||||
depth: usize,
|
||||
deadline: Option<Instant>,
|
||||
visit: &mut F,
|
||||
) -> bool
|
||||
where
|
||||
F: FnMut(PathBuf) -> bool,
|
||||
{
|
||||
if depth == 0 || deadline_expired(deadline) {
|
||||
return !deadline_expired(deadline);
|
||||
}
|
||||
if !root.is_dir() {
|
||||
return !deadline_expired(deadline);
|
||||
}
|
||||
let Ok(entries) = fs::read_dir(root) else {
|
||||
return;
|
||||
return !deadline_expired(deadline);
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
if deadline_expired(deadline) {
|
||||
return false;
|
||||
}
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
collect_named_files(&path, names, depth - 1, output);
|
||||
if !visit_named_files(&path, names, depth - 1, deadline, visit) {
|
||||
return false;
|
||||
}
|
||||
} else if path.is_file()
|
||||
&& path
|
||||
.file_name()
|
||||
@@ -180,55 +344,81 @@ fn collect_named_files(root: &Path, names: &[&str], depth: usize, output: &mut V
|
||||
.iter()
|
||||
.any(|candidate| name.eq_ignore_ascii_case(candidate))
|
||||
})
|
||||
&& !visit(path)
|
||||
{
|
||||
output.push(path);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
!deadline_expired(deadline)
|
||||
}
|
||||
|
||||
fn extension_codex_candidates(home: &Path) -> Vec<PathBuf> {
|
||||
fn visit_extension_codex_candidates<F>(
|
||||
home: &Path,
|
||||
deadline: Option<Instant>,
|
||||
visit: &mut F,
|
||||
) -> bool
|
||||
where
|
||||
F: FnMut(PathBuf) -> bool,
|
||||
{
|
||||
let roots = [
|
||||
home.join(".cursor").join("extensions"),
|
||||
home.join(".vscode").join("extensions"),
|
||||
home.join(".vscode-insiders").join("extensions"),
|
||||
home.join(".windsurf").join("extensions"),
|
||||
];
|
||||
let mut candidates = Vec::new();
|
||||
for root in roots {
|
||||
if deadline_expired(deadline) {
|
||||
return false;
|
||||
}
|
||||
let Ok(entries) = fs::read_dir(root) else {
|
||||
continue;
|
||||
};
|
||||
let mut extension_dirs = entries
|
||||
.flatten()
|
||||
.map(|entry| entry.path())
|
||||
.filter(|path| {
|
||||
path.is_dir()
|
||||
&& path
|
||||
.file_name()
|
||||
.and_then(|value| value.to_str())
|
||||
.is_some_and(|name| {
|
||||
let lower = name.to_ascii_lowercase();
|
||||
lower.starts_with("openai.chatgpt-")
|
||||
|| lower.starts_with("openai.codex-")
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let mut extension_dirs = Vec::new();
|
||||
for entry in entries.flatten() {
|
||||
if deadline_expired(deadline) {
|
||||
break;
|
||||
}
|
||||
let path = entry.path();
|
||||
if path.is_dir()
|
||||
&& path
|
||||
.file_name()
|
||||
.and_then(|value| value.to_str())
|
||||
.is_some_and(|name| {
|
||||
let lower = name.to_ascii_lowercase();
|
||||
lower.starts_with("openai.chatgpt-") || lower.starts_with("openai.codex-")
|
||||
})
|
||||
{
|
||||
extension_dirs.push(path);
|
||||
}
|
||||
}
|
||||
if deadline_expired(deadline) {
|
||||
return false;
|
||||
}
|
||||
extension_dirs.sort_by(|a, b| b.file_name().cmp(&a.file_name()));
|
||||
for extension_dir in extension_dirs {
|
||||
collect_named_files(
|
||||
if deadline_expired(deadline) {
|
||||
return false;
|
||||
}
|
||||
if !visit_named_files(
|
||||
&extension_dir,
|
||||
&["codex", "codex.exe", "codex.cmd"],
|
||||
5,
|
||||
&mut candidates,
|
||||
);
|
||||
deadline,
|
||||
visit,
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
candidates
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn platform_candidates(home: &Path) -> Vec<PathBuf> {
|
||||
let mut candidates = vec![
|
||||
fn visit_platform_candidates<F>(home: &Path, deadline: Option<Instant>, visit: &mut F) -> bool
|
||||
where
|
||||
F: FnMut(PathBuf) -> bool,
|
||||
{
|
||||
let candidates = [
|
||||
PathBuf::from("/Applications/ChatGPT.app/Contents/Resources/codex"),
|
||||
home.join("Applications/ChatGPT.app/Contents/Resources/codex"),
|
||||
PathBuf::from("/Applications/Codex.app/Contents/Resources/codex"),
|
||||
@@ -245,30 +435,49 @@ fn platform_candidates(home: &Path) -> Vec<PathBuf> {
|
||||
home.join(".npm-global/bin/codex"),
|
||||
home.join("Library/pnpm/codex"),
|
||||
];
|
||||
candidates.extend(extension_codex_candidates(home));
|
||||
candidates
|
||||
for candidate in candidates {
|
||||
if deadline_expired(deadline) || !visit(candidate) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
visit_extension_codex_candidates(home, deadline, visit)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn platform_candidates(home: &Path) -> Vec<PathBuf> {
|
||||
let mut candidates = Vec::new();
|
||||
fn visit_platform_candidates<F>(home: &Path, deadline: Option<Instant>, visit: &mut F) -> bool
|
||||
where
|
||||
F: FnMut(PathBuf) -> bool,
|
||||
{
|
||||
if let Ok(appdata) = env::var("APPDATA") {
|
||||
let appdata = PathBuf::from(appdata);
|
||||
candidates.push(appdata.join("npm").join("codex.cmd"));
|
||||
candidates.push(appdata.join("npm").join("codex.exe"));
|
||||
for candidate in [
|
||||
appdata.join("npm").join("codex.cmd"),
|
||||
appdata.join("npm").join("codex.exe"),
|
||||
] {
|
||||
if deadline_expired(deadline) || !visit(candidate) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
for target in ["x86_64-pc-windows-msvc", "aarch64-pc-windows-msvc"] {
|
||||
candidates.push(
|
||||
appdata
|
||||
.join("npm/node_modules/@openai/codex/vendor")
|
||||
.join(target)
|
||||
.join("codex/codex.exe"),
|
||||
);
|
||||
let candidate = appdata
|
||||
.join("npm/node_modules/@openai/codex/vendor")
|
||||
.join(target)
|
||||
.join("codex/codex.exe");
|
||||
if deadline_expired(deadline) || !visit(candidate) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Ok(localappdata) = env::var("LOCALAPPDATA") {
|
||||
let localappdata = PathBuf::from(localappdata);
|
||||
candidates.push(localappdata.join("Microsoft/WindowsApps/codex.exe"));
|
||||
candidates.push(localappdata.join("Microsoft/WindowsApps/codex.cmd"));
|
||||
for candidate in [
|
||||
localappdata.join("Microsoft/WindowsApps/codex.exe"),
|
||||
localappdata.join("Microsoft/WindowsApps/codex.cmd"),
|
||||
] {
|
||||
if deadline_expired(deadline) || !visit(candidate) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
for root in [
|
||||
localappdata.join("Programs/ChatGPT"),
|
||||
localappdata.join("Programs/Codex"),
|
||||
@@ -276,28 +485,35 @@ fn platform_candidates(home: &Path) -> Vec<PathBuf> {
|
||||
localappdata.join("OpenAI/ChatGPT"),
|
||||
localappdata.join("OpenAI/Codex"),
|
||||
] {
|
||||
collect_named_files(&root, &["codex.exe", "codex.cmd"], 7, &mut candidates);
|
||||
if !visit_named_files(&root, &["codex.exe", "codex.cmd"], 7, deadline, visit) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
for variable in ["ProgramFiles", "ProgramFiles(x86)"] {
|
||||
if let Ok(program_files) = env::var(variable) {
|
||||
for app in ["ChatGPT", "Codex"] {
|
||||
collect_named_files(
|
||||
if !visit_named_files(
|
||||
&PathBuf::from(&program_files).join(app),
|
||||
&["codex.exe", "codex.cmd"],
|
||||
7,
|
||||
&mut candidates,
|
||||
);
|
||||
deadline,
|
||||
visit,
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
candidates.extend(extension_codex_candidates(home));
|
||||
candidates
|
||||
visit_extension_codex_candidates(home, deadline, visit)
|
||||
}
|
||||
|
||||
#[cfg(all(not(target_os = "macos"), not(target_os = "windows")))]
|
||||
fn platform_candidates(home: &Path) -> Vec<PathBuf> {
|
||||
let mut candidates = vec![
|
||||
fn visit_platform_candidates<F>(home: &Path, deadline: Option<Instant>, visit: &mut F) -> bool
|
||||
where
|
||||
F: FnMut(PathBuf) -> bool,
|
||||
{
|
||||
let candidates = [
|
||||
PathBuf::from("/usr/local/bin/codex"),
|
||||
PathBuf::from("/usr/bin/codex"),
|
||||
PathBuf::from("/snap/bin/codex"),
|
||||
@@ -305,21 +521,17 @@ fn platform_candidates(home: &Path) -> Vec<PathBuf> {
|
||||
home.join(".npm-global/bin/codex"),
|
||||
home.join(".local/share/pnpm/codex"),
|
||||
];
|
||||
candidates.extend(extension_codex_candidates(home));
|
||||
candidates
|
||||
for candidate in candidates {
|
||||
if deadline_expired(deadline) || !visit(candidate) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
visit_extension_codex_candidates(home, deadline, visit)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn windows_where_candidates() -> Vec<PathBuf> {
|
||||
use std::os::windows::process::CommandExt;
|
||||
|
||||
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||
let mut command = Command::new("where.exe");
|
||||
let Ok(output) = command
|
||||
.creation_flags(CREATE_NO_WINDOW)
|
||||
.arg("codex")
|
||||
.output()
|
||||
else {
|
||||
fn windows_where_candidates(deadline: Option<Instant>) -> Vec<PathBuf> {
|
||||
let Some(output) = run_program(Path::new("where.exe"), &["codex"], deadline) else {
|
||||
return Vec::new();
|
||||
};
|
||||
if !output.status.success() {
|
||||
@@ -334,12 +546,12 @@ fn windows_where_candidates() -> Vec<PathBuf> {
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
fn windows_where_candidates() -> Vec<PathBuf> {
|
||||
fn windows_where_candidates(_deadline: Option<Instant>) -> Vec<PathBuf> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn macos_app_version() -> Option<String> {
|
||||
fn macos_app_version(deadline: Option<Instant>) -> Option<String> {
|
||||
let home = dirs::home_dir().unwrap_or_default();
|
||||
for root in [PathBuf::from("/Applications"), home.join("Applications")] {
|
||||
for name in [
|
||||
@@ -349,6 +561,9 @@ fn macos_app_version() -> Option<String> {
|
||||
"ChatGPT Codex.app",
|
||||
"ChatGPT.app",
|
||||
] {
|
||||
if deadline_expired(deadline) {
|
||||
return None;
|
||||
}
|
||||
let app = root.join(name);
|
||||
if !app.is_dir() {
|
||||
continue;
|
||||
@@ -360,8 +575,11 @@ fn macos_app_version() -> Option<String> {
|
||||
};
|
||||
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])?;
|
||||
let output = run_program(
|
||||
Path::new("mdls"),
|
||||
&["-name", "kMDItemVersion", "-raw", app],
|
||||
deadline,
|
||||
)?;
|
||||
output
|
||||
.status
|
||||
.success()
|
||||
@@ -394,12 +612,12 @@ fn plist_string_value(plist: &str, key: &str) -> Option<String> {
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
fn macos_app_version() -> Option<String> {
|
||||
fn macos_app_version(_deadline: Option<Instant>) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn windows_app_version() -> Option<String> {
|
||||
fn windows_app_version(deadline: Option<Instant>) -> Option<String> {
|
||||
let mut roots = Vec::new();
|
||||
for variable in ["ProgramFiles", "ProgramW6432"] {
|
||||
if let Ok(program_files) = env::var(variable) {
|
||||
@@ -409,14 +627,19 @@ fn windows_app_version() -> Option<String> {
|
||||
roots.push(PathBuf::from(r"C:\Program Files\WindowsApps"));
|
||||
roots.sort();
|
||||
roots.dedup();
|
||||
if let Some(version) = windows_store_app_version_from_roots(&roots) {
|
||||
if let Some(version) = windows_store_app_version_from_roots(&roots, deadline) {
|
||||
return Some(version);
|
||||
}
|
||||
|
||||
if deadline_expired(deadline) {
|
||||
return None;
|
||||
}
|
||||
|
||||
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],
|
||||
deadline,
|
||||
) {
|
||||
if output.status.success() {
|
||||
let versions = String::from_utf8_lossy(&output.stdout)
|
||||
@@ -440,6 +663,9 @@ fn windows_app_version() -> Option<String> {
|
||||
local_appdata.join("Programs/OpenAI/Codex"),
|
||||
local_appdata.join("Programs/Codex"),
|
||||
] {
|
||||
if deadline_expired(deadline) {
|
||||
return None;
|
||||
}
|
||||
if WINDOWS_CODEX_EXECUTABLES.iter().any(|name| {
|
||||
directory.join(name).is_file() || directory.join("app").join(name).is_file()
|
||||
}) {
|
||||
@@ -450,41 +676,143 @@ fn windows_app_version() -> Option<String> {
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
fn windows_app_version() -> Option<String> {
|
||||
fn windows_app_version(_deadline: Option<Instant>) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
pub fn codex_executable_candidates() -> Vec<PathBuf> {
|
||||
let home = dirs::home_dir().unwrap_or_default();
|
||||
fn path_codex_candidates(deadline: Option<Instant>) -> Vec<PathBuf> {
|
||||
let mut candidates = ["codex", "codex.exe", "codex.cmd"]
|
||||
.into_iter()
|
||||
.map(PathBuf::from)
|
||||
.collect::<Vec<_>>();
|
||||
candidates.extend(windows_where_candidates());
|
||||
candidates.extend(platform_candidates(&home));
|
||||
if !deadline_expired(deadline) {
|
||||
candidates.extend(windows_where_candidates(deadline));
|
||||
}
|
||||
candidates
|
||||
}
|
||||
|
||||
fn append_unique_candidates(
|
||||
unique: &mut Vec<PathBuf>,
|
||||
seen: &mut HashSet<String>,
|
||||
candidates: impl IntoIterator<Item = PathBuf>,
|
||||
) {
|
||||
for candidate in candidates {
|
||||
push_candidate(unique, seen, candidate);
|
||||
}
|
||||
}
|
||||
|
||||
fn codex_executable_candidates_until(deadline: Option<Instant>) -> Vec<PathBuf> {
|
||||
let home = dirs::home_dir().unwrap_or_default();
|
||||
let mut seen = HashSet::new();
|
||||
let mut unique = Vec::new();
|
||||
for candidate in candidates {
|
||||
push_candidate(&mut unique, &mut seen, candidate);
|
||||
append_unique_candidates(&mut unique, &mut seen, path_codex_candidates(deadline));
|
||||
if !deadline_expired(deadline) {
|
||||
let mut collect = |candidate| {
|
||||
if deadline_expired(deadline) {
|
||||
return false;
|
||||
}
|
||||
push_candidate(&mut unique, &mut seen, candidate);
|
||||
true
|
||||
};
|
||||
let _ = visit_platform_candidates(&home, deadline, &mut collect);
|
||||
}
|
||||
unique
|
||||
}
|
||||
|
||||
pub fn detect_codex_version() -> Option<String> {
|
||||
for candidate in codex_executable_candidates() {
|
||||
pub fn codex_executable_candidates() -> Vec<PathBuf> {
|
||||
codex_executable_candidates_until(None)
|
||||
}
|
||||
|
||||
fn version_from_candidates(
|
||||
candidates: impl IntoIterator<Item = PathBuf>,
|
||||
seen: &mut HashSet<String>,
|
||||
deadline: Instant,
|
||||
) -> Option<String> {
|
||||
for candidate in candidates {
|
||||
if deadline_expired(Some(deadline)) {
|
||||
return None;
|
||||
}
|
||||
if !seen.insert(candidate_key(&candidate)) {
|
||||
continue;
|
||||
}
|
||||
let is_bare_command = candidate.components().count() == 1;
|
||||
if is_bare_command || candidate.is_file() {
|
||||
if let Some(version) = command_version(&candidate) {
|
||||
if let Some(version) = command_version(&candidate, Some(deadline)) {
|
||||
return Some(version);
|
||||
}
|
||||
}
|
||||
}
|
||||
macos_app_version().or_else(windows_app_version)
|
||||
None
|
||||
}
|
||||
|
||||
fn version_from_platform_candidates(
|
||||
home: &Path,
|
||||
seen: &mut HashSet<String>,
|
||||
deadline: Instant,
|
||||
) -> Option<String> {
|
||||
let mut detected = None;
|
||||
let mut probe = |candidate| {
|
||||
if deadline_expired(Some(deadline)) {
|
||||
return false;
|
||||
}
|
||||
detected = version_from_candidates([candidate], seen, deadline);
|
||||
detected.is_none() && !deadline_expired(Some(deadline))
|
||||
};
|
||||
let _ = visit_platform_candidates(home, Some(deadline), &mut probe);
|
||||
detected
|
||||
}
|
||||
|
||||
fn detect_codex_version_uncached() -> Option<String> {
|
||||
let deadline = Instant::now().checked_add(CODEX_VERSION_PROBE_TIMEOUT)?;
|
||||
let mut seen = HashSet::new();
|
||||
|
||||
// Probe cheap PATH/where.exe results before walking redirected profiles or slow disks.
|
||||
if let Some(version) =
|
||||
version_from_candidates(path_codex_candidates(Some(deadline)), &mut seen, deadline)
|
||||
{
|
||||
return Some(version);
|
||||
}
|
||||
if deadline_expired(Some(deadline)) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let home = dirs::home_dir().unwrap_or_default();
|
||||
if let Some(version) = version_from_platform_candidates(&home, &mut seen, deadline) {
|
||||
return Some(version);
|
||||
}
|
||||
if deadline_expired(Some(deadline)) {
|
||||
return None;
|
||||
}
|
||||
macos_app_version(Some(deadline)).or_else(|| windows_app_version(Some(deadline)))
|
||||
}
|
||||
|
||||
fn cached_codex_version(
|
||||
cache: &OnceLock<String>,
|
||||
detect: impl FnOnce() -> Option<String>,
|
||||
) -> Option<String> {
|
||||
if let Some(version) = cache.get() {
|
||||
return Some(version.clone());
|
||||
}
|
||||
let detected = detect()?;
|
||||
let _ = cache.set(detected.clone());
|
||||
cache.get().cloned().or(Some(detected))
|
||||
}
|
||||
|
||||
pub fn detect_codex_version() -> Option<String> {
|
||||
cached_codex_version(&CODEX_VERSION, detect_codex_version_uncached)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{latest_windows_package_version, plist_string_value, version_line};
|
||||
use super::{
|
||||
cached_codex_version, latest_windows_package_version, plist_string_value, run_program,
|
||||
version_line, visit_named_files,
|
||||
};
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::OnceLock;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
#[test]
|
||||
fn version_parser_prefers_codex_line_over_warning() {
|
||||
@@ -515,6 +843,94 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_version_cache_runs_probe_once() {
|
||||
let cache = OnceLock::new();
|
||||
let calls = AtomicUsize::new(0);
|
||||
|
||||
let first = cached_codex_version(&cache, || {
|
||||
calls.fetch_add(1, Ordering::Relaxed);
|
||||
Some("codex-cli 1.2.3".to_string())
|
||||
});
|
||||
let second = cached_codex_version(&cache, || {
|
||||
calls.fetch_add(1, Ordering::Relaxed);
|
||||
Some("codex-cli 9.9.9".to_string())
|
||||
});
|
||||
|
||||
assert_eq!(first.as_deref(), Some("codex-cli 1.2.3"));
|
||||
assert_eq!(second, first);
|
||||
assert_eq!(calls.load(Ordering::Relaxed), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_version_cache_retries_after_a_missing_result() {
|
||||
let cache = OnceLock::new();
|
||||
let calls = AtomicUsize::new(0);
|
||||
|
||||
assert_eq!(
|
||||
cached_codex_version(&cache, || {
|
||||
calls.fetch_add(1, Ordering::Relaxed);
|
||||
None
|
||||
}),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
cached_codex_version(&cache, || {
|
||||
calls.fetch_add(1, Ordering::Relaxed);
|
||||
Some("codex-cli 1.2.3".to_string())
|
||||
}),
|
||||
Some("codex-cli 1.2.3".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
cached_codex_version(&cache, || {
|
||||
calls.fetch_add(1, Ordering::Relaxed);
|
||||
Some("codex-cli 9.9.9".to_string())
|
||||
}),
|
||||
Some("codex-cli 1.2.3".to_string())
|
||||
);
|
||||
assert_eq!(calls.load(Ordering::Relaxed), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recursive_candidate_scan_stops_immediately_after_visitor_finishes() {
|
||||
static TEMP_SEQUENCE: AtomicUsize = AtomicUsize::new(0);
|
||||
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"codex-x-platform-test-{}-{}",
|
||||
std::process::id(),
|
||||
TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed)
|
||||
));
|
||||
fs::create_dir_all(root.join("first")).expect("create first candidate directory");
|
||||
fs::create_dir_all(root.join("second")).expect("create second candidate directory");
|
||||
fs::write(root.join("first/codex.exe"), b"").expect("create first candidate");
|
||||
fs::write(root.join("second/codex.exe"), b"").expect("create second candidate");
|
||||
|
||||
let mut visits = 0;
|
||||
let completed = visit_named_files(&root, &["codex.exe"], 3, None, &mut |_| {
|
||||
visits += 1;
|
||||
false
|
||||
});
|
||||
fs::remove_dir_all(&root).expect("remove candidate test directory");
|
||||
|
||||
assert!(!completed);
|
||||
assert_eq!(visits, 1);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn program_runner_stops_hung_process_at_deadline() {
|
||||
let started = Instant::now();
|
||||
let deadline = started + Duration::from_millis(100);
|
||||
|
||||
assert!(run_program(
|
||||
Path::new("/bin/sh"),
|
||||
&["-c", "while :; do :; done"],
|
||||
Some(deadline),
|
||||
)
|
||||
.is_none());
|
||||
assert!(started.elapsed() < Duration::from_secs(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_package_detection_accepts_supported_codex_packages() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
use super::{
|
||||
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,
|
||||
normalize_saved_provider, open_store, provider_template_from_document,
|
||||
upsert_ccswitch_provider_on_connection, ProviderUpsertKind, SavedProvider,
|
||||
};
|
||||
use crate::ccswitch::{ccswitch_db_candidates, default_ccswitch_db_path};
|
||||
use crate::error::{CodexxError, Result};
|
||||
use crate::sqlite_utils::table_column_set;
|
||||
use crate::string_value;
|
||||
use crate::toml_utils::ensure_table;
|
||||
use rusqlite::{Connection, OpenFlags, TransactionBehavior};
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use toml_edit::{DocumentMut, Table};
|
||||
use toml_edit::{value, DocumentMut, Item, Table};
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -31,6 +32,7 @@ pub(crate) struct ImportResult {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct OfficialAuthCandidate {
|
||||
auth_json: String,
|
||||
config_text: Option<String>,
|
||||
model: Option<String>,
|
||||
source: String,
|
||||
}
|
||||
@@ -92,6 +94,7 @@ pub(crate) struct CcSwitchCodexSection {
|
||||
pub(crate) wire_api: String,
|
||||
pub(crate) requires_openai_auth: bool,
|
||||
pub(crate) experimental_bearer_token: Option<String>,
|
||||
pub(crate) provider_table: Table,
|
||||
}
|
||||
|
||||
fn table_string(table: &Table, key: &str) -> Option<String> {
|
||||
@@ -135,6 +138,7 @@ pub(super) fn codex_section_from_table(
|
||||
.and_then(|item| item.as_bool())
|
||||
.unwrap_or(false),
|
||||
experimental_bearer_token: table_string(table, "experimental_bearer_token"),
|
||||
provider_table: table.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -161,79 +165,120 @@ fn select_ccswitch_section_for_row(
|
||||
global_sections: &HashMap<String, CcSwitchCodexSection>,
|
||||
) -> Option<CcSwitchCodexSection> {
|
||||
let provider_id = custom_provider_id(&row.id);
|
||||
if let Some(section) = global_sections.get(&provider_id) {
|
||||
return Some(section.clone());
|
||||
}
|
||||
if let Some(section) = global_sections.get(row.id.trim()) {
|
||||
return Some(section.clone());
|
||||
}
|
||||
|
||||
let config_text = settings.get("config").and_then(Value::as_str).unwrap_or("");
|
||||
let doc = config_text.parse::<DocumentMut>().ok()?;
|
||||
let model = string_value(&doc, "model");
|
||||
let active_provider = string_value(&doc, "model_provider");
|
||||
let providers = doc.get("model_providers").and_then(|item| item.as_table());
|
||||
let doc = config_text.parse::<DocumentMut>().ok();
|
||||
|
||||
if let Some(providers) = providers {
|
||||
for exact_id in [provider_id.as_str(), row.id.trim()] {
|
||||
if let Some(section) = providers
|
||||
.get(exact_id)
|
||||
.and_then(|item| item.as_table())
|
||||
.and_then(|table| codex_section_from_table(exact_id, table, model.clone()))
|
||||
{
|
||||
return Some(section);
|
||||
}
|
||||
}
|
||||
if let Some(doc) = doc.as_ref() {
|
||||
let model = string_value(doc, "model");
|
||||
let active_provider = string_value(doc, "model_provider");
|
||||
let providers = doc.get("model_providers").and_then(|item| item.as_table());
|
||||
|
||||
if active_provider.as_deref() == Some(row.id.trim())
|
||||
|| active_provider.as_deref() == Some(provider_id.as_str())
|
||||
{
|
||||
if let Some(active) = active_provider.as_deref() {
|
||||
if let Some(providers) = providers {
|
||||
for exact_id in [provider_id.as_str(), row.id.trim()] {
|
||||
if let Some(section) = providers
|
||||
.get(active)
|
||||
.get(exact_id)
|
||||
.and_then(|item| item.as_table())
|
||||
.and_then(|table| codex_section_from_table(active, table, model.clone()))
|
||||
.and_then(|table| codex_section_from_table(exact_id, table, model.clone()))
|
||||
{
|
||||
return Some(section);
|
||||
}
|
||||
}
|
||||
|
||||
if active_provider.as_deref() == Some(row.id.trim())
|
||||
|| active_provider.as_deref() == Some(provider_id.as_str())
|
||||
{
|
||||
if let Some(active) = active_provider.as_deref() {
|
||||
if let Some(section) = providers
|
||||
.get(active)
|
||||
.and_then(|item| item.as_table())
|
||||
.and_then(|table| codex_section_from_table(active, table, model.clone()))
|
||||
{
|
||||
return Some(section);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy cc-switch templates store each third-party provider under
|
||||
// `[model_providers.custom]` in that row's own complete config.
|
||||
if active_provider
|
||||
.as_deref()
|
||||
.is_none_or(|active| active == "custom")
|
||||
{
|
||||
if let Some(section) = providers
|
||||
.get("custom")
|
||||
.and_then(|item| item.as_table())
|
||||
.and_then(|table| codex_section_from_table("custom", table, model.clone()))
|
||||
{
|
||||
return Some(section);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy cc-switch/custom templates often store every third-party provider
|
||||
// under `[model_providers.custom]`. Only use it when the row's own config
|
||||
// explicitly activates custom or contains no other provider identity.
|
||||
if active_provider
|
||||
.as_deref()
|
||||
.is_none_or(|active| active == "custom")
|
||||
{
|
||||
if let Some(section) = providers
|
||||
.get("custom")
|
||||
.and_then(|item| item.as_table())
|
||||
.and_then(|table| codex_section_from_table("custom", table, model.clone()))
|
||||
{
|
||||
return Some(section);
|
||||
}
|
||||
for exact_id in [provider_id.as_str(), row.id.trim()] {
|
||||
if let Some(section) = global_sections.get(exact_id) {
|
||||
return Some(section.clone());
|
||||
}
|
||||
}
|
||||
|
||||
let doc = doc?;
|
||||
let active_provider = string_value(&doc, "model_provider");
|
||||
doc.get("base_url")
|
||||
.and_then(|item| item.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|base_url| CcSwitchCodexSection {
|
||||
id: provider_id,
|
||||
name: None,
|
||||
base_url: base_url.trim_end_matches('/').to_string(),
|
||||
model,
|
||||
wire_api: "responses".to_string(),
|
||||
requires_openai_auth: false,
|
||||
experimental_bearer_token: experimental_bearer_token_from_doc(
|
||||
&doc,
|
||||
active_provider.as_deref(),
|
||||
),
|
||||
.map(|base_url| {
|
||||
let base_url = base_url.trim_end_matches('/').to_string();
|
||||
let token = experimental_bearer_token_from_doc(&doc, active_provider.as_deref());
|
||||
let mut provider_table = Table::new();
|
||||
provider_table["base_url"] = value(base_url.clone());
|
||||
provider_table["wire_api"] = value("responses");
|
||||
provider_table["requires_openai_auth"] = value(false);
|
||||
if let Some(token) = token.as_deref() {
|
||||
provider_table["experimental_bearer_token"] = value(token);
|
||||
}
|
||||
CcSwitchCodexSection {
|
||||
id: provider_id,
|
||||
name: None,
|
||||
base_url,
|
||||
model: string_value(&doc, "model"),
|
||||
wire_api: "responses".to_string(),
|
||||
requires_openai_auth: false,
|
||||
experimental_bearer_token: token,
|
||||
provider_table,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn ccswitch_provider_template(
|
||||
settings: &Value,
|
||||
section: &CcSwitchCodexSection,
|
||||
provider_name: &str,
|
||||
model: &str,
|
||||
) -> Option<String> {
|
||||
let config_text = settings.get("config").and_then(Value::as_str).unwrap_or("");
|
||||
let mut doc = if config_text.trim().is_empty() {
|
||||
DocumentMut::new()
|
||||
} else {
|
||||
config_text.parse::<DocumentMut>().ok()?
|
||||
};
|
||||
let provider_id = section.id.trim();
|
||||
if provider_id.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
doc["model_provider"] = value(provider_id);
|
||||
doc["model"] = value(model);
|
||||
let providers = ensure_table(doc.as_table_mut(), "model_providers").ok()?;
|
||||
let mut table = section.provider_table.clone();
|
||||
table["name"] = value(provider_name);
|
||||
table["base_url"] = value(section.base_url.clone());
|
||||
table["wire_api"] = value(section.wire_api.clone());
|
||||
table["requires_openai_auth"] = value(section.requires_openai_auth);
|
||||
providers.insert(provider_id, Item::Table(table));
|
||||
provider_template_from_document(&doc, provider_id, model).ok()
|
||||
}
|
||||
|
||||
pub(crate) fn build_ccswitch_codex_provider(
|
||||
row: &CcSwitchCodexRow,
|
||||
global_sections: &HashMap<String, CcSwitchCodexSection>,
|
||||
@@ -241,17 +286,23 @@ pub(crate) fn build_ccswitch_codex_provider(
|
||||
let settings: Value = serde_json::from_str(&row.settings_config).ok()?;
|
||||
let section = select_ccswitch_section_for_row(row, &settings, global_sections)?;
|
||||
let api_key = ccswitch_auth_api_key(&settings).or(section.experimental_bearer_token.clone());
|
||||
let provider_name = if row.name.trim().is_empty() {
|
||||
section.name.clone().unwrap_or_else(|| row.id.clone())
|
||||
} else {
|
||||
row.name.trim().to_string()
|
||||
};
|
||||
let model = section
|
||||
.model
|
||||
.clone()
|
||||
.unwrap_or_else(|| "gpt-5.5".to_string());
|
||||
let toml_config = ccswitch_provider_template(&settings, §ion, &provider_name, &model)?;
|
||||
Some(SavedProvider {
|
||||
id: custom_provider_id(&row.id),
|
||||
provider_name: if row.name.trim().is_empty() {
|
||||
section.name.unwrap_or_else(|| row.id.clone())
|
||||
} else {
|
||||
row.name.trim().to_string()
|
||||
},
|
||||
provider_name,
|
||||
base_url: section.base_url,
|
||||
model: section.model.unwrap_or_else(|| "gpt-5.5".to_string()),
|
||||
model,
|
||||
api_key,
|
||||
toml_config: None,
|
||||
toml_config: Some(toml_config),
|
||||
wire_api: section.wire_api,
|
||||
requires_openai_auth: section.requires_openai_auth,
|
||||
})
|
||||
@@ -435,9 +486,12 @@ pub(crate) fn read_ccswitch_official_auth_inner(
|
||||
CodexxError::Database("cc-switch official provider 缺少 auth object".to_string())
|
||||
})?;
|
||||
|
||||
let model = settings
|
||||
let config_text = settings
|
||||
.get("config")
|
||||
.and_then(Value::as_str)
|
||||
.map(ToString::to_string);
|
||||
let model = config_text
|
||||
.as_deref()
|
||||
.and_then(|text| text.parse::<DocumentMut>().ok())
|
||||
.and_then(|doc| string_value(&doc, "model"));
|
||||
|
||||
@@ -446,7 +500,172 @@ pub(crate) fn read_ccswitch_official_auth_inner(
|
||||
|
||||
Ok(Some(OfficialAuthCandidate {
|
||||
auth_json,
|
||||
config_text,
|
||||
model,
|
||||
source: format!("cc-switch:{name}:{id}"),
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn provider_import_round_trips_complete_config_without_bearer_tokens() {
|
||||
let settings_config = json!({
|
||||
"auth": {"OPENAI_API_KEY": "sk-from-auth"},
|
||||
"config": r#"# keep-imported-comment
|
||||
model_provider = "custom"
|
||||
model = "gpt-5.5"
|
||||
model_reasoning_effort = "xhigh"
|
||||
service_tier = "priority"
|
||||
experimental_bearer_token = "sk-top-level"
|
||||
notify = ["C:\\Users\\Thy\\codex-computer-use.exe", "turn-ended"]
|
||||
|
||||
[model_providers.custom]
|
||||
name = "Stale name"
|
||||
base_url = "https://proxy.example.com/v1/"
|
||||
wire_api = "responses"
|
||||
requires_openai_auth = false
|
||||
experimental_bearer_token = "sk-from-config"
|
||||
request_max_retries = 7
|
||||
|
||||
[model_providers.other]
|
||||
name = "Other provider"
|
||||
base_url = "https://other.example.com/v1"
|
||||
experimental_bearer_token = "sk-other"
|
||||
|
||||
[projects."/work/project"]
|
||||
trust_level = "trusted"
|
||||
|
||||
[desktop]
|
||||
followUpQueueMode = "queue"
|
||||
localeOverride = "zh-CN"
|
||||
|
||||
[windows]
|
||||
sandbox = "elevated"
|
||||
shell_path = 'D:\Program Files\PowerShell\7\pwsh.exe'
|
||||
|
||||
[plugins."browser@openai-bundled"]
|
||||
enabled = true
|
||||
|
||||
[features]
|
||||
js_repl = false
|
||||
|
||||
[shell_environment_policy.set]
|
||||
CODEX_HOME = 'C:\Users\Thy\.codex'
|
||||
|
||||
[mcp_servers.docs]
|
||||
command = "docs-server"
|
||||
"#,
|
||||
})
|
||||
.to_string();
|
||||
let row = CcSwitchCodexRow {
|
||||
id: "magicai-123".to_string(),
|
||||
name: " MagicAI ".to_string(),
|
||||
settings_config,
|
||||
category: None,
|
||||
};
|
||||
|
||||
let provider =
|
||||
build_ccswitch_codex_provider(&row, &HashMap::new()).expect("build cc-switch provider");
|
||||
assert_eq!(provider.id, "magicai-123");
|
||||
assert_eq!(provider.provider_name, "MagicAI");
|
||||
assert_eq!(provider.base_url, "https://proxy.example.com/v1");
|
||||
assert_eq!(provider.model, "gpt-5.5");
|
||||
assert_eq!(provider.api_key.as_deref(), Some("sk-from-auth"));
|
||||
assert_eq!(provider.wire_api, "responses");
|
||||
assert!(!provider.requires_openai_auth);
|
||||
|
||||
let text = provider.toml_config.expect("complete provider TOML");
|
||||
let doc = text.parse::<DocumentMut>().expect("parse provider TOML");
|
||||
assert!(text.contains("# keep-imported-comment"));
|
||||
assert_eq!(doc["model_provider"].as_str(), Some("custom"));
|
||||
assert_eq!(doc["model_reasoning_effort"].as_str(), Some("xhigh"));
|
||||
assert_eq!(doc["service_tier"].as_str(), Some("priority"));
|
||||
assert_eq!(doc["notify"].as_array().map(|values| values.len()), Some(2));
|
||||
assert_eq!(
|
||||
doc["model_providers"]["custom"]["name"].as_str(),
|
||||
Some("MagicAI")
|
||||
);
|
||||
assert_eq!(
|
||||
doc["model_providers"]["custom"]["base_url"].as_str(),
|
||||
Some("https://proxy.example.com/v1")
|
||||
);
|
||||
assert_eq!(
|
||||
doc["model_providers"]["custom"]["request_max_retries"].as_integer(),
|
||||
Some(7)
|
||||
);
|
||||
assert_eq!(
|
||||
doc["model_providers"]["other"]["base_url"].as_str(),
|
||||
Some("https://other.example.com/v1")
|
||||
);
|
||||
assert_eq!(
|
||||
doc["projects"]["/work/project"]["trust_level"].as_str(),
|
||||
Some("trusted")
|
||||
);
|
||||
assert_eq!(doc["desktop"]["followUpQueueMode"].as_str(), Some("queue"));
|
||||
assert_eq!(doc["desktop"]["localeOverride"].as_str(), Some("zh-CN"));
|
||||
assert_eq!(doc["windows"]["sandbox"].as_str(), Some("elevated"));
|
||||
assert_eq!(
|
||||
doc["windows"]["shell_path"].as_str(),
|
||||
Some(r"D:\Program Files\PowerShell\7\pwsh.exe")
|
||||
);
|
||||
assert_eq!(
|
||||
doc["plugins"]["browser@openai-bundled"]["enabled"].as_bool(),
|
||||
Some(true)
|
||||
);
|
||||
assert_eq!(doc["features"]["js_repl"].as_bool(), Some(false));
|
||||
assert_eq!(
|
||||
doc["shell_environment_policy"]["set"]["CODEX_HOME"].as_str(),
|
||||
Some(r"C:\Users\Thy\.codex")
|
||||
);
|
||||
assert_eq!(
|
||||
doc["mcp_servers"]["docs"]["command"].as_str(),
|
||||
Some("docs-server")
|
||||
);
|
||||
assert!(doc.get("experimental_bearer_token").is_none());
|
||||
assert!(doc["model_providers"]
|
||||
.as_table()
|
||||
.expect("model providers table")
|
||||
.iter()
|
||||
.all(|(_, item)| item
|
||||
.as_table()
|
||||
.is_none_or(|table| table.get("experimental_bearer_token").is_none())));
|
||||
assert!(!text.contains("sk-from-auth"));
|
||||
assert!(!text.contains("experimental_bearer_token"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_row_config_does_not_create_a_sparse_provider() {
|
||||
let section = codex_sections_from_config(
|
||||
r#"model_provider = "broken-row"
|
||||
model = "gpt-5.5"
|
||||
|
||||
[model_providers.broken-row]
|
||||
name = "Recovered only from another row"
|
||||
base_url = "https://proxy.example.com/v1"
|
||||
wire_api = "responses"
|
||||
requires_openai_auth = false
|
||||
"#,
|
||||
)
|
||||
.into_iter()
|
||||
.next()
|
||||
.expect("global provider section");
|
||||
let mut global_sections = HashMap::new();
|
||||
global_sections.insert(section.id.clone(), section);
|
||||
let row = CcSwitchCodexRow {
|
||||
id: "broken-row".to_string(),
|
||||
name: "Broken row".to_string(),
|
||||
settings_config: json!({
|
||||
"auth": {"OPENAI_API_KEY": "sk-must-not-import"},
|
||||
"config": "model = ["
|
||||
})
|
||||
.to_string(),
|
||||
category: None,
|
||||
};
|
||||
|
||||
assert!(build_ccswitch_codex_provider(&row, &global_sections).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -24,10 +24,10 @@ pub(crate) use connection::{
|
||||
};
|
||||
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,
|
||||
build_provider_toml_draft_inner, 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::{
|
||||
@@ -35,10 +35,10 @@ pub(crate) use live::{
|
||||
switch_provider_with_pre_persist,
|
||||
};
|
||||
#[cfg(test)]
|
||||
pub(crate) use official_auth::official_snapshot_path_for_test;
|
||||
pub(crate) use official_auth::{capture_live_chatgpt_config, 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,
|
||||
document_is_official, get_official_config_draft_inner, official_auth_available,
|
||||
OfficialConfigDraft,
|
||||
};
|
||||
#[cfg(test)]
|
||||
pub(crate) use store::{
|
||||
@@ -47,12 +47,13 @@ pub(crate) use store::{
|
||||
};
|
||||
pub(crate) use store::{
|
||||
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,
|
||||
delete_provider_inner, experimental_bearer_token_from_doc, is_placeholder_provider,
|
||||
list_saved_providers_inner, list_saved_providers_on_connection,
|
||||
matching_saved_provider_ids_for_live, normalize_saved_provider,
|
||||
provider_template_from_document, reserved_codex_provider_id, rollback_provider_store_inner,
|
||||
save_provider_inner, save_provider_with_rollback_inner, strip_provider_bearer_tokens,
|
||||
unique_saved_provider_id_for_live, upsert_ccswitch_provider_on_connection,
|
||||
ProviderStoreRollback, ProviderUpsertKind, SavedProvider,
|
||||
};
|
||||
|
||||
pub(crate) fn open_store() -> Result<Connection> {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
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::file_io::{ensure_directory, io_err, parse_toml_document, write_private_json};
|
||||
use crate::paths::app_home;
|
||||
use crate::toml_utils::ensure_table;
|
||||
use crate::{auth_path, config_path, string_value};
|
||||
use chrono::Local;
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -9,13 +10,16 @@ use serde_json::Value;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use toml_edit::value;
|
||||
|
||||
const SNAPSHOT_VERSION: u32 = 2;
|
||||
const SNAPSHOT_VERSION: u32 = 3;
|
||||
const MODEL_ONLY_SNAPSHOT_VERSION: u32 = 2;
|
||||
const LEGACY_SNAPSHOT_VERSION: u32 = 1;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct OfficialConfigCandidate {
|
||||
pub(crate) auth: Value,
|
||||
pub(crate) auth: Option<Value>,
|
||||
pub(crate) config_text: Option<String>,
|
||||
pub(crate) model: Option<String>,
|
||||
pub(crate) source: String,
|
||||
}
|
||||
@@ -24,6 +28,7 @@ pub(crate) struct OfficialConfigCandidate {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct OfficialConfigDraft {
|
||||
auth_json: String,
|
||||
config_text: String,
|
||||
model: Option<String>,
|
||||
source: String,
|
||||
}
|
||||
@@ -36,12 +41,14 @@ struct OfficialConfigSnapshot {
|
||||
captured_at: String,
|
||||
model: Option<String>,
|
||||
#[serde(default)]
|
||||
config: Option<String>,
|
||||
#[serde(default)]
|
||||
auth: Option<Value>,
|
||||
}
|
||||
|
||||
enum SnapshotState {
|
||||
Missing,
|
||||
Reset,
|
||||
Reset(OfficialConfigCandidate),
|
||||
Ready(OfficialConfigCandidate),
|
||||
}
|
||||
|
||||
@@ -78,11 +85,14 @@ pub(crate) fn auth_value_has_material(value: &Value) -> bool {
|
||||
})
|
||||
}
|
||||
|
||||
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"));
|
||||
pub(crate) fn is_chatgpt_auth(value: &Value) -> bool {
|
||||
// Older Codex releases wrote OAuth tokens without auth_mode. Accept that
|
||||
// unambiguous legacy shape, but reject any explicitly non-ChatGPT mode.
|
||||
let chatgpt_mode = match value.get("auth_mode") {
|
||||
None => true,
|
||||
Some(Value::String(mode)) => mode.eq_ignore_ascii_case("chatgpt"),
|
||||
Some(_) => false,
|
||||
};
|
||||
let has_api_key = value
|
||||
.get("OPENAI_API_KEY")
|
||||
.and_then(Value::as_str)
|
||||
@@ -116,21 +126,108 @@ fn read_auth_value(path: &Path) -> Result<Option<Value>> {
|
||||
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))?;
|
||||
// A malformed live auth file is not trusted authentication. Treat it as
|
||||
// absent so state reads and provider switches can continue and repair it.
|
||||
// Snapshot parsing remains strict in load_snapshot.
|
||||
let value: Value = match serde_json::from_str(&text) {
|
||||
Ok(value) => value,
|
||||
Err(_) => return Ok(None),
|
||||
};
|
||||
if !value.is_object() || !auth_value_has_material(&value) {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(value))
|
||||
}
|
||||
|
||||
fn official_model(codex_dir: &Path) -> Result<Option<String>> {
|
||||
fn model_from_config(path: &Path, text: &str) -> Result<Option<String>> {
|
||||
let doc = parse_toml_document(path, text)?;
|
||||
Ok(string_value(&doc, "model"))
|
||||
}
|
||||
|
||||
fn live_official_config_text(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"))
|
||||
Ok(document_is_official(&doc).then_some(text))
|
||||
}
|
||||
|
||||
fn remove_bearer_tokens(doc: &mut toml_edit::DocumentMut) {
|
||||
doc.as_table_mut().remove("experimental_bearer_token");
|
||||
if let Some(providers) = doc
|
||||
.get_mut("model_providers")
|
||||
.and_then(|item| item.as_table_mut())
|
||||
{
|
||||
for (_, item) in providers.iter_mut() {
|
||||
if let Some(table) = item.as_table_mut() {
|
||||
table.remove("experimental_bearer_token");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn build_official_config_text(
|
||||
codex_dir: &Path,
|
||||
model: Option<&str>,
|
||||
clear_model_if_none: bool,
|
||||
) -> Result<String> {
|
||||
let path = config_path(codex_dir);
|
||||
let text = if path.is_file() {
|
||||
fs::read_to_string(&path).map_err(|error| io_err(&path, error))?
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
let mut doc = parse_toml_document(&path, &text)?;
|
||||
|
||||
doc["model_provider"] = value("custom");
|
||||
doc.as_table_mut().remove("base_url");
|
||||
remove_bearer_tokens(&mut doc);
|
||||
let providers = ensure_table(doc.as_table_mut(), "model_providers")?;
|
||||
providers.remove("custom");
|
||||
let official = ensure_table(providers, "custom")?;
|
||||
official["name"] = value("OpenAI");
|
||||
official["requires_openai_auth"] = value(true);
|
||||
official["supports_websockets"] = value(true);
|
||||
official["wire_api"] = value("responses");
|
||||
|
||||
if let Some(model) = model.map(str::trim).filter(|model| !model.is_empty()) {
|
||||
doc["model"] = value(model);
|
||||
} else if clear_model_if_none {
|
||||
doc.as_table_mut().remove("model");
|
||||
}
|
||||
|
||||
Ok(doc.to_string())
|
||||
}
|
||||
|
||||
pub(crate) fn validate_official_config_text(
|
||||
codex_dir: &Path,
|
||||
config_text: &str,
|
||||
model: Option<&str>,
|
||||
) -> Result<(String, Option<String>)> {
|
||||
let path = config_path(codex_dir);
|
||||
let mut doc = parse_toml_document(&path, config_text)?;
|
||||
let has_legacy_proxy_endpoint = doc
|
||||
.get("base_url")
|
||||
.and_then(|item| item.as_str())
|
||||
.is_some_and(|value| !value.trim().is_empty());
|
||||
if !document_is_official(&doc) || has_legacy_proxy_endpoint {
|
||||
return Err(CodexxError::Config(
|
||||
"官方 config.toml 必须使用 OpenAI 官方路由,不能包含第三方 base_url".to_string(),
|
||||
));
|
||||
}
|
||||
remove_bearer_tokens(&mut doc);
|
||||
// A supplied complete TOML document is authoritative. The separate model
|
||||
// field only fills a missing value for compatibility with older clients.
|
||||
if string_value(&doc, "model").is_none() {
|
||||
if let Some(model) = model.map(str::trim).filter(|model| !model.is_empty()) {
|
||||
doc["model"] = value(model);
|
||||
}
|
||||
}
|
||||
let text = doc.to_string();
|
||||
let model = string_value(&doc, "model");
|
||||
Ok((text, model))
|
||||
}
|
||||
|
||||
pub(crate) fn live_config_is_official(codex_dir: &Path) -> Result<bool> {
|
||||
@@ -175,7 +272,12 @@ pub(crate) fn document_is_official(doc: &toml_edit::DocumentMut) -> bool {
|
||||
})
|
||||
}
|
||||
|
||||
fn write_snapshot(codex_dir: &Path, model: Option<String>, auth: Option<Value>) -> Result<()> {
|
||||
fn write_snapshot(
|
||||
codex_dir: &Path,
|
||||
config: Option<String>,
|
||||
model: Option<String>,
|
||||
auth: Option<Value>,
|
||||
) -> Result<()> {
|
||||
let path = official_snapshot_path(codex_dir)?;
|
||||
if let Some(parent) = path.parent() {
|
||||
ensure_directory(parent)?;
|
||||
@@ -185,6 +287,7 @@ fn write_snapshot(codex_dir: &Path, model: Option<String>, auth: Option<Value>)
|
||||
codex_dir: canonical_identity(codex_dir),
|
||||
captured_at: Local::now().to_rfc3339(),
|
||||
model,
|
||||
config,
|
||||
auth,
|
||||
};
|
||||
let value = serde_json::to_value(snapshot)
|
||||
@@ -194,6 +297,7 @@ fn write_snapshot(codex_dir: &Path, model: Option<String>, auth: Option<Value>)
|
||||
|
||||
pub(crate) fn save_official_config_snapshot(
|
||||
codex_dir: &Path,
|
||||
config: Option<String>,
|
||||
model: Option<String>,
|
||||
auth: &Value,
|
||||
) -> Result<()> {
|
||||
@@ -202,25 +306,68 @@ pub(crate) fn save_official_config_snapshot(
|
||||
"官方 auth.json 没有可用认证信息,请先完成官方登录".to_string(),
|
||||
));
|
||||
}
|
||||
write_snapshot(codex_dir, model, Some(auth.clone()))
|
||||
write_snapshot(codex_dir, config, 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 mark_official_config_reset(
|
||||
codex_dir: &Path,
|
||||
config: Option<String>,
|
||||
model: Option<String>,
|
||||
) -> Result<()> {
|
||||
write_snapshot(codex_dir, config, 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)
|
||||
})
|
||||
}
|
||||
if !live_config_is_official(codex_dir)? {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
pub(crate) fn capture_live_chatgpt_config(codex_dir: &Path) -> Result<bool> {
|
||||
capture_live_official_auth(codex_dir, is_chatgpt_auth)
|
||||
let live_config = live_official_config_text(codex_dir)?;
|
||||
let live_model = live_config
|
||||
.as_deref()
|
||||
.map(|text| model_from_config(&config_path(codex_dir), text))
|
||||
.transpose()?
|
||||
.flatten();
|
||||
let has_explicit_official_route = live_config
|
||||
.as_deref()
|
||||
.map(|text| {
|
||||
let doc = parse_toml_document(&config_path(codex_dir), text)?;
|
||||
Ok::<_, CodexxError>(string_value(&doc, "model_provider").is_some())
|
||||
})
|
||||
.transpose()?
|
||||
.unwrap_or(false);
|
||||
let live_auth = read_auth_value(&auth_path(codex_dir))?.filter(|auth| {
|
||||
is_chatgpt_auth(auth) || (has_explicit_official_route && has_openai_api_key(auth))
|
||||
});
|
||||
|
||||
let previous = match load_snapshot(codex_dir)? {
|
||||
SnapshotState::Ready(candidate) | SnapshotState::Reset(candidate) => Some(candidate),
|
||||
SnapshotState::Missing => None,
|
||||
};
|
||||
let config = live_config.or_else(|| {
|
||||
previous
|
||||
.as_ref()
|
||||
.and_then(|candidate| candidate.config_text.clone())
|
||||
});
|
||||
let model = live_model.or_else(|| {
|
||||
previous
|
||||
.as_ref()
|
||||
.and_then(|candidate| candidate.model.clone())
|
||||
});
|
||||
let previous_auth = previous
|
||||
.as_ref()
|
||||
.and_then(|candidate| candidate.auth.clone());
|
||||
let auth = prefer_chatgpt_auth(live_auth, previous_auth);
|
||||
if config.is_none() && auth.is_none() {
|
||||
return Ok(false);
|
||||
}
|
||||
write_snapshot(codex_dir, config, model, auth)?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn capture_live_official_auth(
|
||||
codex_dir: &Path,
|
||||
is_trusted: impl FnOnce(&Value) -> bool,
|
||||
@@ -234,28 +381,70 @@ fn capture_live_official_auth(
|
||||
if !is_trusted(&auth) {
|
||||
return Ok(false);
|
||||
}
|
||||
save_official_config_snapshot(codex_dir, official_model(codex_dir)?, &auth)?;
|
||||
let config = live_official_config_text(codex_dir)?;
|
||||
let model = config
|
||||
.as_deref()
|
||||
.map(|text| model_from_config(&config_path(codex_dir), text))
|
||||
.transpose()?
|
||||
.flatten();
|
||||
save_official_config_snapshot(codex_dir, config, model, &auth)?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn capture_live_chatgpt_config(codex_dir: &Path) -> Result<bool> {
|
||||
capture_live_official_auth(codex_dir, is_chatgpt_auth)
|
||||
}
|
||||
|
||||
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)
|
||||
let snapshot: OfficialConfigSnapshot = match serde_json::from_str(&text) {
|
||||
Ok(snapshot) => snapshot,
|
||||
Err(_) => return Ok(SnapshotState::Missing),
|
||||
};
|
||||
// This is an app-owned recovery cache, not the live source of truth. A
|
||||
// truncated, stale, or incompatible cache must never block startup or a
|
||||
// provider switch; valid live auth/history can repair it on the next write.
|
||||
Ok(snapshot_state(codex_dir, &path, snapshot).unwrap_or(SnapshotState::Missing))
|
||||
}
|
||||
|
||||
fn prefer_chatgpt_auth(primary: Option<Value>, fallback: Option<Value>) -> Option<Value> {
|
||||
if primary.as_ref().is_some_and(is_chatgpt_auth) {
|
||||
return primary;
|
||||
}
|
||||
if fallback.as_ref().is_some_and(is_chatgpt_auth) {
|
||||
return fallback;
|
||||
}
|
||||
primary.or(fallback)
|
||||
}
|
||||
|
||||
fn snapshot_state(
|
||||
codex_dir: &Path,
|
||||
path: &Path,
|
||||
snapshot: OfficialConfigSnapshot,
|
||||
) -> Result<SnapshotState> {
|
||||
if !matches!(
|
||||
snapshot.version,
|
||||
SNAPSHOT_VERSION | MODEL_ONLY_SNAPSHOT_VERSION | LEGACY_SNAPSHOT_VERSION
|
||||
) || snapshot.codex_dir != canonical_identity(codex_dir)
|
||||
{
|
||||
return Err(CodexxError::Config(format!(
|
||||
"官方配置快照与当前 CODEX_HOME 不匹配: {}",
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
let source = "Codex-X 官方配置快照".to_string();
|
||||
let Some(auth) = snapshot.auth else {
|
||||
return Ok(SnapshotState::Reset);
|
||||
return Ok(SnapshotState::Reset(OfficialConfigCandidate {
|
||||
auth: None,
|
||||
config_text: snapshot.config,
|
||||
model: snapshot.model,
|
||||
source,
|
||||
}));
|
||||
};
|
||||
if !auth.is_object() || !auth_value_has_material(&auth) {
|
||||
return Err(CodexxError::Config(format!(
|
||||
@@ -269,9 +458,10 @@ fn load_snapshot(codex_dir: &Path) -> Result<SnapshotState> {
|
||||
return Ok(SnapshotState::Missing);
|
||||
}
|
||||
Ok(SnapshotState::Ready(OfficialConfigCandidate {
|
||||
auth,
|
||||
auth: Some(auth),
|
||||
config_text: snapshot.config,
|
||||
model: snapshot.model,
|
||||
source: "Codex-X 官方配置快照".to_string(),
|
||||
source,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -289,14 +479,12 @@ fn backup_config_is_official(dir: &Path, meta: &BackupMeta) -> bool {
|
||||
document_is_official(&doc)
|
||||
}
|
||||
|
||||
fn backup_model(dir: &Path, meta: &BackupMeta) -> Option<String> {
|
||||
fn backup_config(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")
|
||||
fs::read_to_string(&path).ok()
|
||||
}
|
||||
|
||||
fn latest_official_backup(codex_dir: &Path) -> Result<Option<OfficialConfigCandidate>> {
|
||||
@@ -335,85 +523,282 @@ fn latest_official_backup(codex_dir: &Path) -> Result<Option<OfficialConfigCandi
|
||||
if !is_chatgpt_auth(&auth) {
|
||||
continue;
|
||||
}
|
||||
candidates.push((meta.created_at.clone(), dir, meta, auth));
|
||||
let config_text = backup_config(&dir, &meta);
|
||||
let model = config_text.as_deref().and_then(|text| {
|
||||
model_from_config(&dir.join("config.toml"), text)
|
||||
.ok()
|
||||
.flatten()
|
||||
});
|
||||
candidates.push((meta.created_at.clone(), auth, config_text, model));
|
||||
}
|
||||
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),
|
||||
}))
|
||||
.map(
|
||||
|(created_at, auth, config_text, model)| OfficialConfigCandidate {
|
||||
auth: Some(auth),
|
||||
config_text,
|
||||
model,
|
||||
source: format!("Codex-X 历史备份 {created_at}"),
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
fn live_chatgpt_candidate(codex_dir: &Path) -> Result<Option<OfficialConfigCandidate>> {
|
||||
fn live_auth_candidate(
|
||||
codex_dir: &Path,
|
||||
require_official_route: bool,
|
||||
is_trusted: impl FnOnce(&Value) -> bool,
|
||||
) -> Result<Option<OfficialConfigCandidate>> {
|
||||
let is_official = live_config_is_official(codex_dir)?;
|
||||
if require_official_route && !is_official {
|
||||
return Ok(None);
|
||||
}
|
||||
let Some(auth) = read_auth_value(&auth_path(codex_dir))? else {
|
||||
return Ok(None);
|
||||
};
|
||||
if !is_chatgpt_auth(&auth) {
|
||||
if !is_trusted(&auth) {
|
||||
return Ok(None);
|
||||
}
|
||||
let model = if live_config_is_official(codex_dir)? {
|
||||
official_model(codex_dir)?
|
||||
let config_text = if is_official {
|
||||
live_official_config_text(codex_dir)?
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let model = config_text
|
||||
.as_deref()
|
||||
.map(|text| model_from_config(&config_path(codex_dir), text))
|
||||
.transpose()?
|
||||
.flatten();
|
||||
Ok(Some(OfficialConfigCandidate {
|
||||
auth,
|
||||
auth: Some(auth),
|
||||
config_text,
|
||||
model,
|
||||
source: "当前 ChatGPT 官方登录".to_string(),
|
||||
source: "当前 OpenAI 官方认证".to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
fn live_chatgpt_candidate(codex_dir: &Path) -> Result<Option<OfficialConfigCandidate>> {
|
||||
live_auth_candidate(codex_dir, false, is_chatgpt_auth)
|
||||
}
|
||||
|
||||
fn live_official_auth_candidate(codex_dir: &Path) -> Result<Option<OfficialConfigCandidate>> {
|
||||
live_auth_candidate(codex_dir, true, |auth| {
|
||||
is_chatgpt_auth(auth) || has_openai_api_key(auth)
|
||||
})
|
||||
}
|
||||
|
||||
fn complete_candidate_config(
|
||||
codex_dir: &Path,
|
||||
mut candidate: OfficialConfigCandidate,
|
||||
backup: Option<&OfficialConfigCandidate>,
|
||||
) -> Result<OfficialConfigCandidate> {
|
||||
if candidate.config_text.is_none() {
|
||||
candidate.config_text = live_official_config_text(codex_dir)?
|
||||
.or_else(|| backup.and_then(|value| value.config_text.clone()));
|
||||
}
|
||||
if candidate.config_text.is_none() {
|
||||
candidate.config_text = Some(build_official_config_text(
|
||||
codex_dir,
|
||||
candidate.model.as_deref(),
|
||||
false,
|
||||
)?);
|
||||
}
|
||||
if let Some(config_text) = candidate.config_text.as_deref() {
|
||||
candidate.model =
|
||||
model_from_config(&config_path(codex_dir), config_text)?.or(candidate.model);
|
||||
}
|
||||
Ok(candidate)
|
||||
}
|
||||
|
||||
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 => {}
|
||||
SnapshotState::Ready(candidate) => {
|
||||
// A third-party route may deliberately keep the user's ChatGPT
|
||||
// login in auth.json and carry its own key in
|
||||
// experimental_bearer_token. Prefer that live OAuth value so a
|
||||
// token refresh during proxy use is not replaced by an older
|
||||
// official snapshot when switching back.
|
||||
if let Some(live) = live_chatgpt_candidate(codex_dir)? {
|
||||
return complete_candidate_config(codex_dir, live, Some(&candidate)).map(Some);
|
||||
}
|
||||
// Never let an API-key-only live file silently downgrade a trusted
|
||||
// OAuth snapshot. Older Codex-X builds could leave exactly that
|
||||
// polluted state while config.toml already pointed at OpenAI.
|
||||
if candidate.auth.as_ref().is_some_and(is_chatgpt_auth) {
|
||||
return complete_candidate_config(codex_dir, candidate, None).map(Some);
|
||||
}
|
||||
if let Some(live) = live_official_auth_candidate(codex_dir)? {
|
||||
return complete_candidate_config(codex_dir, live, Some(&candidate)).map(Some);
|
||||
}
|
||||
return complete_candidate_config(codex_dir, candidate, None).map(Some);
|
||||
}
|
||||
SnapshotState::Reset(reset) if !include_history_after_reset => {
|
||||
if let Some(live) = live_official_auth_candidate(codex_dir)? {
|
||||
return complete_candidate_config(codex_dir, live, Some(&reset)).map(Some);
|
||||
}
|
||||
if let Some(live) = live_chatgpt_candidate(codex_dir)? {
|
||||
return complete_candidate_config(codex_dir, live, Some(&reset)).map(Some);
|
||||
}
|
||||
return complete_candidate_config(codex_dir, reset, None).map(Some);
|
||||
}
|
||||
SnapshotState::Reset(reset) => {
|
||||
if let Some(live) = live_official_auth_candidate(codex_dir)? {
|
||||
return complete_candidate_config(codex_dir, live, Some(&reset)).map(Some);
|
||||
}
|
||||
if let Some(live) = live_chatgpt_candidate(codex_dir)? {
|
||||
return complete_candidate_config(codex_dir, live, Some(&reset)).map(Some);
|
||||
}
|
||||
if let Some(mut backup) = latest_official_backup(codex_dir)? {
|
||||
if reset.config_text.is_some() {
|
||||
backup.config_text = reset.config_text;
|
||||
backup.model = reset.model.or(backup.model);
|
||||
}
|
||||
return complete_candidate_config(codex_dir, backup, None).map(Some);
|
||||
}
|
||||
return complete_candidate_config(codex_dir, reset, None).map(Some);
|
||||
}
|
||||
SnapshotState::Missing => {}
|
||||
}
|
||||
|
||||
let backup = latest_official_backup(codex_dir)?;
|
||||
if let Some(candidate) = live_chatgpt_candidate(codex_dir)? {
|
||||
return Ok(Some(candidate));
|
||||
return complete_candidate_config(codex_dir, candidate, backup.as_ref()).map(Some);
|
||||
}
|
||||
latest_official_backup(codex_dir)
|
||||
backup
|
||||
.map(|candidate| complete_candidate_config(codex_dir, candidate, None))
|
||||
.transpose()
|
||||
}
|
||||
|
||||
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::Reset(_) => return Ok(live_official_auth_candidate(codex_dir)?.is_some()),
|
||||
SnapshotState::Missing => {}
|
||||
}
|
||||
if live_official_auth_candidate(codex_dir)?.is_some() {
|
||||
return Ok(true);
|
||||
}
|
||||
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())
|
||||
// Historical backup discovery is intentionally deferred to restore. A
|
||||
// status refresh must not traverse every backup on the startup path.
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
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 candidate = match official_config_candidate(&codex_dir, false)? {
|
||||
Some(candidate) => candidate,
|
||||
None => {
|
||||
let config_text = build_official_config_text(&codex_dir, None, false)?;
|
||||
let model = model_from_config(&config_path(&codex_dir), &config_text)?;
|
||||
OfficialConfigCandidate {
|
||||
auth: None,
|
||||
config_text: Some(config_text),
|
||||
model,
|
||||
source: "根据当前 config.toml 生成".to_string(),
|
||||
}
|
||||
}
|
||||
};
|
||||
let auth_json = serde_json::to_string_pretty(&candidate.auth)
|
||||
.map_err(|error| CodexxError::Config(format!("格式化官方配置快照失败: {error}")))?;
|
||||
Ok(Some(OfficialConfigDraft {
|
||||
official_config_draft(candidate).map(Some)
|
||||
}
|
||||
|
||||
fn official_config_draft(candidate: OfficialConfigCandidate) -> Result<OfficialConfigDraft> {
|
||||
let auth_json = candidate
|
||||
.auth
|
||||
.as_ref()
|
||||
.map(serde_json::to_string_pretty)
|
||||
.transpose()
|
||||
.map_err(|error| CodexxError::Config(format!("格式化官方配置快照失败: {error}")))?
|
||||
.unwrap_or_default();
|
||||
Ok(OfficialConfigDraft {
|
||||
auth_json,
|
||||
config_text: candidate.config_text.unwrap_or_default(),
|
||||
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)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn legacy_oauth_without_auth_mode_is_trusted() {
|
||||
assert!(is_chatgpt_auth(&json!({
|
||||
"OPENAI_API_KEY": null,
|
||||
"tokens": {"access_token": "legacy-access"},
|
||||
"last_refresh": "2026-08-11T00:00:00Z"
|
||||
})));
|
||||
assert!(!is_chatgpt_auth(&json!({
|
||||
"auth_mode": "apikey",
|
||||
"OPENAI_API_KEY": null,
|
||||
"tokens": {"access_token": "must-not-trust"}
|
||||
})));
|
||||
assert!(!is_chatgpt_auth(&json!({
|
||||
"tokens": {"access_token": "legacy-access"},
|
||||
"OPENAI_API_KEY": "sk-third-party"
|
||||
})));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v3_reset_snapshot_keeps_complete_config_without_restoring_auth() {
|
||||
let codex_dir = std::env::temp_dir().join(format!(
|
||||
"codex-x-official-reset-snapshot-{}",
|
||||
std::process::id()
|
||||
));
|
||||
fs::create_dir_all(&codex_dir).expect("create test Codex directory");
|
||||
let config = r#"# keep-reset-config
|
||||
model_provider = "openai"
|
||||
model = "official-model"
|
||||
approval_policy = "on-request"
|
||||
|
||||
[features]
|
||||
js_repl = false
|
||||
"#;
|
||||
let snapshot = OfficialConfigSnapshot {
|
||||
version: SNAPSHOT_VERSION,
|
||||
codex_dir: canonical_identity(&codex_dir),
|
||||
captured_at: "2026-08-11T00:00:00+08:00".to_string(),
|
||||
model: Some("official-model".to_string()),
|
||||
config: Some(config.to_string()),
|
||||
auth: None,
|
||||
};
|
||||
|
||||
let candidate = match snapshot_state(&codex_dir, Path::new("snapshot.json"), snapshot)
|
||||
.expect("classify reset snapshot")
|
||||
{
|
||||
SnapshotState::Reset(candidate) => candidate,
|
||||
SnapshotState::Missing | SnapshotState::Ready(_) => {
|
||||
panic!("v3 auth-less snapshot must remain an explicit reset")
|
||||
}
|
||||
};
|
||||
let candidate = complete_candidate_config(&codex_dir, candidate, None)
|
||||
.expect("complete reset candidate");
|
||||
assert!(candidate.auth.is_none());
|
||||
assert_eq!(candidate.model.as_deref(), Some("official-model"));
|
||||
assert_eq!(candidate.config_text.as_deref(), Some(config));
|
||||
|
||||
let draft = official_config_draft(candidate).expect("build reset draft");
|
||||
assert!(draft.auth_json.is_empty());
|
||||
assert_eq!(draft.model.as_deref(), Some("official-model"));
|
||||
assert_eq!(draft.config_text, config);
|
||||
|
||||
fs::remove_dir_all(codex_dir).expect("remove test Codex directory");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ use crate::{now_rfc3339, sanitize_id};
|
||||
use rusqlite::{params, Connection, TransactionBehavior};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use toml_edit::{value, DocumentMut, Item, Table};
|
||||
use toml_edit::{value, DocumentMut};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -35,6 +35,7 @@ pub(crate) struct ProviderStoreRollback {
|
||||
|
||||
const MANUAL_PROVIDER_SOURCE: &str = "manual";
|
||||
pub(crate) const CCSWITCH_PROVIDER_SOURCE: &str = "cc-switch";
|
||||
const CCSWITCH_LOCAL_PROVIDER_SOURCE: &str = "cc-switch-local";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub(crate) enum ProviderIdentity {
|
||||
@@ -113,6 +114,11 @@ fn normalized_provider_name(input: &str) -> String {
|
||||
.to_lowercase()
|
||||
}
|
||||
|
||||
pub(crate) fn is_placeholder_provider(provider_name: &str, base_url: &str) -> bool {
|
||||
normalized_provider_name(provider_name) == "your-provider"
|
||||
&& canonical_provider_base_url(base_url) == "https://example.com/v1"
|
||||
}
|
||||
|
||||
fn effective_provider_api_key(provider: &SavedProvider) -> Option<String> {
|
||||
provider
|
||||
.api_key
|
||||
@@ -160,27 +166,40 @@ pub(crate) fn provider_template_from_document(
|
||||
provider_id: &str,
|
||||
model: &str,
|
||||
) -> Result<String> {
|
||||
let mut provider_table = doc
|
||||
let provider_exists = doc
|
||||
.get("model_providers")
|
||||
.and_then(|item| item.as_table())
|
||||
.and_then(|providers| providers.get(provider_id))
|
||||
.and_then(|item| item.as_table())
|
||||
.cloned()
|
||||
.ok_or_else(|| {
|
||||
CodexxError::Config(format!("供应商 TOML 缺少 [model_providers.{provider_id}]"))
|
||||
})?;
|
||||
provider_table.remove("experimental_bearer_token");
|
||||
.is_some();
|
||||
if !provider_exists {
|
||||
return Err(CodexxError::Config(format!(
|
||||
"供应商 TOML 缺少 [model_providers.{provider_id}]"
|
||||
)));
|
||||
}
|
||||
|
||||
let mut template = DocumentMut::new();
|
||||
let mut template = doc.clone();
|
||||
template["model_provider"] = value(provider_id);
|
||||
template["model"] = value(model);
|
||||
let mut providers = Table::new();
|
||||
providers.set_implicit(true);
|
||||
providers.insert(provider_id, Item::Table(provider_table));
|
||||
template["model_providers"] = Item::Table(providers);
|
||||
strip_provider_bearer_tokens(&mut template);
|
||||
Ok(template.to_string().trim_end().to_string())
|
||||
}
|
||||
|
||||
pub(crate) fn strip_provider_bearer_tokens(doc: &mut DocumentMut) {
|
||||
doc.as_table_mut().remove("experimental_bearer_token");
|
||||
let Some(providers) = doc
|
||||
.get_mut("model_providers")
|
||||
.and_then(|item| item.as_table_mut())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
for (_, item) in providers.iter_mut() {
|
||||
if let Some(table) = item.as_table_mut() {
|
||||
table.remove("experimental_bearer_token");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn same_provider_endpoint(left: &SavedProvider, right: &SavedProvider) -> bool {
|
||||
let left = canonical_provider_base_url(&left.base_url);
|
||||
!left.is_empty() && left == canonical_provider_base_url(&right.base_url)
|
||||
@@ -412,54 +431,44 @@ fn unique_provider_id_on_connection(conn: &Connection, preferred: &str) -> Resul
|
||||
}
|
||||
}
|
||||
|
||||
fn preserve_existing_provider_config(
|
||||
fn merge_authoritative_import(
|
||||
mut incoming: SavedProvider,
|
||||
existing: &SavedProvider,
|
||||
) -> SavedProvider {
|
||||
incoming.id = existing.id.clone();
|
||||
if !existing.provider_name.trim().is_empty() {
|
||||
if incoming.provider_name.trim().is_empty() {
|
||||
incoming.provider_name = existing.provider_name.clone();
|
||||
}
|
||||
if !existing.base_url.trim().is_empty() {
|
||||
if incoming.base_url.trim().is_empty() {
|
||||
incoming.base_url = existing.base_url.clone();
|
||||
}
|
||||
if !existing.model.trim().is_empty() {
|
||||
if incoming.model.trim().is_empty() {
|
||||
incoming.model = existing.model.clone();
|
||||
}
|
||||
if existing
|
||||
if incoming
|
||||
.api_key
|
||||
.as_deref()
|
||||
.is_some_and(|value| !value.trim().is_empty())
|
||||
.is_none_or(|value| value.trim().is_empty())
|
||||
{
|
||||
incoming.api_key = existing.api_key.clone();
|
||||
}
|
||||
if existing
|
||||
if incoming
|
||||
.toml_config
|
||||
.as_deref()
|
||||
.is_some_and(|value| !value.trim().is_empty())
|
||||
.is_none_or(|value| value.trim().is_empty())
|
||||
{
|
||||
incoming.toml_config = existing.toml_config.clone();
|
||||
}
|
||||
if !existing.wire_api.trim().is_empty() {
|
||||
if incoming.wire_api.trim().is_empty() {
|
||||
incoming.wire_api = existing.wire_api.clone();
|
||||
}
|
||||
incoming.requires_openai_auth = existing.requires_openai_auth;
|
||||
incoming
|
||||
}
|
||||
|
||||
fn update_stable_import(mut incoming: SavedProvider, existing: &SavedProvider) -> SavedProvider {
|
||||
incoming.id = existing.id.clone();
|
||||
if incoming.api_key.is_none() {
|
||||
incoming.api_key = existing.api_key.clone();
|
||||
}
|
||||
if incoming.toml_config.is_none() {
|
||||
incoming.toml_config = existing.toml_config.clone();
|
||||
}
|
||||
incoming
|
||||
}
|
||||
|
||||
fn source_matches(row: &StoredProvider, origin: (&str, &str)) -> bool {
|
||||
row.source == origin.0 && row.source_id.as_deref() == Some(origin.1)
|
||||
let source_matches = row.source == origin.0
|
||||
|| (origin.0 == CCSWITCH_PROVIDER_SOURCE && row.source == CCSWITCH_LOCAL_PROVIDER_SOURCE);
|
||||
source_matches && row.source_id.as_deref() == Some(origin.1)
|
||||
}
|
||||
|
||||
fn source_can_merge(row: &StoredProvider, origin: Option<(&str, &str)>) -> bool {
|
||||
@@ -542,16 +551,17 @@ fn upsert_provider_in_savepoint(
|
||||
.or(identity_match)
|
||||
.or(compatible_match),
|
||||
};
|
||||
let preserves_local_id = mode == ProviderUpsertMode::Imported
|
||||
&& target.is_some_and(|candidate| {
|
||||
candidate.source == MANUAL_PROVIDER_SOURCE
|
||||
|| candidate.source == CCSWITCH_LOCAL_PROVIDER_SOURCE
|
||||
});
|
||||
let kind = if let Some(target) = target {
|
||||
let existing = &target.provider;
|
||||
let same_id = existing.id == requested_id;
|
||||
provider.id = existing.id.clone();
|
||||
if import_rehome_target.is_some() {
|
||||
provider = preserve_existing_provider_config(provider, existing);
|
||||
} else if source_match.is_some_and(|candidate| candidate.provider.id == existing.id) {
|
||||
provider = update_stable_import(provider, existing);
|
||||
} else if mode != ProviderUpsertMode::Manual {
|
||||
provider = preserve_existing_provider_config(provider, existing);
|
||||
if mode == ProviderUpsertMode::Imported {
|
||||
provider = merge_authoritative_import(provider, existing);
|
||||
}
|
||||
if import_rehome_target.is_some() {
|
||||
ProviderUpsertKind::Merged
|
||||
@@ -573,7 +583,13 @@ fn upsert_provider_in_savepoint(
|
||||
.map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
}
|
||||
}
|
||||
write_provider_with_origin(conn, &provider, origin)?;
|
||||
let write_origin = match origin {
|
||||
Some((CCSWITCH_PROVIDER_SOURCE, source_id)) if preserves_local_id => {
|
||||
Some((CCSWITCH_LOCAL_PROVIDER_SOURCE, source_id))
|
||||
}
|
||||
_ => origin,
|
||||
};
|
||||
write_provider_with_origin(conn, &provider, write_origin)?;
|
||||
for duplicate_id in conflicting_provider_ids(&stored, &provider, origin) {
|
||||
conn.execute("DELETE FROM providers WHERE id = ?1", [&duplicate_id])
|
||||
.map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
@@ -669,12 +685,20 @@ pub(crate) fn consolidate_legacy_provider_duplicates_on_connection(
|
||||
.collect::<Vec<_>>();
|
||||
let mut merged = 0usize;
|
||||
for mut group in duplicate_groups {
|
||||
let preserves_local_profile = group.iter().any(|row| {
|
||||
row.source == MANUAL_PROVIDER_SOURCE || row.source == CCSWITCH_LOCAL_PROVIDER_SOURCE
|
||||
});
|
||||
let mut origins = group
|
||||
.iter()
|
||||
.filter_map(|row| {
|
||||
row.source_id
|
||||
.as_ref()
|
||||
.map(|source_id| (row.source.clone(), source_id.clone()))
|
||||
row.source_id.as_ref().map(|source_id| {
|
||||
let source = if row.source == CCSWITCH_LOCAL_PROVIDER_SOURCE {
|
||||
CCSWITCH_PROVIDER_SOURCE.to_string()
|
||||
} else {
|
||||
row.source.clone()
|
||||
};
|
||||
(source, source_id.clone())
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
origins.sort();
|
||||
@@ -683,8 +707,16 @@ pub(crate) fn consolidate_legacy_provider_duplicates_on_connection(
|
||||
continue;
|
||||
}
|
||||
group.sort_by(|left, right| {
|
||||
is_historical_custom_provider_id(&left.provider.id)
|
||||
.cmp(&is_historical_custom_provider_id(&right.provider.id))
|
||||
let left_is_local = left.source == MANUAL_PROVIDER_SOURCE
|
||||
|| left.source == CCSWITCH_LOCAL_PROVIDER_SOURCE;
|
||||
let right_is_local = right.source == MANUAL_PROVIDER_SOURCE
|
||||
|| right.source == CCSWITCH_LOCAL_PROVIDER_SOURCE;
|
||||
right_is_local
|
||||
.cmp(&left_is_local)
|
||||
.then_with(|| {
|
||||
is_historical_custom_provider_id(&left.provider.id)
|
||||
.cmp(&is_historical_custom_provider_id(&right.provider.id))
|
||||
})
|
||||
.then_with(|| right.updated_at.cmp(&left.updated_at))
|
||||
.then_with(|| right.created_at.cmp(&left.created_at))
|
||||
.then_with(|| left.provider.id.cmp(&right.provider.id))
|
||||
@@ -732,9 +764,14 @@ pub(crate) fn consolidate_legacy_provider_duplicates_on_connection(
|
||||
.map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
merged += 1;
|
||||
}
|
||||
let origin = origins
|
||||
.first()
|
||||
.map(|(source, source_id)| (source.as_str(), source_id.as_str()));
|
||||
let origin = origins.first().map(|(source, source_id)| {
|
||||
let source = if preserves_local_profile && source == CCSWITCH_PROVIDER_SOURCE {
|
||||
CCSWITCH_LOCAL_PROVIDER_SOURCE
|
||||
} else {
|
||||
source.as_str()
|
||||
};
|
||||
(source, source_id.as_str())
|
||||
});
|
||||
write_provider_with_origin(conn, &survivor.provider, origin)?;
|
||||
}
|
||||
|
||||
@@ -863,6 +900,11 @@ pub(crate) fn normalize_saved_provider(provider: SavedProvider) -> Result<SavedP
|
||||
if normalized.model.is_empty() {
|
||||
return Err(CodexxError::Config("model 不能为空".to_string()));
|
||||
}
|
||||
if is_placeholder_provider(&normalized.provider_name, &normalized.base_url) {
|
||||
return Err(CodexxError::Config(
|
||||
"供应商名称和 base_url 不能使用示例占位值,请填写实际配置".to_string(),
|
||||
));
|
||||
}
|
||||
normalize_provider_toml(&mut normalized)?;
|
||||
Ok(normalized)
|
||||
}
|
||||
@@ -1156,7 +1198,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stable_import_collision_keeps_manual_record_and_rehomes_source_identity() {
|
||||
fn stable_import_collision_keeps_manual_id_and_applies_source_values() {
|
||||
let conn = test_connection();
|
||||
let mut imported = provider("cc-old", "Imported old", Some("sk-old"));
|
||||
imported.base_url = "https://old.example.com/v1".to_string();
|
||||
@@ -1178,18 +1220,154 @@ mod tests {
|
||||
|
||||
assert_eq!(result.kind, ProviderUpsertKind::Merged);
|
||||
assert_eq!(result.provider.id, "manual-local");
|
||||
assert_eq!(result.provider.provider_name, "Locally edited");
|
||||
assert_eq!(result.provider.model, "local-model");
|
||||
assert_eq!(result.provider.provider_name, "Imported changed");
|
||||
assert_eq!(result.provider.model, "remote-model");
|
||||
assert_eq!(
|
||||
result.provider.toml_config.as_deref(),
|
||||
Some("model = \"local-model\"")
|
||||
);
|
||||
let rows = stored_providers_on_connection(&conn).expect("read merged providers");
|
||||
assert_eq!(rows.len(), 1);
|
||||
assert_eq!(rows[0].source, CCSWITCH_PROVIDER_SOURCE);
|
||||
assert_eq!(rows[0].source, CCSWITCH_LOCAL_PROVIDER_SOURCE);
|
||||
assert_eq!(rows[0].source_id.as_deref(), Some("cc-row"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ccswitch_import_replaces_stale_local_values_with_complete_source() {
|
||||
let conn = test_connection();
|
||||
let mut stale = provider("legacy-local", "Local name", Some("sk-local"));
|
||||
stale.base_url = "https://old.example.com/v1".to_string();
|
||||
stale.model = "local-model".to_string();
|
||||
stale.toml_config = Some(
|
||||
r#"model_provider = "custom"
|
||||
model = "local-model"
|
||||
|
||||
[model_providers.custom]
|
||||
name = "Local name"
|
||||
base_url = "https://old.example.com/v1"
|
||||
wire_api = "chat"
|
||||
requires_openai_auth = true
|
||||
request_max_retries = 3
|
||||
"#
|
||||
.to_string(),
|
||||
);
|
||||
stale.wire_api = "chat".to_string();
|
||||
write_provider_with_origin(
|
||||
&conn,
|
||||
&stale,
|
||||
Some((CCSWITCH_LOCAL_PROVIDER_SOURCE, "cc-row")),
|
||||
)
|
||||
.expect("seed stale local import");
|
||||
|
||||
let mut imported = provider("cc-row", "CC name", Some("sk-imported"));
|
||||
imported.base_url = "https://new.example.com/v1".to_string();
|
||||
imported.model = "remote-model".to_string();
|
||||
imported.requires_openai_auth = false;
|
||||
imported.toml_config = Some(
|
||||
r#"# complete cc-switch template
|
||||
model_provider = "custom"
|
||||
model = "remote-model"
|
||||
service_tier = "priority"
|
||||
|
||||
[model_providers.custom]
|
||||
name = "CC name"
|
||||
base_url = "https://new.example.com/v1"
|
||||
wire_api = "responses"
|
||||
requires_openai_auth = false
|
||||
request_max_retries = 7
|
||||
|
||||
[projects."/work/project"]
|
||||
trust_level = "trusted"
|
||||
|
||||
[plugins."browser@openai-bundled"]
|
||||
enabled = true
|
||||
"#
|
||||
.to_string(),
|
||||
);
|
||||
let result = upsert_ccswitch_provider_on_connection(&conn, imported, "cc-row")
|
||||
.expect("replace stale local values from the authoritative cc-switch row");
|
||||
|
||||
assert_eq!(result.kind, ProviderUpsertKind::Updated);
|
||||
assert_eq!(result.provider.id, "legacy-local");
|
||||
assert_eq!(result.provider.provider_name, "CC name");
|
||||
assert_eq!(result.provider.base_url, "https://new.example.com/v1");
|
||||
assert_eq!(result.provider.model, "remote-model");
|
||||
assert_eq!(result.provider.api_key.as_deref(), Some("sk-imported"));
|
||||
assert_eq!(result.provider.wire_api, "responses");
|
||||
assert!(!result.provider.requires_openai_auth);
|
||||
let template = result
|
||||
.provider
|
||||
.toml_config
|
||||
.expect("complete cc-switch template");
|
||||
let doc = template
|
||||
.parse::<DocumentMut>()
|
||||
.expect("parse imported provider template");
|
||||
assert!(template.contains("# complete cc-switch template"));
|
||||
assert_eq!(doc["service_tier"].as_str(), Some("priority"));
|
||||
assert_eq!(doc["model"].as_str(), Some("remote-model"));
|
||||
assert_eq!(
|
||||
doc["model_providers"]["custom"]["name"].as_str(),
|
||||
Some("CC name")
|
||||
);
|
||||
assert_eq!(
|
||||
doc["model_providers"]["custom"]["request_max_retries"].as_integer(),
|
||||
Some(7)
|
||||
);
|
||||
assert_eq!(
|
||||
doc["projects"]["/work/project"]["trust_level"].as_str(),
|
||||
Some("trusted")
|
||||
);
|
||||
assert_eq!(
|
||||
doc["plugins"]["browser@openai-bundled"]["enabled"].as_bool(),
|
||||
Some(true)
|
||||
);
|
||||
let rows = stored_providers_on_connection(&conn).expect("read imported row");
|
||||
assert_eq!(rows.len(), 1);
|
||||
assert_eq!(rows[0].source, CCSWITCH_LOCAL_PROVIDER_SOURCE);
|
||||
assert_eq!(rows[0].source_id.as_deref(), Some("cc-row"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ccswitch_import_falls_back_only_when_source_fields_are_missing() {
|
||||
let conn = test_connection();
|
||||
let mut existing = provider("local-id", "Existing name", Some("sk-existing"));
|
||||
existing.base_url = "https://existing.example.com/v1".to_string();
|
||||
existing.model = "existing-model".to_string();
|
||||
existing.toml_config = Some("model = \"existing-model\"".to_string());
|
||||
existing.wire_api = "responses".to_string();
|
||||
write_provider_with_origin(
|
||||
&conn,
|
||||
&existing,
|
||||
Some((CCSWITCH_LOCAL_PROVIDER_SOURCE, "cc-row")),
|
||||
)
|
||||
.expect("seed existing source row");
|
||||
|
||||
let missing = SavedProvider {
|
||||
id: "remote-id".to_string(),
|
||||
provider_name: " ".to_string(),
|
||||
base_url: String::new(),
|
||||
model: "\t".to_string(),
|
||||
api_key: Some(" ".to_string()),
|
||||
toml_config: Some("\n".to_string()),
|
||||
wire_api: String::new(),
|
||||
requires_openai_auth: false,
|
||||
};
|
||||
let result = upsert_ccswitch_provider_on_connection(&conn, missing, "cc-row")
|
||||
.expect("fill missing import fields from the existing source row");
|
||||
|
||||
assert_eq!(result.provider.id, "local-id");
|
||||
assert_eq!(result.provider.provider_name, "Existing name");
|
||||
assert_eq!(result.provider.base_url, "https://existing.example.com/v1");
|
||||
assert_eq!(result.provider.model, "existing-model");
|
||||
assert_eq!(result.provider.api_key.as_deref(), Some("sk-existing"));
|
||||
assert_eq!(
|
||||
result.provider.toml_config.as_deref(),
|
||||
Some("model = \"existing-model\"")
|
||||
);
|
||||
assert_eq!(result.provider.wire_api, "responses");
|
||||
assert!(!result.provider.requires_openai_auth);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn historical_custom_ghost_cleanup_requires_one_stable_endpoint_match() {
|
||||
let conn = test_connection();
|
||||
@@ -1235,20 +1413,20 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_consolidation_transfers_one_external_source_to_the_latest_record() {
|
||||
fn legacy_consolidation_keeps_local_id_but_next_import_is_authoritative() {
|
||||
let conn = test_connection();
|
||||
let mut imported = provider("imported-old", "Imported old", Some("sk-same"));
|
||||
imported.model = "old-model".to_string();
|
||||
write_provider_with_origin(&conn, &imported, Some((CCSWITCH_PROVIDER_SOURCE, "cc-row")))
|
||||
.unwrap();
|
||||
let mut manual = provider("manual-latest", "Edited locally", Some("sk-same"));
|
||||
let mut manual = provider("manual-local", "Edited locally", Some("sk-same"));
|
||||
manual.model = "latest-model".to_string();
|
||||
manual.toml_config = Some("model = \"latest-model\"".to_string());
|
||||
write_provider_on_connection(&conn, &manual).unwrap();
|
||||
conn.execute(
|
||||
"UPDATE providers SET updated_at = CASE id
|
||||
WHEN 'imported-old' THEN '2026-01-01T00:00:00Z'
|
||||
ELSE '2026-02-01T00:00:00Z' END",
|
||||
WHEN 'imported-old' THEN '2026-02-01T00:00:00Z'
|
||||
ELSE '2026-01-01T00:00:00Z' END",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
@@ -1259,14 +1437,32 @@ mod tests {
|
||||
);
|
||||
let rows = stored_providers_on_connection(&conn).unwrap();
|
||||
assert_eq!(rows.len(), 1);
|
||||
assert_eq!(rows[0].provider.id, "manual-latest");
|
||||
assert_eq!(rows[0].provider.id, "manual-local");
|
||||
assert_eq!(rows[0].provider.provider_name, "Edited locally");
|
||||
assert_eq!(rows[0].provider.model, "latest-model");
|
||||
assert_eq!(
|
||||
rows[0].provider.toml_config.as_deref(),
|
||||
Some("model = \"latest-model\"")
|
||||
);
|
||||
assert_eq!(rows[0].source, CCSWITCH_PROVIDER_SOURCE);
|
||||
assert_eq!(rows[0].source, CCSWITCH_LOCAL_PROVIDER_SOURCE);
|
||||
assert_eq!(rows[0].source_id.as_deref(), Some("cc-row"));
|
||||
|
||||
let mut repeated = provider("imported-old", "Remote replacement", Some("sk-same"));
|
||||
repeated.model = "remote-model".to_string();
|
||||
repeated.toml_config = Some("model = \"remote-model\"".to_string());
|
||||
upsert_ccswitch_provider_on_connection(&conn, repeated, "cc-row")
|
||||
.expect("repeat import after legacy consolidation");
|
||||
|
||||
let rows = stored_providers_on_connection(&conn).unwrap();
|
||||
assert_eq!(rows.len(), 1);
|
||||
assert_eq!(rows[0].provider.id, "manual-local");
|
||||
assert_eq!(rows[0].provider.provider_name, "Remote replacement");
|
||||
assert_eq!(rows[0].provider.model, "remote-model");
|
||||
assert_eq!(
|
||||
rows[0].provider.toml_config.as_deref(),
|
||||
Some("model = \"remote-model\"")
|
||||
);
|
||||
assert_eq!(rows[0].source, CCSWITCH_LOCAL_PROVIDER_SOURCE);
|
||||
assert_eq!(rows[0].source_id.as_deref(), Some("cc-row"));
|
||||
}
|
||||
|
||||
@@ -1320,6 +1516,7 @@ mod tests {
|
||||
r#"model_provider = "proxy"
|
||||
model = "stale-model"
|
||||
approval_policy = "never"
|
||||
experimental_bearer_token = "sk-top-level"
|
||||
|
||||
[model_providers.proxy]
|
||||
name = "Stale Name"
|
||||
@@ -1333,11 +1530,12 @@ request_max_retries = 7
|
||||
X-Route = "keep-me"
|
||||
|
||||
[model_providers.unrelated]
|
||||
name = "Do not store"
|
||||
name = "Keep this provider"
|
||||
base_url = "https://unrelated.example.com/v1"
|
||||
experimental_bearer_token = "sk-unrelated"
|
||||
|
||||
[mcp_servers.docs]
|
||||
command = "do-not-copy"
|
||||
command = "keep-this-command"
|
||||
"#
|
||||
.to_string(),
|
||||
);
|
||||
@@ -1366,10 +1564,33 @@ command = "do-not-copy"
|
||||
doc["model_providers"]["proxy"]["http_headers"]["X-Route"].as_str(),
|
||||
Some("keep-me")
|
||||
);
|
||||
assert!(doc.get("approval_policy").is_none());
|
||||
assert!(doc["model_providers"].get("unrelated").is_none());
|
||||
assert!(doc.get("mcp_servers").is_none());
|
||||
assert_eq!(doc["model_provider"].as_str(), Some("proxy"));
|
||||
assert_eq!(doc["approval_policy"].as_str(), Some("never"));
|
||||
assert_eq!(
|
||||
doc["model_providers"]["unrelated"]["name"].as_str(),
|
||||
Some("Keep this provider")
|
||||
);
|
||||
assert_eq!(
|
||||
doc["mcp_servers"]["docs"]["command"].as_str(),
|
||||
Some("keep-this-command")
|
||||
);
|
||||
assert!(doc.get("experimental_bearer_token").is_none());
|
||||
assert!(doc["model_providers"]
|
||||
.as_table()
|
||||
.expect("model providers table")
|
||||
.iter()
|
||||
.all(|(_, item)| item
|
||||
.as_table()
|
||||
.is_none_or(|table| table.get("experimental_bearer_token").is_none())));
|
||||
assert!(!text.contains("experimental_bearer_token"));
|
||||
assert_eq!(normalized.api_key.as_deref(), Some("sk-explicit"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn placeholder_provider_values_are_rejected() {
|
||||
let mut item = provider("placeholder", "your-provider", None);
|
||||
item.model = "gpt-5.5".to_string();
|
||||
let error = normalize_saved_provider(item).expect_err("placeholder must be rejected");
|
||||
assert!(error.to_string().contains("示例占位值"));
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,6 @@
|
||||
mod app_server;
|
||||
mod backup;
|
||||
mod catalog;
|
||||
mod delete;
|
||||
mod global_state;
|
||||
mod storage;
|
||||
|
||||
@@ -1253,7 +1253,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn root_state_10_uses_shared_bucket_even_when_openai_is_requested() {
|
||||
fn root_state_10_honors_an_explicit_provider_target() {
|
||||
let codex_dir = temp_codex_dir("root-state-10");
|
||||
let database = codex_dir.join("state_10.sqlite");
|
||||
let id = "019f6000-0000-7000-8000-000000000301";
|
||||
@@ -1282,17 +1282,17 @@ mod tests {
|
||||
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);
|
||||
.expect("read explicit provider status");
|
||||
assert_eq!(status.target_provider, "openai");
|
||||
assert!(!status.needs_sync);
|
||||
|
||||
let result = crate::sessions::sync::sync_sessions_provider_inner(
|
||||
Some(codex_dir.display().to_string()),
|
||||
Some("openai".to_string()),
|
||||
)
|
||||
.expect("sync root state_10");
|
||||
assert_eq!(result.status.target_provider, "custom");
|
||||
assert_eq!(result.updated_threads, 1);
|
||||
assert_eq!(result.status.target_provider, "openai");
|
||||
assert_eq!(result.updated_threads, 0);
|
||||
let provider: String = Connection::open(&database)
|
||||
.expect("reopen state_10")
|
||||
.query_row(
|
||||
@@ -1301,7 +1301,7 @@ mod tests {
|
||||
|row| row.get(0),
|
||||
)
|
||||
.expect("read updated provider");
|
||||
assert_eq!(provider, "custom");
|
||||
assert_eq!(provider, "openai");
|
||||
|
||||
let _ = fs::remove_dir_all(codex_dir);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
use super::backup::{create_provider_sync_backup, prune_provider_sync_backups};
|
||||
use super::catalog::{scan_catalog_sync, CatalogSyncScan};
|
||||
use super::storage::{
|
||||
current_model_provider, discover_sqlite_databases, list_session_previews_with_paths,
|
||||
scan_rollouts_for_thread_ids, scan_sqlite_with_paths, SqliteDiscovery,
|
||||
current_model_provider, discover_sqlite_databases, ensure_sqlite_discovery_writable,
|
||||
list_session_previews_with_paths, scan_rollouts, scan_rollouts_for_thread_ids,
|
||||
scan_sqlite_with_paths, SqliteDiscovery,
|
||||
};
|
||||
use super::transaction::{
|
||||
execute_provider_sync_mutation, mutation_error, prepare_sqlite_updates, rollback_mutation,
|
||||
@@ -16,18 +18,6 @@ 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
|
||||
@@ -53,90 +43,205 @@ fn scan_provider_buckets(
|
||||
Ok(rollouts)
|
||||
}
|
||||
|
||||
fn scan_legacy_index_warnings(
|
||||
codex_dir: &Path,
|
||||
target_provider: &str,
|
||||
discovery: &SqliteDiscovery,
|
||||
) -> Vec<String> {
|
||||
if discovery.active_paths.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let legacy_paths = discovery
|
||||
.thread_paths
|
||||
.iter()
|
||||
.filter(|path| !discovery.active_paths.contains(path))
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
if legacy_paths.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let failures = scan_sqlite_with_paths(&legacy_paths, &RolloutScan::default(), target_provider)
|
||||
.and_then(|sqlite| scan_provider_buckets(codex_dir, target_provider, &sqlite))
|
||||
.map(|scan| scan.scan_failures)
|
||||
.unwrap_or_else(|error| vec![error.to_string()]);
|
||||
failures
|
||||
.into_iter()
|
||||
.map(|failure| format!("已忽略旧会话索引异常:{failure}"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
struct ProviderSyncScan {
|
||||
active_sqlite: SqliteScan,
|
||||
sqlite: SqliteScan,
|
||||
rollouts: RolloutScan,
|
||||
catalog: CatalogSyncScan,
|
||||
scan_failures: Vec<String>,
|
||||
warnings: Vec<String>,
|
||||
}
|
||||
|
||||
fn scan_provider_sync_data(
|
||||
codex_dir: &Path,
|
||||
target_provider: &str,
|
||||
discovery: &SqliteDiscovery,
|
||||
) -> Result<ProviderSyncScan> {
|
||||
let active_sqlite = scan_sqlite_with_paths(
|
||||
&discovery.active_paths,
|
||||
&RolloutScan::default(),
|
||||
target_provider,
|
||||
)?;
|
||||
let sqlite = scan_sqlite_with_paths(
|
||||
&discovery.thread_paths,
|
||||
&RolloutScan::default(),
|
||||
target_provider,
|
||||
)?;
|
||||
let indexed_rollouts = if active_sqlite.sqlite_dbs > 0 {
|
||||
scan_provider_buckets(codex_dir, target_provider, &active_sqlite)?
|
||||
} else {
|
||||
scan_provider_buckets(codex_dir, target_provider, &sqlite)?
|
||||
};
|
||||
let legacy_index_warnings = scan_legacy_index_warnings(codex_dir, target_provider, discovery);
|
||||
let mut rollouts = scan_rollouts(codex_dir, target_provider)?;
|
||||
// Provider synchronization changes provider routing only; cwd remains independently managed.
|
||||
rollouts.cwd_by_thread_id.clear();
|
||||
let catalog = scan_catalog_sync(
|
||||
&discovery.thread_paths,
|
||||
&discovery.related_paths,
|
||||
target_provider,
|
||||
)?;
|
||||
|
||||
let mut scan_failures = discovery.active_scan_failures.clone();
|
||||
scan_failures.extend(active_sqlite.scan_failures.iter().cloned());
|
||||
scan_failures.extend(sqlite.scan_failures.iter().cloned());
|
||||
scan_failures.extend(indexed_rollouts.scan_failures.iter().cloned());
|
||||
for path in &discovery.unreadable_paths {
|
||||
scan_failures.push(format!("无法读取会话数据库: {}", path.display()));
|
||||
}
|
||||
|
||||
let indexed_failures = indexed_rollouts
|
||||
.scan_failures
|
||||
.iter()
|
||||
.cloned()
|
||||
.collect::<HashSet<_>>();
|
||||
let mut warnings = rollouts.warnings.clone();
|
||||
warnings.extend(active_sqlite.warnings.iter().cloned());
|
||||
warnings.extend(sqlite.warnings.iter().cloned());
|
||||
warnings.extend(legacy_index_warnings);
|
||||
warnings.extend(
|
||||
rollouts
|
||||
.scan_failures
|
||||
.iter()
|
||||
.filter(|failure| !indexed_failures.contains(*failure))
|
||||
.map(|failure| format!("已跳过未进入会话索引的异常文件:{failure}")),
|
||||
);
|
||||
let mut seen = HashSet::new();
|
||||
scan_failures.retain(|failure| seen.insert(failure.clone()));
|
||||
seen.clear();
|
||||
warnings.retain(|warning| seen.insert(warning.clone()));
|
||||
|
||||
Ok(ProviderSyncScan {
|
||||
active_sqlite,
|
||||
sqlite,
|
||||
rollouts,
|
||||
catalog,
|
||||
scan_failures,
|
||||
warnings,
|
||||
})
|
||||
}
|
||||
|
||||
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 = SHARED_SESSION_PROVIDER.to_string();
|
||||
let target = current_model_provider(&codex_dir, target_provider)?;
|
||||
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 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 (sessions, session_warnings) = match list_session_previews_with_paths(
|
||||
&discovery.active_paths,
|
||||
&rollouts,
|
||||
let scan = scan_provider_sync_data(codex_dir, &target, discovery)?;
|
||||
let display_sqlite = if scan.active_sqlite.sqlite_dbs > 0 {
|
||||
&scan.active_sqlite
|
||||
} else {
|
||||
&scan.sqlite
|
||||
};
|
||||
let session_limit = display_sqlite.sqlite_threads.clamp(50, 1000);
|
||||
let preview_paths = if discovery.active_paths.is_empty() {
|
||||
discovery.active_first_session_paths()
|
||||
} else {
|
||||
discovery.active_paths.clone()
|
||||
};
|
||||
let (mut sessions, session_failures) = match list_session_previews_with_paths(
|
||||
&preview_paths,
|
||||
&scan.rollouts,
|
||||
&target,
|
||||
session_limit,
|
||||
) {
|
||||
Ok(result) => result,
|
||||
Err(error) => {
|
||||
scan_failures.push(format!("无法读取当前活动会话列表: {error}"));
|
||||
(Vec::new(), Vec::new())
|
||||
let mut failures = scan.scan_failures.clone();
|
||||
failures.push(format!("无法读取当前活动会话列表: {error}"));
|
||||
return Ok(SessionSyncStatus {
|
||||
codex_dir: codex_dir.display().to_string(),
|
||||
target_provider: target,
|
||||
rollout_files: scan.rollouts.rollout_files,
|
||||
session_meta_count: scan.rollouts.session_meta_count,
|
||||
mismatched_rollouts: scan.rollouts.mismatched_rollouts,
|
||||
mismatched_session_meta: scan.rollouts.mismatched_session_meta,
|
||||
sqlite_dbs: scan.sqlite.sqlite_dbs,
|
||||
sqlite_threads: display_sqlite.sqlite_threads,
|
||||
top_level_threads: display_sqlite.top_level_threads,
|
||||
subagent_threads: display_sqlite.subagent_threads,
|
||||
mismatched_threads: scan.sqlite.mismatched_threads,
|
||||
mismatched_sessions: 0,
|
||||
needs_sync: false,
|
||||
scan_complete: false,
|
||||
scan_failures: failures,
|
||||
backup_dir: None,
|
||||
warnings: scan.warnings,
|
||||
sessions: Vec::new(),
|
||||
});
|
||||
}
|
||||
};
|
||||
scan_failures.extend(session_warnings);
|
||||
let mut scan_failures = scan.scan_failures;
|
||||
scan_failures.extend(session_failures);
|
||||
let mut seen_failures = HashSet::new();
|
||||
scan_failures.retain(|failure| seen_failures.insert(failure.clone()));
|
||||
let mut warnings = rollouts.warnings;
|
||||
warnings.extend(sqlite.warnings);
|
||||
let scan_complete = scan_failures.is_empty();
|
||||
let mismatched_sessions = sqlite
|
||||
.mismatched_thread_ids
|
||||
.union(&rollouts.mismatched_thread_ids)
|
||||
.count();
|
||||
let mut sqlite_mismatch_ids = scan.sqlite.mismatched_thread_ids.clone();
|
||||
sqlite_mismatch_ids.extend(scan.catalog.mismatched_thread_ids.iter().cloned());
|
||||
let mut mismatched_ids = sqlite_mismatch_ids.clone();
|
||||
mismatched_ids.extend(scan.rollouts.mismatched_thread_ids.iter().cloned());
|
||||
for session in &mut sessions {
|
||||
if mismatched_ids.contains(&session.id) {
|
||||
session.needs_sync = true;
|
||||
}
|
||||
}
|
||||
let needs_sync = !scan.rollouts.changes.is_empty()
|
||||
|| scan.sqlite.mismatched_threads > 0
|
||||
|| scan.catalog.total_updates() > 0;
|
||||
Ok(SessionSyncStatus {
|
||||
codex_dir: codex_dir.display().to_string(),
|
||||
target_provider: target,
|
||||
rollout_files: rollouts.rollout_files,
|
||||
session_meta_count: rollouts.session_meta_count,
|
||||
mismatched_rollouts: rollouts.mismatched_rollouts,
|
||||
mismatched_session_meta: rollouts.mismatched_session_meta,
|
||||
sqlite_dbs: sqlite.sqlite_dbs,
|
||||
sqlite_threads: sqlite.sqlite_threads,
|
||||
top_level_threads: sqlite.top_level_threads,
|
||||
subagent_threads: sqlite.subagent_threads,
|
||||
mismatched_threads: sqlite.mismatched_threads,
|
||||
mismatched_sessions,
|
||||
needs_sync: mismatched_sessions > 0,
|
||||
rollout_files: scan.rollouts.rollout_files,
|
||||
session_meta_count: scan.rollouts.session_meta_count,
|
||||
mismatched_rollouts: scan.rollouts.mismatched_rollouts,
|
||||
mismatched_session_meta: scan.rollouts.mismatched_session_meta,
|
||||
sqlite_dbs: scan.sqlite.sqlite_dbs,
|
||||
sqlite_threads: display_sqlite.sqlite_threads,
|
||||
top_level_threads: display_sqlite.top_level_threads,
|
||||
subagent_threads: display_sqlite.subagent_threads,
|
||||
mismatched_threads: sqlite_mismatch_ids.len(),
|
||||
mismatched_sessions: mismatched_ids.len(),
|
||||
needs_sync,
|
||||
scan_complete,
|
||||
scan_failures,
|
||||
backup_dir: None,
|
||||
warnings,
|
||||
warnings: scan.warnings,
|
||||
sessions,
|
||||
})
|
||||
}
|
||||
@@ -192,7 +297,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
|
||||
@@ -200,30 +305,23 @@ where
|
||||
{
|
||||
let codex_dir = resolve_codex_dir(config_dir)?;
|
||||
ensure_directory(&codex_dir)?;
|
||||
let target_provider = SHARED_SESSION_PROVIDER.to_string();
|
||||
let target_provider = current_model_provider(&codex_dir, target_provider)?;
|
||||
let _maintenance_lock = acquire_session_maintenance_lock(&codex_dir)?;
|
||||
let discovery = discover_sqlite_databases(&codex_dir);
|
||||
ensure_sqlite_discovery_writable(&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 scan = scan_provider_sync_data(&codex_dir, &target_provider, &discovery)?;
|
||||
if !scan.scan_failures.is_empty() {
|
||||
return Err(scan_failure_error(&scan.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 {
|
||||
if scan.rollouts.changes.is_empty()
|
||||
&& scan.sqlite.mismatched_threads == 0
|
||||
&& scan.catalog.total_updates() == 0
|
||||
{
|
||||
return Ok(SessionSyncResult {
|
||||
status: initial_status,
|
||||
updated_rollouts: 0,
|
||||
@@ -232,12 +330,13 @@ where
|
||||
});
|
||||
}
|
||||
|
||||
let changed_rollouts = rollouts
|
||||
let changed_rollouts = scan
|
||||
.rollouts
|
||||
.changes
|
||||
.iter()
|
||||
.map(|change| change.path.clone())
|
||||
.collect::<Vec<_>>();
|
||||
let mut pending_sqlite = prepare_sqlite_updates(&discovery.active_paths)?;
|
||||
let mut pending_sqlite = prepare_sqlite_updates(&discovery.related_paths)?;
|
||||
let sqlite_snapshot_paths = pending_sqlite
|
||||
.iter()
|
||||
.map(|update| update.path().to_path_buf())
|
||||
@@ -256,9 +355,10 @@ where
|
||||
};
|
||||
let mut journal = MutationJournal::default();
|
||||
let mutation = execute_provider_sync_mutation(
|
||||
&rollouts,
|
||||
&scan.rollouts,
|
||||
&mut pending_sqlite,
|
||||
&target_provider,
|
||||
&scan.catalog.sources,
|
||||
&mut journal,
|
||||
&mut hook,
|
||||
);
|
||||
@@ -303,6 +403,8 @@ mod tests {
|
||||
use rusqlite::Connection;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
const SHARED_SESSION_PROVIDER: &str = "custom";
|
||||
|
||||
fn temp_codex_dir(label: &str) -> std::path::PathBuf {
|
||||
static COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
@@ -365,6 +467,74 @@ mod tests {
|
||||
.expect("insert thread with rollout path");
|
||||
}
|
||||
|
||||
fn create_catalog_database(path: &Path, rows: &[(&str, &str)]) {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent).expect("create catalog database parent");
|
||||
}
|
||||
let conn = Connection::open(path).expect("create catalog database");
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE local_thread_catalog (
|
||||
host_id TEXT NOT NULL,
|
||||
thread_id TEXT NOT NULL,
|
||||
display_title TEXT NOT NULL,
|
||||
source_created_at REAL NOT NULL,
|
||||
source_updated_at REAL NOT NULL,
|
||||
cwd TEXT NOT NULL,
|
||||
source_kind TEXT NOT NULL,
|
||||
source_detail TEXT,
|
||||
model_provider TEXT NOT NULL,
|
||||
git_branch TEXT,
|
||||
observation_sequence INTEGER NOT NULL,
|
||||
missing_candidate INTEGER NOT NULL DEFAULT 0,
|
||||
thread_source TEXT,
|
||||
source_recency_at REAL NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (host_id, thread_id)
|
||||
);
|
||||
CREATE TABLE local_thread_catalog_hosts (
|
||||
host_id TEXT PRIMARY KEY,
|
||||
host_kind TEXT NOT NULL
|
||||
);
|
||||
INSERT INTO local_thread_catalog_hosts VALUES ('local', 'local');
|
||||
CREATE TABLE local_thread_catalog_metadata (
|
||||
id INTEGER PRIMARY KEY,
|
||||
catalog_revision INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
INSERT INTO local_thread_catalog_metadata VALUES (1, 7);
|
||||
CREATE TABLE local_thread_catalog_sync_state (
|
||||
host_id TEXT PRIMARY KEY,
|
||||
watermark_updated_at REAL,
|
||||
initial_build_complete INTEGER NOT NULL DEFAULT 0,
|
||||
observation_sequence INTEGER NOT NULL DEFAULT 0,
|
||||
last_full_reconciled_at INTEGER
|
||||
);
|
||||
INSERT INTO local_thread_catalog_sync_state VALUES ('local', 100, 1, 0, 100);",
|
||||
)
|
||||
.expect("create catalog tables");
|
||||
for (index, (id, provider)) in rows.iter().enumerate() {
|
||||
conn.execute(
|
||||
"INSERT INTO local_thread_catalog (
|
||||
host_id, thread_id, display_title, source_created_at, source_updated_at,
|
||||
cwd, source_kind, source_detail, model_provider, git_branch,
|
||||
observation_sequence, missing_candidate, thread_source, source_recency_at
|
||||
) VALUES ('local', ?1, ?1, 100, 100, '/tmp/project', 'cli', '', ?2,
|
||||
NULL, ?3, 0, 'user', 100)",
|
||||
(id, provider, index as i64 + 1),
|
||||
)
|
||||
.expect("insert catalog row");
|
||||
}
|
||||
}
|
||||
|
||||
fn catalog_provider(path: &Path, id: &str) -> String {
|
||||
Connection::open(path)
|
||||
.expect("open catalog database")
|
||||
.query_row(
|
||||
"SELECT model_provider FROM local_thread_catalog WHERE thread_id = ?1",
|
||||
[id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.expect("read catalog provider")
|
||||
}
|
||||
|
||||
fn thread_provider(path: &Path, id: &str) -> String {
|
||||
Connection::open(path)
|
||||
.expect("open session database")
|
||||
@@ -394,22 +564,76 @@ mod tests {
|
||||
}
|
||||
|
||||
#[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");
|
||||
fn live_provider_is_used_as_the_sync_target() {
|
||||
let codex_dir = temp_codex_dir("live-provider-target");
|
||||
let provider = "wujin_provider_1785657600000";
|
||||
write_config(&codex_dir, provider);
|
||||
let id = "019f6000-0000-7000-8000-000000000500";
|
||||
let database = codex_dir.join("state_5.sqlite");
|
||||
create_thread_database(&database, id, "openai");
|
||||
let rollout = write_rollout(&codex_dir, id, "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")));
|
||||
.expect("read live provider status");
|
||||
assert!(status.scan_complete, "{:?}", status.scan_failures);
|
||||
assert_eq!(status.target_provider, provider);
|
||||
assert!(status.needs_sync);
|
||||
|
||||
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 会话"));
|
||||
let result = sync_sessions_provider_inner(Some(codex_dir.display().to_string()), None)
|
||||
.expect("synchronize to the live provider");
|
||||
assert_eq!(result.status.target_provider, provider);
|
||||
assert!(!result.status.needs_sync);
|
||||
assert_eq!(thread_provider(&database, id), provider);
|
||||
assert!(fs::read_to_string(rollout)
|
||||
.expect("read synchronized rollout")
|
||||
.contains(&format!("\"model_provider\":\"{provider}\"")));
|
||||
|
||||
fs::remove_dir_all(codex_dir).expect("remove test directory");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_target_overrides_config_without_rewriting_it() {
|
||||
let codex_dir = temp_codex_dir("explicit-provider-target");
|
||||
write_config(&codex_dir, "configured-provider");
|
||||
let original_config = fs::read(codex_dir.join("config.toml")).expect("read config");
|
||||
let id = "019f6000-0000-7000-8000-000000000501";
|
||||
let database = codex_dir.join("state_5.sqlite");
|
||||
create_thread_database(&database, id, "openai");
|
||||
let rollout = write_rollout(&codex_dir, id, "openai");
|
||||
|
||||
let result = sync_sessions_provider_inner(
|
||||
Some(codex_dir.display().to_string()),
|
||||
Some("manual-provider".to_string()),
|
||||
)
|
||||
.expect("synchronize to explicit target");
|
||||
assert_eq!(result.status.target_provider, "manual-provider");
|
||||
assert_eq!(thread_provider(&database, id), "manual-provider");
|
||||
assert!(fs::read_to_string(rollout)
|
||||
.expect("read synchronized rollout")
|
||||
.contains("\"model_provider\":\"manual-provider\""));
|
||||
assert_eq!(
|
||||
fs::read(codex_dir.join("config.toml")).expect("read unchanged config"),
|
||||
original_config
|
||||
);
|
||||
|
||||
fs::remove_dir_all(codex_dir).expect("remove test directory");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_config_defaults_to_openai() {
|
||||
let codex_dir = temp_codex_dir("default-provider-target");
|
||||
let id = "019f6000-0000-7000-8000-000000000502";
|
||||
let database = codex_dir.join("state_5.sqlite");
|
||||
create_thread_database(&database, id, "custom");
|
||||
|
||||
let status = session_sync_status_inner(Some(codex_dir.display().to_string()), None)
|
||||
.expect("read default provider status");
|
||||
assert_eq!(status.target_provider, "openai");
|
||||
assert!(status.needs_sync);
|
||||
let result = sync_sessions_provider_inner(Some(codex_dir.display().to_string()), None)
|
||||
.expect("synchronize to default provider");
|
||||
assert_eq!(result.status.target_provider, "openai");
|
||||
assert_eq!(thread_provider(&database, id), "openai");
|
||||
|
||||
fs::remove_dir_all(codex_dir).expect("remove test directory");
|
||||
}
|
||||
@@ -460,9 +684,10 @@ mod tests {
|
||||
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.rollout_files, 2);
|
||||
assert_eq!(status.session_meta_count, 1);
|
||||
assert!(!status.needs_sync);
|
||||
assert_eq!(status.warnings.len(), 1);
|
||||
|
||||
fs::remove_dir_all(codex_dir).expect("remove test directory");
|
||||
}
|
||||
@@ -487,8 +712,9 @@ mod tests {
|
||||
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.rollout_files, 2);
|
||||
assert_eq!(status.session_meta_count, 1);
|
||||
assert_eq!(status.warnings.len(), 1);
|
||||
|
||||
fs::remove_dir_all(codex_dir).expect("remove test directory");
|
||||
}
|
||||
@@ -575,8 +801,8 @@ mod tests {
|
||||
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.needs_sync);
|
||||
assert_eq!(status.rollout_files, 1);
|
||||
assert!(status
|
||||
.scan_failures
|
||||
.iter()
|
||||
@@ -622,7 +848,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqlite_rollout_path_excludes_other_files_with_the_same_thread_id() {
|
||||
fn provider_sync_updates_all_rollouts_even_when_sqlite_selects_one() {
|
||||
let codex_dir = temp_codex_dir("referenced-rollout-excludes-duplicates");
|
||||
write_config(&codex_dir, SHARED_SESSION_PROVIDER);
|
||||
let id = "019f6000-0000-7000-8000-000000000508";
|
||||
@@ -633,14 +859,14 @@ mod tests {
|
||||
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");
|
||||
.expect("scan every rollout");
|
||||
assert!(status.scan_complete, "{:?}", status.scan_failures);
|
||||
assert_eq!(status.rollout_files, 1);
|
||||
assert_eq!(status.mismatched_rollouts, 1);
|
||||
assert_eq!(status.rollout_files, 2);
|
||||
assert_eq!(status.mismatched_rollouts, 2);
|
||||
|
||||
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);
|
||||
.expect("synchronize every rollout");
|
||||
assert_eq!(result.updated_rollouts, 2);
|
||||
assert_eq!(result.updated_threads, 1);
|
||||
assert_eq!(thread_provider(&database, id), SHARED_SESSION_PROVIDER);
|
||||
assert!(fs::read_to_string(&selected)
|
||||
@@ -648,7 +874,7 @@ mod tests {
|
||||
.contains("\"model_provider\":\"custom\""));
|
||||
assert!(fs::read_to_string(&duplicate)
|
||||
.expect("read duplicate rollout")
|
||||
.contains("\"model_provider\":\"openai\""));
|
||||
.contains("\"model_provider\":\"custom\""));
|
||||
|
||||
fs::remove_dir_all(codex_dir).expect("remove test directory");
|
||||
}
|
||||
@@ -709,7 +935,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_sqlite_is_not_counted_or_modified() {
|
||||
fn all_thread_databases_are_synchronized_without_inflating_the_active_list() {
|
||||
let codex_dir = temp_codex_dir("active-only");
|
||||
write_config(&codex_dir, SHARED_SESSION_PROVIDER);
|
||||
let active = codex_dir.join("state_5.sqlite");
|
||||
@@ -722,23 +948,57 @@ mod tests {
|
||||
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_dbs, 2);
|
||||
assert_eq!(status.sqlite_threads, 1);
|
||||
assert_eq!(status.mismatched_threads, 1);
|
||||
assert_eq!(status.mismatched_threads, 2);
|
||||
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!(result.updated_threads, 2);
|
||||
assert_eq!(thread_provider(&active, active_id), "custom");
|
||||
assert_eq!(thread_provider(&legacy, legacy_id), "openai");
|
||||
assert_eq!(thread_provider(&legacy, legacy_id), "custom");
|
||||
|
||||
fs::remove_dir_all(codex_dir).expect("remove test directory");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configured_sqlite_home_is_the_only_active_database_root() {
|
||||
fn stale_legacy_rollout_reference_warns_without_blocking_active_sync() {
|
||||
let codex_dir = temp_codex_dir("stale-legacy-rollout");
|
||||
write_config(&codex_dir, SHARED_SESSION_PROVIDER);
|
||||
let active_id = "019f6000-0000-7000-8000-000000000513";
|
||||
let legacy_id = "019f6000-0000-7000-8000-000000000514";
|
||||
let active_rollout = write_rollout(&codex_dir, active_id, "openai");
|
||||
let active = codex_dir.join("state_5.sqlite");
|
||||
create_thread_database_with_rollout(&active, active_id, "openai", &active_rollout);
|
||||
let stale_rollout = codex_dir.join(format!("sessions/rollout-deleted-{legacy_id}.jsonl"));
|
||||
let legacy = codex_dir.join("sqlite/state_5.sqlite");
|
||||
create_thread_database_with_rollout(&legacy, legacy_id, "openai", &stale_rollout);
|
||||
|
||||
let status = session_sync_status_inner(Some(codex_dir.display().to_string()), None)
|
||||
.expect("scan with stale legacy reference");
|
||||
assert!(status.scan_complete, "{:?}", status.scan_failures);
|
||||
assert!(status.needs_sync);
|
||||
assert!(status
|
||||
.warnings
|
||||
.iter()
|
||||
.any(|warning| warning.contains("已忽略旧会话索引异常")));
|
||||
|
||||
let result = sync_sessions_provider_inner(Some(codex_dir.display().to_string()), None)
|
||||
.expect("synchronize despite stale legacy reference");
|
||||
assert_eq!(result.updated_threads, 2);
|
||||
assert_eq!(thread_provider(&active, active_id), "custom");
|
||||
assert_eq!(thread_provider(&legacy, legacy_id), "custom");
|
||||
assert!(fs::read_to_string(active_rollout)
|
||||
.expect("read active rollout")
|
||||
.contains("\"model_provider\":\"custom\""));
|
||||
|
||||
fs::remove_dir_all(codex_dir).expect("remove test directory");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configured_sqlite_home_controls_display_while_all_databases_are_synchronized() {
|
||||
let codex_dir = temp_codex_dir("configured-sqlite-home");
|
||||
fs::write(
|
||||
codex_dir.join("config.toml"),
|
||||
@@ -760,15 +1020,15 @@ mod tests {
|
||||
|
||||
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!(result.updated_threads, 2);
|
||||
assert_eq!(thread_provider(&configured, configured_id), "custom");
|
||||
assert_eq!(thread_provider(&root_copy, root_id), "openai");
|
||||
assert_eq!(thread_provider(&root_copy, root_id), "custom");
|
||||
|
||||
fs::remove_dir_all(codex_dir).expect("remove test directory");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_only_database_is_not_treated_as_active() {
|
||||
fn legacy_only_database_can_still_be_synchronized() {
|
||||
let codex_dir = temp_codex_dir("legacy-only");
|
||||
write_config(&codex_dir, SHARED_SESSION_PROVIDER);
|
||||
let legacy = codex_dir.join("sqlite/state_5.sqlite");
|
||||
@@ -777,18 +1037,19 @@ mod tests {
|
||||
|
||||
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");
|
||||
assert!(status.scan_complete, "{:?}", status.scan_failures);
|
||||
assert_eq!(status.sqlite_threads, 1);
|
||||
assert_eq!(status.sessions.len(), 1);
|
||||
let result = sync_sessions_provider_inner(Some(codex_dir.display().to_string()), None)
|
||||
.expect("synchronize legacy-only database");
|
||||
assert_eq!(result.updated_threads, 1);
|
||||
assert_eq!(thread_provider(&legacy, id), "custom");
|
||||
|
||||
fs::remove_dir_all(codex_dir).expect("remove test directory");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn orphan_rollout_is_not_counted_or_modified() {
|
||||
fn orphan_rollout_is_synchronized_like_codex_plusplus() {
|
||||
let codex_dir = temp_codex_dir("orphan-rollout");
|
||||
write_config(&codex_dir, SHARED_SESSION_PROVIDER);
|
||||
let active_id = "019f6000-0000-7000-8000-000000000531";
|
||||
@@ -803,16 +1064,16 @@ mod tests {
|
||||
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);
|
||||
assert!(status.needs_sync);
|
||||
assert_eq!(status.mismatched_sessions, 1);
|
||||
assert_eq!(status.mismatched_rollouts, 1);
|
||||
|
||||
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);
|
||||
.expect("synchronize orphan rollout");
|
||||
assert_eq!(result.updated_rollouts, 1);
|
||||
assert!(fs::read_to_string(orphan)
|
||||
.expect("read orphan rollout")
|
||||
.contains("\"model_provider\":\"openai\""));
|
||||
.contains("\"model_provider\":\"custom\""));
|
||||
|
||||
fs::remove_dir_all(codex_dir).expect("remove test directory");
|
||||
}
|
||||
@@ -855,6 +1116,146 @@ mod tests {
|
||||
fs::remove_dir_all(codex_dir).expect("remove test directory");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_sync_updates_and_repairs_the_client_catalog() {
|
||||
let codex_dir = temp_codex_dir("catalog-repair");
|
||||
let provider = "wujin_provider_1785657600001";
|
||||
write_config(&codex_dir, provider);
|
||||
let first_id = "019f6000-0000-7000-8000-000000000551";
|
||||
let second_id = "019f6000-0000-7000-8000-000000000552";
|
||||
let state = codex_dir.join("state_5.sqlite");
|
||||
create_thread_database(&state, first_id, provider);
|
||||
let state_conn = Connection::open(&state).expect("open state database");
|
||||
state_conn
|
||||
.execute(
|
||||
"INSERT INTO threads (id, model_provider, title) VALUES (?1, ?2, 'Second')",
|
||||
(second_id, provider),
|
||||
)
|
||||
.expect("insert second thread");
|
||||
state_conn
|
||||
.execute_batch(
|
||||
"ALTER TABLE threads ADD COLUMN updated_at_ms INTEGER;
|
||||
ALTER TABLE threads ADD COLUMN recency_at_ms INTEGER;
|
||||
UPDATE threads SET updated_at_ms = 200000, recency_at_ms = 300000;",
|
||||
)
|
||||
.expect("seed thread timestamps");
|
||||
drop(state_conn);
|
||||
let catalog = codex_dir.join("sqlite/codex-dev.db");
|
||||
create_catalog_database(&catalog, &[(first_id, "openai")]);
|
||||
|
||||
let status = session_sync_status_inner(Some(codex_dir.display().to_string()), None)
|
||||
.expect("scan catalog drift");
|
||||
assert!(status.scan_complete, "{:?}", status.scan_failures);
|
||||
assert!(status.needs_sync);
|
||||
assert_eq!(status.mismatched_sessions, 2);
|
||||
|
||||
let result = sync_sessions_provider_inner(Some(codex_dir.display().to_string()), None)
|
||||
.expect("repair client catalog");
|
||||
assert_eq!(result.updated_threads, 2);
|
||||
assert!(!result.status.needs_sync);
|
||||
assert_eq!(catalog_provider(&catalog, first_id), provider);
|
||||
assert_eq!(catalog_provider(&catalog, second_id), provider);
|
||||
let catalog_conn = Connection::open(&catalog).expect("open repaired catalog");
|
||||
let (revision, observation_sequence, recency): (i64, i64, f64) = catalog_conn
|
||||
.query_row(
|
||||
"SELECT
|
||||
(SELECT catalog_revision FROM local_thread_catalog_metadata WHERE id = 1),
|
||||
(SELECT observation_sequence FROM local_thread_catalog_sync_state WHERE host_id = 'local'),
|
||||
(SELECT source_recency_at FROM local_thread_catalog WHERE thread_id = ?1)",
|
||||
[second_id],
|
||||
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
|
||||
)
|
||||
.expect("read catalog metadata");
|
||||
assert_eq!(revision, 8);
|
||||
assert!(observation_sequence >= 2);
|
||||
assert_eq!(recency, 300.0);
|
||||
drop(catalog_conn);
|
||||
|
||||
let second = sync_sessions_provider_inner(Some(codex_dir.display().to_string()), None)
|
||||
.expect("repeat catalog synchronization");
|
||||
assert_eq!(second.updated_rollouts, 0);
|
||||
assert_eq!(second.updated_threads, 0);
|
||||
assert!(second.backup_dir.is_empty());
|
||||
|
||||
fs::remove_dir_all(codex_dir).expect("remove test directory");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn injected_failure_restores_catalog_rows_metadata_and_sync_state() {
|
||||
let codex_dir = temp_codex_dir("catalog-rollback");
|
||||
write_config(&codex_dir, SHARED_SESSION_PROVIDER);
|
||||
let first_id = "019f6000-0000-7000-8000-000000000561";
|
||||
let second_id = "019f6000-0000-7000-8000-000000000562";
|
||||
let state = codex_dir.join("state_5.sqlite");
|
||||
create_thread_database(&state, first_id, "openai");
|
||||
Connection::open(&state)
|
||||
.expect("open state database")
|
||||
.execute(
|
||||
"INSERT INTO threads (id, model_provider, title) VALUES (?1, 'openai', 'Second')",
|
||||
[second_id],
|
||||
)
|
||||
.expect("insert second thread");
|
||||
let rollout = write_rollout(&codex_dir, first_id, "openai");
|
||||
let original_rollout = fs::read(&rollout).expect("read original rollout");
|
||||
let catalog = codex_dir.join("sqlite/codex-dev.db");
|
||||
create_catalog_database(&catalog, &[(first_id, "openai")]);
|
||||
|
||||
let error = sync_sessions_provider_with_hook(
|
||||
Some(codex_dir.display().to_string()),
|
||||
None,
|
||||
|point| match point {
|
||||
MutationPoint::AfterSqliteCommit(1) => {
|
||||
assert_eq!(catalog_provider(&catalog, first_id), "custom");
|
||||
assert_eq!(catalog_provider(&catalog, second_id), "custom");
|
||||
Err(CodexxError::Config("catalog rollback test".to_string()))
|
||||
}
|
||||
_ => Ok(()),
|
||||
},
|
||||
)
|
||||
.expect_err("inject failure after catalog commit");
|
||||
assert_eq!(error.to_string(), "配置错误: catalog rollback test");
|
||||
assert_eq!(thread_provider(&state, first_id), "openai");
|
||||
assert_eq!(thread_provider(&state, second_id), "openai");
|
||||
assert_eq!(catalog_provider(&catalog, first_id), "openai");
|
||||
let catalog_conn = Connection::open(&catalog).expect("open restored catalog");
|
||||
let (rows, revision, watermark, sequence, reconciled): (i64, i64, f64, i64, i64) =
|
||||
catalog_conn
|
||||
.query_row(
|
||||
"SELECT
|
||||
(SELECT COUNT(*) FROM local_thread_catalog),
|
||||
(SELECT catalog_revision FROM local_thread_catalog_metadata WHERE id = 1),
|
||||
(SELECT watermark_updated_at FROM local_thread_catalog_sync_state WHERE host_id = 'local'),
|
||||
(SELECT observation_sequence FROM local_thread_catalog_sync_state WHERE host_id = 'local'),
|
||||
(SELECT last_full_reconciled_at FROM local_thread_catalog_sync_state WHERE host_id = 'local')",
|
||||
[],
|
||||
|row| {
|
||||
Ok((
|
||||
row.get(0)?,
|
||||
row.get(1)?,
|
||||
row.get(2)?,
|
||||
row.get(3)?,
|
||||
row.get(4)?,
|
||||
))
|
||||
},
|
||||
)
|
||||
.expect("read restored catalog state");
|
||||
assert_eq!(
|
||||
(rows, revision, watermark, sequence, reconciled),
|
||||
(1, 7, 100.0, 0, 100)
|
||||
);
|
||||
let quick_check: String = catalog_conn
|
||||
.query_row("PRAGMA quick_check", [], |row| row.get(0))
|
||||
.expect("check restored catalog");
|
||||
assert_eq!(quick_check, "ok");
|
||||
assert_eq!(
|
||||
fs::read(rollout).expect("read restored rollout"),
|
||||
original_rollout
|
||||
);
|
||||
|
||||
drop(catalog_conn);
|
||||
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");
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
use super::catalog::{
|
||||
apply_catalog_updates, catalog_columns, create_catalog_rollback_tables,
|
||||
restore_catalog_updates, CatalogRepairThread,
|
||||
};
|
||||
use super::storage::{apply_session_changes, restore_session_changes};
|
||||
use super::types::{RolloutScan, SessionFileChange};
|
||||
use crate::error::{CodexxError, Result};
|
||||
use crate::file_io::io_err;
|
||||
use crate::sqlite_utils::{sqlite_has_table, table_column_set};
|
||||
use rusqlite::{Connection, OpenFlags};
|
||||
use std::collections::HashSet;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -12,16 +16,18 @@ use std::time::Duration;
|
||||
pub(super) struct SqliteUpdateCounts {
|
||||
provider_rows: usize,
|
||||
cwd_rows: usize,
|
||||
catalog_insert_rows: usize,
|
||||
}
|
||||
|
||||
impl SqliteUpdateCounts {
|
||||
pub(super) fn total(&self) -> usize {
|
||||
self.provider_rows + self.cwd_rows
|
||||
self.provider_rows + self.cwd_rows + self.catalog_insert_rows
|
||||
}
|
||||
|
||||
fn add(&mut self, other: Self) {
|
||||
self.provider_rows += other.provider_rows;
|
||||
self.cwd_rows += other.cwd_rows;
|
||||
self.catalog_insert_rows += other.catalog_insert_rows;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,7 +47,8 @@ pub(super) struct PendingSqliteUpdate {
|
||||
path: PathBuf,
|
||||
conn: Connection,
|
||||
observer: Connection,
|
||||
columns: HashSet<String>,
|
||||
thread_columns: HashSet<String>,
|
||||
catalog_columns: HashSet<String>,
|
||||
counts: SqliteUpdateCounts,
|
||||
transaction_open: bool,
|
||||
}
|
||||
@@ -76,7 +83,8 @@ fn create_sqlite_rollback_table(conn: &Connection) -> Result<()> {
|
||||
cwd_changed INTEGER NOT NULL DEFAULT 0
|
||||
);",
|
||||
)
|
||||
.map_err(|error| CodexxError::Database(error.to_string()))
|
||||
.map_err(|error| CodexxError::Database(error.to_string()))?;
|
||||
create_catalog_rollback_tables(conn)
|
||||
}
|
||||
|
||||
pub(super) fn prepare_sqlite_updates(sqlite_paths: &[PathBuf]) -> Result<Vec<PendingSqliteUpdate>> {
|
||||
@@ -103,11 +111,17 @@ pub(super) fn prepare_sqlite_updates(sqlite_paths: &[PathBuf]) -> Result<Vec<Pen
|
||||
})?;
|
||||
conn.busy_timeout(Duration::from_secs(5))
|
||||
.map_err(|error| CodexxError::Database(error.to_string()))?;
|
||||
if !sqlite_has_table(&conn, "threads")? {
|
||||
continue;
|
||||
}
|
||||
let columns = table_column_set(&conn, "threads")?;
|
||||
if !columns.contains("id") || !columns.contains("model_provider") {
|
||||
let thread_columns = if sqlite_has_table(&conn, "threads")? {
|
||||
table_column_set(&conn, "threads")?
|
||||
} else {
|
||||
HashSet::new()
|
||||
};
|
||||
let catalog_columns = catalog_columns(&conn)?;
|
||||
let supports_thread_updates =
|
||||
thread_columns.contains("id") && thread_columns.contains("model_provider");
|
||||
let supports_catalog_updates =
|
||||
catalog_columns.contains("thread_id") && catalog_columns.contains("model_provider");
|
||||
if !supports_thread_updates && !supports_catalog_updates {
|
||||
continue;
|
||||
}
|
||||
conn.execute_batch("BEGIN IMMEDIATE")
|
||||
@@ -143,7 +157,8 @@ pub(super) fn prepare_sqlite_updates(sqlite_paths: &[PathBuf]) -> Result<Vec<Pen
|
||||
path: identity,
|
||||
conn,
|
||||
observer,
|
||||
columns,
|
||||
thread_columns,
|
||||
catalog_columns,
|
||||
counts: SqliteUpdateCounts::default(),
|
||||
transaction_open: true,
|
||||
});
|
||||
@@ -161,30 +176,35 @@ fn apply_sqlite_updates(
|
||||
pending: &mut [PendingSqliteUpdate],
|
||||
rollouts: &RolloutScan,
|
||||
target_provider: &str,
|
||||
catalog_sources: &HashMap<String, CatalogRepairThread>,
|
||||
) -> Result<()> {
|
||||
for update in pending.iter_mut() {
|
||||
update
|
||||
.conn
|
||||
.execute(
|
||||
"INSERT INTO temp.codexx_session_rollback
|
||||
(id, model_provider, provider_changed)
|
||||
SELECT id, model_provider, 1 FROM threads
|
||||
WHERE COALESCE(model_provider, '') <> ?1
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
model_provider = excluded.model_provider,
|
||||
provider_changed = 1",
|
||||
[target_provider],
|
||||
)
|
||||
.map_err(|error| CodexxError::Database(error.to_string()))?;
|
||||
update.counts.provider_rows = update
|
||||
.conn
|
||||
.execute(
|
||||
"UPDATE threads SET model_provider = ?1 WHERE COALESCE(model_provider, '') <> ?1",
|
||||
[target_provider],
|
||||
)
|
||||
.map_err(|error| CodexxError::Database(error.to_string()))?;
|
||||
if update.thread_columns.contains("id") && update.thread_columns.contains("model_provider")
|
||||
{
|
||||
update
|
||||
.conn
|
||||
.execute(
|
||||
"INSERT INTO temp.codexx_session_rollback
|
||||
(id, model_provider, provider_changed)
|
||||
SELECT id, model_provider, 1 FROM threads
|
||||
WHERE COALESCE(model_provider, '') <> ?1
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
model_provider = excluded.model_provider,
|
||||
provider_changed = 1",
|
||||
[target_provider],
|
||||
)
|
||||
.map_err(|error| CodexxError::Database(error.to_string()))?;
|
||||
update.counts.provider_rows += update
|
||||
.conn
|
||||
.execute(
|
||||
"UPDATE threads SET model_provider = ?1 \
|
||||
WHERE COALESCE(model_provider, '') <> ?1",
|
||||
[target_provider],
|
||||
)
|
||||
.map_err(|error| CodexxError::Database(error.to_string()))?;
|
||||
}
|
||||
|
||||
if update.columns.contains("id") && update.columns.contains("cwd") {
|
||||
if update.thread_columns.contains("id") && update.thread_columns.contains("cwd") {
|
||||
for (thread_id, cwd) in &rollouts.cwd_by_thread_id {
|
||||
update
|
||||
.conn
|
||||
@@ -208,6 +228,14 @@ fn apply_sqlite_updates(
|
||||
.map_err(|error| CodexxError::Database(error.to_string()))?;
|
||||
}
|
||||
}
|
||||
let catalog_counts = apply_catalog_updates(
|
||||
&update.conn,
|
||||
&update.catalog_columns,
|
||||
target_provider,
|
||||
catalog_sources,
|
||||
)?;
|
||||
update.counts.provider_rows += catalog_counts.provider_rows;
|
||||
update.counts.catalog_insert_rows += catalog_counts.inserted_rows;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -269,18 +297,23 @@ fn restore_sqlite_update(
|
||||
)));
|
||||
}
|
||||
|
||||
let mut statements = vec![
|
||||
"UPDATE threads
|
||||
SET model_provider = (
|
||||
SELECT rollback.model_provider
|
||||
FROM temp.codexx_session_rollback AS rollback
|
||||
WHERE rollback.id = threads.id
|
||||
)
|
||||
WHERE id IN (
|
||||
SELECT id FROM temp.codexx_session_rollback WHERE provider_changed = 1
|
||||
)",
|
||||
];
|
||||
if update.columns.contains("cwd") {
|
||||
restore_catalog_updates(&update.conn, &update.catalog_columns)?;
|
||||
let mut statements = Vec::new();
|
||||
if update.thread_columns.contains("id") && update.thread_columns.contains("model_provider")
|
||||
{
|
||||
statements.push(
|
||||
"UPDATE threads
|
||||
SET model_provider = (
|
||||
SELECT rollback.model_provider
|
||||
FROM temp.codexx_session_rollback AS rollback
|
||||
WHERE rollback.id = threads.id
|
||||
)
|
||||
WHERE id IN (
|
||||
SELECT id FROM temp.codexx_session_rollback WHERE provider_changed = 1
|
||||
)",
|
||||
);
|
||||
}
|
||||
if update.thread_columns.contains("id") && update.thread_columns.contains("cwd") {
|
||||
statements.push(
|
||||
"UPDATE threads
|
||||
SET cwd = (
|
||||
@@ -361,6 +394,7 @@ pub(super) fn execute_provider_sync_mutation<F>(
|
||||
rollouts: &RolloutScan,
|
||||
pending_sqlite: &mut [PendingSqliteUpdate],
|
||||
target_provider: &str,
|
||||
catalog_sources: &HashMap<String, CatalogRepairThread>,
|
||||
journal: &mut MutationJournal,
|
||||
hook: &mut F,
|
||||
) -> Result<MutationResult>
|
||||
@@ -370,7 +404,7 @@ where
|
||||
let result = (|| -> Result<MutationResult> {
|
||||
let (applied_rollouts, skipped_rollouts) = apply_session_changes(&rollouts.changes)?;
|
||||
journal.applied_rollouts = applied_rollouts;
|
||||
apply_sqlite_updates(pending_sqlite, rollouts, target_provider)?;
|
||||
apply_sqlite_updates(pending_sqlite, rollouts, target_provider, catalog_sources)?;
|
||||
let sqlite_updates = commit_sqlite_updates(pending_sqlite, journal, hook)?;
|
||||
Ok(MutationResult {
|
||||
applied_rollouts: journal.applied_rollouts.len(),
|
||||
|
||||
@@ -178,7 +178,8 @@ fn failed_sqlite_commit_rolls_back_without_snapshot_restore() {
|
||||
path: database.clone(),
|
||||
conn,
|
||||
observer,
|
||||
columns: HashSet::new(),
|
||||
thread_columns: HashSet::new(),
|
||||
catalog_columns: HashSet::new(),
|
||||
counts: SqliteUpdateCounts::default(),
|
||||
transaction_open: true,
|
||||
}];
|
||||
|
||||
@@ -1,16 +1,25 @@
|
||||
use super::build_skills_mcp_state_inner;
|
||||
use super::types::{ManagedMcpServer, SkillsMcpState};
|
||||
use crate::backups::create_backup;
|
||||
use crate::ccswitch::default_ccswitch_db_path;
|
||||
use crate::error::{CodexxError, Result};
|
||||
use crate::file_io::{ensure_directory, parse_toml_document, read_to_string_if_exists, write_text};
|
||||
use crate::file_io::{ensure_directory, parse_toml_document, read_to_string_if_exists};
|
||||
use crate::live_config::{
|
||||
acquire_live_config_lock, apply_file_change, fail_with_file_rollback, read_file_snapshot,
|
||||
text_from_snapshot,
|
||||
};
|
||||
use crate::toml_utils::ensure_table;
|
||||
use crate::{config_path, now_rfc3339, open_db, resolve_codex_dir};
|
||||
use rusqlite::{params, Connection, OpenFlags};
|
||||
use rusqlite::{
|
||||
params, Connection, OpenFlags, OptionalExtension, Transaction, TransactionBehavior,
|
||||
};
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::HashSet;
|
||||
use std::path::Path;
|
||||
use toml_edit::{value, Item, Table};
|
||||
|
||||
type CcSwitchMcpCandidate = (String, String, Value, bool);
|
||||
|
||||
fn toml_value_to_json(value: &toml_edit::Value) -> Value {
|
||||
if let Some(s) = value.as_str() {
|
||||
return json!(s);
|
||||
@@ -134,8 +143,13 @@ pub(super) fn mcp_summary(config: &Value) -> (String, Option<String>, Option<Str
|
||||
(transport, command, url, summary)
|
||||
}
|
||||
|
||||
pub(super) fn save_managed_mcp(id: &str, name: &str, config: &Value, enabled: bool) -> Result<()> {
|
||||
let conn = open_db()?;
|
||||
fn save_managed_mcp_on_connection(
|
||||
conn: &Connection,
|
||||
id: &str,
|
||||
name: &str,
|
||||
config: &Value,
|
||||
enabled: bool,
|
||||
) -> Result<()> {
|
||||
conn.execute(
|
||||
"INSERT INTO managed_mcp_servers (id, name, server_config, enabled, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5)
|
||||
@@ -156,6 +170,83 @@ pub(super) fn save_managed_mcp(id: &str, name: &str, config: &Value, enabled: bo
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn save_managed_mcp(id: &str, name: &str, config: &Value, enabled: bool) -> Result<()> {
|
||||
let conn = open_db()?;
|
||||
save_managed_mcp_on_connection(&conn, id, name, config, enabled)
|
||||
}
|
||||
|
||||
fn managed_mcp_on_connection(conn: &Connection, id: &str) -> Result<Option<(String, Value, bool)>> {
|
||||
conn.query_row(
|
||||
"SELECT name, server_config, enabled FROM managed_mcp_servers WHERE id = ?1 LIMIT 1",
|
||||
[id],
|
||||
|row| {
|
||||
let config_text: String = row.get(1)?;
|
||||
Ok((
|
||||
row.get(0)?,
|
||||
serde_json::from_str(&config_text).unwrap_or(Value::Object(Default::default())),
|
||||
row.get(2)?,
|
||||
))
|
||||
},
|
||||
)
|
||||
.optional()
|
||||
.map_err(|error| CodexxError::Database(error.to_string()))
|
||||
}
|
||||
|
||||
fn document_mcp_ids(doc: &toml_edit::DocumentMut) -> HashSet<String> {
|
||||
doc.get("mcp_servers")
|
||||
.and_then(|item| item.as_table())
|
||||
.map(|table| {
|
||||
table
|
||||
.iter()
|
||||
.filter(|(_, item)| item.is_table())
|
||||
.map(|(id, _)| id.to_string())
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn document_bytes(snapshot: Option<&[u8]>, doc: &toml_edit::DocumentMut) -> Option<Vec<u8>> {
|
||||
let bytes = doc.to_string().into_bytes();
|
||||
if snapshot.is_none() && bytes.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(bytes)
|
||||
}
|
||||
}
|
||||
|
||||
fn commit_mcp_transaction_with_config<BeforeApply, BeforeCommit>(
|
||||
transaction: Transaction<'_>,
|
||||
codex_dir: &Path,
|
||||
before: Option<Vec<u8>>,
|
||||
after: Option<Vec<u8>>,
|
||||
backup_action: &str,
|
||||
before_apply: BeforeApply,
|
||||
before_commit: BeforeCommit,
|
||||
) -> Result<()>
|
||||
where
|
||||
BeforeApply: FnOnce(&Path) -> Result<()>,
|
||||
BeforeCommit: FnOnce(&Transaction<'_>) -> Result<()>,
|
||||
{
|
||||
let cfg = config_path(codex_dir);
|
||||
let mut changes = Vec::new();
|
||||
if before != after {
|
||||
create_backup(codex_dir, backup_action)?;
|
||||
before_apply(&cfg)?;
|
||||
changes.push(apply_file_change(&cfg, before, after)?);
|
||||
}
|
||||
|
||||
if let Err(error) = before_commit(&transaction) {
|
||||
return fail_with_file_rollback(error, &changes);
|
||||
}
|
||||
if let Err(error) = transaction
|
||||
.commit()
|
||||
.map_err(|error| CodexxError::Database(error.to_string()))
|
||||
{
|
||||
return fail_with_file_rollback(error, &changes);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn db_managed_mcp() -> Result<Vec<(String, String, Value, bool)>> {
|
||||
let conn = open_db()?;
|
||||
let mut stmt = conn.prepare("SELECT id, name, server_config, enabled FROM managed_mcp_servers ORDER BY name ASC, id ASC")
|
||||
@@ -264,36 +355,76 @@ pub(super) fn import_ccswitch_mcp_servers_for_codex(
|
||||
))
|
||||
})
|
||||
.map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
|
||||
let cfg = config_path(codex_dir);
|
||||
let text = read_to_string_if_exists(&cfg)?;
|
||||
let mut doc = parse_toml_document(&cfg, &text)?;
|
||||
let live_enabled = list_mcp_from_config(codex_dir)?
|
||||
.into_iter()
|
||||
.map(|server| server.id)
|
||||
.collect::<HashSet<_>>();
|
||||
let mut imported = 0usize;
|
||||
let mut changed_config = false;
|
||||
let mut candidates = Vec::new();
|
||||
for row in rows {
|
||||
let (id, name, config_text, enabled_codex) =
|
||||
row.map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
let config: Value =
|
||||
serde_json::from_str(&config_text).unwrap_or(Value::Object(Default::default()));
|
||||
if !imported_ids.insert(id.clone()) {
|
||||
continue;
|
||||
}
|
||||
let enabled = enabled_codex || live_enabled.contains(&id);
|
||||
save_managed_mcp(&id, &name, &config, enabled)?;
|
||||
if enabled_codex && !live_enabled.contains(&id) {
|
||||
ensure_table(doc.as_table_mut(), "mcp_servers")?
|
||||
.insert(&id, json_to_toml_item(&config));
|
||||
changed_config = true;
|
||||
}
|
||||
imported += 1;
|
||||
candidates.push((id, name, config, enabled_codex));
|
||||
}
|
||||
if changed_config {
|
||||
write_text(&cfg, &doc.to_string())?;
|
||||
|
||||
import_ccswitch_mcp_candidates_with_hooks(
|
||||
codex_dir,
|
||||
imported_ids,
|
||||
candidates,
|
||||
|_| Ok(()),
|
||||
|_| Ok(()),
|
||||
)
|
||||
}
|
||||
|
||||
fn import_ccswitch_mcp_candidates_with_hooks<BeforeApply, BeforeCommit>(
|
||||
codex_dir: &Path,
|
||||
imported_ids: &mut HashSet<String>,
|
||||
candidates: Vec<CcSwitchMcpCandidate>,
|
||||
before_apply: BeforeApply,
|
||||
before_commit: BeforeCommit,
|
||||
) -> Result<usize>
|
||||
where
|
||||
BeforeApply: FnOnce(&Path) -> Result<()>,
|
||||
BeforeCommit: FnOnce(&Transaction<'_>) -> Result<()>,
|
||||
{
|
||||
let mut staged_ids = HashSet::new();
|
||||
let candidates = candidates
|
||||
.into_iter()
|
||||
.filter(|(id, _, _, _)| !imported_ids.contains(id) && staged_ids.insert(id.clone()))
|
||||
.collect::<Vec<_>>();
|
||||
if candidates.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
ensure_directory(codex_dir)?;
|
||||
let _live_lock = acquire_live_config_lock(codex_dir)?;
|
||||
let cfg = config_path(codex_dir);
|
||||
let before = read_file_snapshot(&cfg)?;
|
||||
let text = text_from_snapshot(&cfg, before.as_deref())?;
|
||||
let mut doc = parse_toml_document(&cfg, &text)?;
|
||||
let live_enabled = document_mcp_ids(&doc);
|
||||
|
||||
let mut app_conn = open_db()?;
|
||||
let transaction = app_conn
|
||||
.transaction_with_behavior(TransactionBehavior::Immediate)
|
||||
.map_err(|error| CodexxError::Database(error.to_string()))?;
|
||||
for (id, name, config, enabled_codex) in &candidates {
|
||||
let enabled = *enabled_codex || live_enabled.contains(id);
|
||||
save_managed_mcp_on_connection(&transaction, id, name, config, enabled)?;
|
||||
if *enabled_codex && !live_enabled.contains(id) {
|
||||
ensure_table(doc.as_table_mut(), "mcp_servers")?.insert(id, json_to_toml_item(config));
|
||||
}
|
||||
}
|
||||
let after = document_bytes(before.as_deref(), &doc);
|
||||
commit_mcp_transaction_with_config(
|
||||
transaction,
|
||||
codex_dir,
|
||||
before,
|
||||
after,
|
||||
"import-ccswitch-mcp",
|
||||
before_apply,
|
||||
before_commit,
|
||||
)?;
|
||||
|
||||
let imported = candidates.len();
|
||||
imported_ids.extend(staged_ids);
|
||||
Ok(imported)
|
||||
}
|
||||
|
||||
@@ -368,31 +499,287 @@ pub(crate) fn toggle_codex_mcp_inner(
|
||||
id: String,
|
||||
enabled: bool,
|
||||
) -> Result<SkillsMcpState> {
|
||||
toggle_codex_mcp_with_hooks(config_dir, id, enabled, |_| Ok(()), |_| Ok(()))
|
||||
}
|
||||
|
||||
fn toggle_codex_mcp_with_hooks<BeforeApply, BeforeCommit>(
|
||||
config_dir: Option<String>,
|
||||
id: String,
|
||||
enabled: bool,
|
||||
before_apply: BeforeApply,
|
||||
before_commit: BeforeCommit,
|
||||
) -> Result<SkillsMcpState>
|
||||
where
|
||||
BeforeApply: FnOnce(&Path) -> Result<()>,
|
||||
BeforeCommit: FnOnce(&Transaction<'_>) -> Result<()>,
|
||||
{
|
||||
let codex_dir = resolve_codex_dir(config_dir.clone())?;
|
||||
ensure_directory(&codex_dir)?;
|
||||
let _live_lock = acquire_live_config_lock(&codex_dir)?;
|
||||
let cfg = config_path(&codex_dir);
|
||||
let text = read_to_string_if_exists(&cfg)?;
|
||||
let before = read_file_snapshot(&cfg)?;
|
||||
let text = text_from_snapshot(&cfg, before.as_deref())?;
|
||||
let mut doc = parse_toml_document(&cfg, &text)?;
|
||||
|
||||
let mut conn = open_db()?;
|
||||
let transaction = conn
|
||||
.transaction_with_behavior(TransactionBehavior::Immediate)
|
||||
.map_err(|error| CodexxError::Database(error.to_string()))?;
|
||||
let stored = managed_mcp_on_connection(&transaction, &id)?;
|
||||
if enabled {
|
||||
let db = db_managed_mcp()?
|
||||
.into_iter()
|
||||
.find(|(sid, _, _, _)| sid == &id)
|
||||
.ok_or_else(|| CodexxError::Config(format!("未找到 MCP: {id}")))?;
|
||||
ensure_table(doc.as_table_mut(), "mcp_servers")?.insert(&id, json_to_toml_item(&db.2));
|
||||
save_managed_mcp(&id, &db.1, &db.2, true)?;
|
||||
let (name, config, _) =
|
||||
stored.ok_or_else(|| CodexxError::Config(format!("未找到 MCP: {id}")))?;
|
||||
ensure_table(doc.as_table_mut(), "mcp_servers")?.insert(&id, json_to_toml_item(&config));
|
||||
save_managed_mcp_on_connection(&transaction, &id, &name, &config, true)?;
|
||||
} else {
|
||||
if let Some(item) = doc
|
||||
let live_config = doc
|
||||
.get("mcp_servers")
|
||||
.and_then(|m| m.as_table())
|
||||
.and_then(|tbl| tbl.get(&id))
|
||||
{
|
||||
let config = toml_item_to_json(item);
|
||||
save_managed_mcp(&id, &id, &config, false)?;
|
||||
.map(toml_item_to_json);
|
||||
if let Some(config) = live_config {
|
||||
let name = stored
|
||||
.as_ref()
|
||||
.map(|(name, _, _)| name.as_str())
|
||||
.unwrap_or(&id);
|
||||
save_managed_mcp_on_connection(&transaction, &id, name, &config, false)?;
|
||||
} else if let Some((name, config, _)) = stored {
|
||||
save_managed_mcp_on_connection(&transaction, &id, &name, &config, false)?;
|
||||
}
|
||||
if let Some(tbl) = doc.get_mut("mcp_servers").and_then(|m| m.as_table_mut()) {
|
||||
tbl.remove(&id);
|
||||
}
|
||||
}
|
||||
write_text(&cfg, &doc.to_string())?;
|
||||
|
||||
let after = document_bytes(before.as_deref(), &doc);
|
||||
commit_mcp_transaction_with_config(
|
||||
transaction,
|
||||
&codex_dir,
|
||||
before,
|
||||
after,
|
||||
"toggle-mcp",
|
||||
before_apply,
|
||||
before_commit,
|
||||
)?;
|
||||
build_skills_mcp_state_inner(config_dir)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
fn test_case(name: &str) -> (PathBuf, String) {
|
||||
static COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
let suffix = COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"codex-x-mcp-{name}-{}-{suffix}",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
fs::create_dir_all(&dir).expect("create MCP test directory");
|
||||
(dir, format!("test-mcp-{name}-{suffix}"))
|
||||
}
|
||||
|
||||
fn database_error(error: rusqlite::Error) -> CodexxError {
|
||||
CodexxError::Database(error.to_string())
|
||||
}
|
||||
|
||||
fn remove_test_mcp(id: &str) {
|
||||
open_db()
|
||||
.expect("open test database")
|
||||
.execute("DELETE FROM managed_mcp_servers WHERE id = ?1", [id])
|
||||
.expect("remove test MCP");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toggle_updates_only_the_target_mcp_and_keeps_database_in_sync() {
|
||||
let _db_guard = crate::app_db::test_db_guard();
|
||||
let (codex_dir, id) = test_case("success");
|
||||
let cfg = config_path(&codex_dir);
|
||||
let original = b"# keep this comment\nmodel = \"gpt-5.5\"\n\n[features]\njs_repl = false\n\n[mcp_servers.existing]\ncommand = \"existing\"\n";
|
||||
fs::write(&cfg, original).expect("seed original config");
|
||||
save_managed_mcp(&id, "Managed", &json!({ "command": "managed" }), false)
|
||||
.expect("seed managed MCP");
|
||||
|
||||
let enabled_state =
|
||||
toggle_codex_mcp_inner(Some(codex_dir.display().to_string()), id.clone(), true)
|
||||
.expect("enable managed MCP");
|
||||
assert!(enabled_state
|
||||
.mcp_servers
|
||||
.iter()
|
||||
.any(|server| server.id == id && server.enabled));
|
||||
let enabled_text = fs::read_to_string(&cfg).expect("read enabled config");
|
||||
let enabled_doc = enabled_text
|
||||
.parse::<toml_edit::DocumentMut>()
|
||||
.expect("parse enabled config");
|
||||
assert!(enabled_text.contains("# keep this comment"));
|
||||
assert_eq!(enabled_doc["features"]["js_repl"].as_bool(), Some(false));
|
||||
assert_eq!(
|
||||
enabled_doc["mcp_servers"]["existing"]["command"].as_str(),
|
||||
Some("existing")
|
||||
);
|
||||
assert_eq!(
|
||||
enabled_doc["mcp_servers"][&id]["command"].as_str(),
|
||||
Some("managed")
|
||||
);
|
||||
let conn = open_db().expect("open test database");
|
||||
assert!(
|
||||
managed_mcp_on_connection(&conn, &id)
|
||||
.expect("read enabled MCP")
|
||||
.expect("enabled MCP exists")
|
||||
.2
|
||||
);
|
||||
drop(conn);
|
||||
|
||||
toggle_codex_mcp_inner(Some(codex_dir.display().to_string()), id.clone(), false)
|
||||
.expect("disable managed MCP");
|
||||
let disabled_text = fs::read_to_string(&cfg).expect("read disabled config");
|
||||
let disabled_doc = disabled_text
|
||||
.parse::<toml_edit::DocumentMut>()
|
||||
.expect("parse disabled config");
|
||||
assert!(disabled_text.contains("# keep this comment"));
|
||||
assert!(disabled_doc["mcp_servers"].get(&id).is_none());
|
||||
assert_eq!(
|
||||
disabled_doc["mcp_servers"]["existing"]["command"].as_str(),
|
||||
Some("existing")
|
||||
);
|
||||
let conn = open_db().expect("open test database");
|
||||
let (name, config, enabled) = managed_mcp_on_connection(&conn, &id)
|
||||
.expect("read disabled MCP")
|
||||
.expect("disabled MCP exists");
|
||||
assert_eq!(name, "Managed");
|
||||
assert_eq!(config["command"].as_str(), Some("managed"));
|
||||
assert!(!enabled);
|
||||
drop(conn);
|
||||
assert!(codex_dir.join(".codexx-test-backups").is_dir());
|
||||
|
||||
remove_test_mcp(&id);
|
||||
fs::remove_dir_all(codex_dir).expect("remove MCP test directory");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toggle_rejects_a_stale_snapshot_without_changing_database_or_external_config() {
|
||||
let _db_guard = crate::app_db::test_db_guard();
|
||||
let (codex_dir, id) = test_case("stale");
|
||||
let cfg = config_path(&codex_dir);
|
||||
let original = b"# original\nmodel = \"gpt-5.5\"\n";
|
||||
let external = b"# external update\nmodel = \"gpt-5.5\"\n\n[mcp_servers.external]\ncommand = \"external\"\n";
|
||||
fs::write(&cfg, original).expect("seed original config");
|
||||
save_managed_mcp(&id, "Managed", &json!({ "command": "managed" }), false)
|
||||
.expect("seed managed MCP");
|
||||
|
||||
let error = toggle_codex_mcp_with_hooks(
|
||||
Some(codex_dir.display().to_string()),
|
||||
id.clone(),
|
||||
true,
|
||||
|path| {
|
||||
fs::write(path, external).map_err(|error| CodexxError::Config(error.to_string()))
|
||||
},
|
||||
|_| Ok(()),
|
||||
)
|
||||
.expect_err("stale MCP toggle must fail");
|
||||
|
||||
assert!(error.to_string().contains("已被其他程序修改"));
|
||||
assert_eq!(fs::read(&cfg).expect("read external config"), external);
|
||||
let conn = open_db().expect("open test database");
|
||||
let (_, config, enabled) = managed_mcp_on_connection(&conn, &id)
|
||||
.expect("read managed MCP")
|
||||
.expect("managed MCP still exists");
|
||||
assert_eq!(config["command"].as_str(), Some("managed"));
|
||||
assert!(!enabled);
|
||||
drop(conn);
|
||||
|
||||
remove_test_mcp(&id);
|
||||
fs::remove_dir_all(codex_dir).expect("remove MCP test directory");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toggle_rolls_back_config_when_database_commit_fails() {
|
||||
let _db_guard = crate::app_db::test_db_guard();
|
||||
let (codex_dir, id) = test_case("commit");
|
||||
let cfg = config_path(&codex_dir);
|
||||
let original = b"# keep exact bytes\nmodel = \"gpt-5.5\"\n\n[features]\njs_repl = false\n";
|
||||
fs::write(&cfg, original).expect("seed original config");
|
||||
save_managed_mcp(&id, "Managed", &json!({ "command": "managed" }), false)
|
||||
.expect("seed managed MCP");
|
||||
|
||||
toggle_codex_mcp_with_hooks(
|
||||
Some(codex_dir.display().to_string()),
|
||||
id.clone(),
|
||||
true,
|
||||
|_| Ok(()),
|
||||
|transaction| {
|
||||
transaction
|
||||
.execute_batch("ROLLBACK")
|
||||
.map_err(database_error)
|
||||
},
|
||||
)
|
||||
.expect_err("database commit failure must fail the toggle");
|
||||
|
||||
assert_eq!(fs::read(&cfg).expect("read rolled-back config"), original);
|
||||
let conn = open_db().expect("open test database");
|
||||
let (_, _, enabled) = managed_mcp_on_connection(&conn, &id)
|
||||
.expect("read managed MCP")
|
||||
.expect("managed MCP still exists");
|
||||
assert!(!enabled);
|
||||
drop(conn);
|
||||
assert!(codex_dir.join(".codexx-test-backups").is_dir());
|
||||
|
||||
remove_test_mcp(&id);
|
||||
fs::remove_dir_all(codex_dir).expect("remove MCP test directory");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ccswitch_import_rolls_back_file_database_and_ids_when_commit_fails() {
|
||||
let _db_guard = crate::app_db::test_db_guard();
|
||||
let (codex_dir, id) = test_case("import-commit");
|
||||
let second_id = format!("{id}-second");
|
||||
let cfg = config_path(&codex_dir);
|
||||
let original = b"# import baseline\nmodel = \"gpt-5.5\"\n";
|
||||
fs::write(&cfg, original).expect("seed original config");
|
||||
let mut imported_ids = HashSet::new();
|
||||
|
||||
import_ccswitch_mcp_candidates_with_hooks(
|
||||
&codex_dir,
|
||||
&mut imported_ids,
|
||||
vec![
|
||||
(
|
||||
id.clone(),
|
||||
"Imported".to_string(),
|
||||
json!({ "command": "imported" }),
|
||||
true,
|
||||
),
|
||||
(
|
||||
second_id.clone(),
|
||||
"Imported second".to_string(),
|
||||
json!({ "command": "imported-second" }),
|
||||
true,
|
||||
),
|
||||
],
|
||||
|_| Ok(()),
|
||||
|transaction| {
|
||||
transaction
|
||||
.execute_batch("ROLLBACK")
|
||||
.map_err(database_error)
|
||||
},
|
||||
)
|
||||
.expect_err("database commit failure must fail the import");
|
||||
|
||||
assert_eq!(fs::read(&cfg).expect("read rolled-back config"), original);
|
||||
assert!(imported_ids.is_empty());
|
||||
let conn = open_db().expect("open test database");
|
||||
assert!(managed_mcp_on_connection(&conn, &id)
|
||||
.expect("read imported MCP")
|
||||
.is_none());
|
||||
assert!(managed_mcp_on_connection(&conn, &second_id)
|
||||
.expect("read second imported MCP")
|
||||
.is_none());
|
||||
drop(conn);
|
||||
assert!(codex_dir.join(".codexx-test-backups").is_dir());
|
||||
|
||||
fs::remove_dir_all(codex_dir).expect("remove MCP test directory");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
pub(crate) struct BuiltinSkinAsset {
|
||||
pub(crate) id: &'static str,
|
||||
pub(crate) manifest: &'static str,
|
||||
pub(crate) image: &'static [u8],
|
||||
}
|
||||
|
||||
pub(crate) const BUILTIN_SKIN_ID: &str = "shiina-mashiro-blossom";
|
||||
|
||||
pub(crate) fn retired_builtin_skin_ids() -> &'static [&'static str] {
|
||||
&[
|
||||
"preset-midnight-aurora",
|
||||
"preset-sakura-dawn",
|
||||
"preset-amber-dusk",
|
||||
"preset-forest-mist",
|
||||
"preset-cyber-neon",
|
||||
]
|
||||
}
|
||||
|
||||
pub(crate) fn builtin_skin_assets() -> &'static [BuiltinSkinAsset] {
|
||||
&[]
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
use crate::error::{CodexxError, Result};
|
||||
use crate::file_io::{atomic_write, ensure_directory};
|
||||
use crate::skins::skins_root;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub(super) const RUNTIME_VERSION: &str = "1.2.2-codexx.3";
|
||||
pub(super) const UPSTREAM_COMMIT: &str = "5fd8af532efbaa87d2d0092297fd2d45cd56574e";
|
||||
|
||||
struct EmbeddedRuntimeAsset {
|
||||
relative_path: &'static str,
|
||||
bytes: &'static [u8],
|
||||
}
|
||||
|
||||
fn embedded_assets() -> &'static [EmbeddedRuntimeAsset] {
|
||||
&[
|
||||
EmbeddedRuntimeAsset {
|
||||
relative_path: "scripts/injector.mjs",
|
||||
bytes: include_bytes!("../../resources/skin-runtime/scripts/injector.mjs"),
|
||||
},
|
||||
EmbeddedRuntimeAsset {
|
||||
relative_path: "scripts/image-metadata.mjs",
|
||||
bytes: include_bytes!("../../resources/skin-runtime/scripts/image-metadata.mjs"),
|
||||
},
|
||||
EmbeddedRuntimeAsset {
|
||||
relative_path: "scripts/stage-theme.mjs",
|
||||
bytes: include_bytes!("../../resources/skin-runtime/scripts/stage-theme.mjs"),
|
||||
},
|
||||
EmbeddedRuntimeAsset {
|
||||
relative_path: "assets/dream-skin.css",
|
||||
bytes: include_bytes!("../../resources/skin-runtime/assets/dream-skin.css"),
|
||||
},
|
||||
EmbeddedRuntimeAsset {
|
||||
relative_path: "assets/renderer-inject.js",
|
||||
bytes: include_bytes!("../../resources/skin-runtime/assets/renderer-inject.js"),
|
||||
},
|
||||
EmbeddedRuntimeAsset {
|
||||
relative_path: "windows/codexx-windows.ps1",
|
||||
bytes: include_bytes!("../../resources/skin-runtime/windows/codexx-windows.ps1"),
|
||||
},
|
||||
EmbeddedRuntimeAsset {
|
||||
relative_path: "windows/common-windows.ps1",
|
||||
bytes: include_bytes!("../../resources/skin-runtime/windows/common-windows.ps1"),
|
||||
},
|
||||
EmbeddedRuntimeAsset {
|
||||
relative_path: "windows/config-utf8.ps1",
|
||||
bytes: include_bytes!("../../resources/skin-runtime/windows/config-utf8.ps1"),
|
||||
},
|
||||
EmbeddedRuntimeAsset {
|
||||
relative_path: "LICENSE",
|
||||
bytes: include_bytes!("../../resources/skin-runtime/LICENSE"),
|
||||
},
|
||||
EmbeddedRuntimeAsset {
|
||||
relative_path: "NOTICE.md",
|
||||
bytes: include_bytes!("../../resources/skin-runtime/NOTICE.md"),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
fn bytes_match(path: &Path, expected: &[u8]) -> bool {
|
||||
if !fs::symlink_metadata(path)
|
||||
.is_ok_and(|metadata| metadata.is_file() && !metadata.file_type().is_symlink())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let Ok(actual) = fs::read(path) else {
|
||||
return false;
|
||||
};
|
||||
Sha256::digest(actual) == Sha256::digest(expected)
|
||||
}
|
||||
|
||||
pub(super) fn ensure_runtime_directory(path: &Path) -> Result<()> {
|
||||
ensure_directory(path)?;
|
||||
let metadata =
|
||||
fs::symlink_metadata(path).map_err(|source| crate::file_io::io_err(path, source))?;
|
||||
if metadata.file_type().is_symlink() || !metadata.is_dir() {
|
||||
return Err(CodexxError::Config(format!(
|
||||
"皮肤运行时目录不能是符号链接或普通文件: {}",
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
set_private_permissions(path, true)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn set_private_permissions(path: &Path, directory: bool) -> Result<()> {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let mode = if directory { 0o700 } else { 0o600 };
|
||||
fs::set_permissions(path, fs::Permissions::from_mode(mode))
|
||||
.map_err(|source| crate::file_io::io_err(path, source))
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn set_private_permissions(_path: &Path, _directory: bool) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) struct RuntimeAssets {
|
||||
pub(super) injector: PathBuf,
|
||||
pub(super) stage_theme: PathBuf,
|
||||
#[cfg(any(target_os = "windows", feature = "windows-runtime-check"))]
|
||||
#[cfg_attr(feature = "windows-runtime-check", allow(dead_code))]
|
||||
pub(super) windows_adapter: PathBuf,
|
||||
}
|
||||
|
||||
pub(super) fn ensure_runtime_assets() -> Result<RuntimeAssets> {
|
||||
let base = skins_root()?.join("runtime");
|
||||
ensure_runtime_directory(&base)?;
|
||||
let root = base.join(RUNTIME_VERSION);
|
||||
ensure_runtime_directory(&root)?;
|
||||
for asset in embedded_assets() {
|
||||
let path = root.join(asset.relative_path);
|
||||
let parent = path.parent().ok_or_else(|| {
|
||||
CodexxError::Config(format!("内置皮肤资源路径无效: {}", path.display()))
|
||||
})?;
|
||||
ensure_runtime_directory(parent)?;
|
||||
if !bytes_match(&path, asset.bytes) {
|
||||
atomic_write(&path, asset.bytes)?;
|
||||
}
|
||||
set_private_permissions(&path, false)?;
|
||||
}
|
||||
Ok(RuntimeAssets {
|
||||
injector: root.join("scripts/injector.mjs"),
|
||||
stage_theme: root.join("scripts/stage-theme.mjs"),
|
||||
#[cfg(any(target_os = "windows", feature = "windows-runtime-check"))]
|
||||
windows_adapter: root.join("windows/codexx-windows.ps1"),
|
||||
})
|
||||
}
|
||||
@@ -1,786 +0,0 @@
|
||||
use super::assets::{
|
||||
ensure_runtime_assets, ensure_runtime_directory, RuntimeAssets, RUNTIME_VERSION,
|
||||
UPSTREAM_COMMIT,
|
||||
};
|
||||
use super::macos_injector::{
|
||||
launch_watcher, remove_live_skin, run_injector_once, stage_theme_snapshot,
|
||||
};
|
||||
use super::macos_system::{discover_codex_runtime, CodexRuntime};
|
||||
use super::{SkinRuntimeAction, SkinRuntimeStatus};
|
||||
use crate::error::{CodexxError, Result};
|
||||
use crate::file_io::{atomic_write, io_err};
|
||||
use crate::now_rfc3339;
|
||||
use crate::skins::skins_root;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs::{self, File, OpenOptions};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Stdio};
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
const DEFAULT_CDP_PORT: u16 = 9341;
|
||||
const MAX_CDP_PORT_OFFSET: u16 = 100;
|
||||
const RUNTIME_STATE_SCHEMA: u32 = 1;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct RuntimeStateFile {
|
||||
schema_version: u32,
|
||||
runtime_version: String,
|
||||
upstream_commit: String,
|
||||
session: String,
|
||||
port: u16,
|
||||
injector_pid: u32,
|
||||
injector_started_at: String,
|
||||
injector_path: String,
|
||||
node_path: String,
|
||||
node_version: String,
|
||||
codex_bundle: String,
|
||||
codex_executable: String,
|
||||
codex_version: String,
|
||||
codex_team_id: String,
|
||||
theme_id: String,
|
||||
theme_dir: String,
|
||||
created_at: String,
|
||||
updated_at: String,
|
||||
}
|
||||
|
||||
fn runtime_root() -> Result<PathBuf> {
|
||||
Ok(skins_root()?.join("runtime"))
|
||||
}
|
||||
|
||||
fn state_path() -> Result<PathBuf> {
|
||||
Ok(runtime_root()?.join("state.json"))
|
||||
}
|
||||
|
||||
pub(super) fn snapshots_root() -> Result<PathBuf> {
|
||||
Ok(runtime_root()?.join("themes"))
|
||||
}
|
||||
|
||||
fn logs_root() -> Result<PathBuf> {
|
||||
Ok(runtime_root()?.join("logs"))
|
||||
}
|
||||
|
||||
fn read_runtime_state() -> Result<Option<RuntimeStateFile>> {
|
||||
let path = state_path()?;
|
||||
let metadata = match fs::symlink_metadata(&path) {
|
||||
Ok(metadata) => metadata,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
|
||||
Err(error) => return Err(io_err(&path, error)),
|
||||
};
|
||||
if metadata.file_type().is_symlink() || !metadata.is_file() || metadata.len() > 256 * 1024 {
|
||||
return Err(CodexxError::Config(
|
||||
"皮肤运行状态文件不是安全的普通文件,已停止自动处理".to_string(),
|
||||
));
|
||||
}
|
||||
let bytes = fs::read(&path).map_err(|source| io_err(&path, source))?;
|
||||
let state: RuntimeStateFile = serde_json::from_slice(&bytes)
|
||||
.map_err(|error| CodexxError::Config(format!("解析皮肤运行状态失败: {error}")))?;
|
||||
if state.schema_version != RUNTIME_STATE_SCHEMA || !(1024..=65535).contains(&state.port) {
|
||||
return Err(CodexxError::Config(
|
||||
"皮肤运行状态版本或端口无效,已停止自动处理".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(Some(state))
|
||||
}
|
||||
|
||||
fn write_runtime_state(state: &RuntimeStateFile) -> Result<()> {
|
||||
let path = state_path()?;
|
||||
let text = serde_json::to_string_pretty(state)
|
||||
.map_err(|error| CodexxError::Config(format!("序列化皮肤运行状态失败: {error}")))?;
|
||||
atomic_write(&path, format!("{text}\n").as_bytes())?;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
fs::set_permissions(&path, fs::Permissions::from_mode(0o600))
|
||||
.map_err(|source| io_err(&path, source))
|
||||
}
|
||||
|
||||
fn remove_runtime_state() -> Result<()> {
|
||||
let path = state_path()?;
|
||||
match fs::remove_file(&path) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||
Err(error) => Err(io_err(&path, error)),
|
||||
}
|
||||
}
|
||||
|
||||
fn process_table() -> Vec<(u32, String)> {
|
||||
let Ok(output) = Command::new("/bin/ps")
|
||||
.args(["-axo", "pid=,command="])
|
||||
.output()
|
||||
else {
|
||||
return Vec::new();
|
||||
};
|
||||
String::from_utf8_lossy(&output.stdout)
|
||||
.lines()
|
||||
.filter_map(|line| {
|
||||
let trimmed = line.trim_start();
|
||||
let split = trimmed.find(char::is_whitespace)?;
|
||||
let pid = trimmed[..split].parse::<u32>().ok()?;
|
||||
Some((pid, trimmed[split..].trim_start().to_string()))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(super) fn process_command(pid: u32) -> Option<String> {
|
||||
let output = Command::new("/bin/ps")
|
||||
.args(["-p", &pid.to_string(), "-o", "command="])
|
||||
.output()
|
||||
.ok()?;
|
||||
let command = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
(!command.is_empty()).then_some(command)
|
||||
}
|
||||
|
||||
pub(super) fn process_started_at(pid: u32) -> Option<String> {
|
||||
let output = Command::new("/bin/ps")
|
||||
.args(["-p", &pid.to_string(), "-o", "lstart="])
|
||||
.output()
|
||||
.ok()?;
|
||||
let value = String::from_utf8_lossy(&output.stdout)
|
||||
.split_whitespace()
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
(!value.is_empty()).then_some(value)
|
||||
}
|
||||
|
||||
pub(super) fn process_alive(pid: u32) -> bool {
|
||||
pid > 0
|
||||
&& Command::new("/bin/kill")
|
||||
.args(["-0", &pid.to_string()])
|
||||
.status()
|
||||
.is_ok_and(|status| status.success())
|
||||
}
|
||||
|
||||
fn codex_main_pids(runtime: &CodexRuntime) -> Vec<u32> {
|
||||
let prefix = runtime.executable.to_string_lossy();
|
||||
process_table()
|
||||
.into_iter()
|
||||
.filter_map(|(pid, command)| command.starts_with(prefix.as_ref()).then_some(pid))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn codex_is_running(runtime: &CodexRuntime) -> bool {
|
||||
!codex_main_pids(runtime).is_empty()
|
||||
}
|
||||
|
||||
fn debug_port_from_command(command: &str) -> Option<u16> {
|
||||
command.split_whitespace().find_map(|argument| {
|
||||
argument
|
||||
.strip_prefix("--remote-debugging-port=")?
|
||||
.parse::<u16>()
|
||||
.ok()
|
||||
.filter(|port| *port >= 1024)
|
||||
})
|
||||
}
|
||||
|
||||
fn process_parent(pid: u32) -> Option<u32> {
|
||||
let output = Command::new("/bin/ps")
|
||||
.args(["-p", &pid.to_string(), "-o", "ppid="])
|
||||
.output()
|
||||
.ok()?;
|
||||
String::from_utf8_lossy(&output.stdout).trim().parse().ok()
|
||||
}
|
||||
|
||||
fn pid_is_codex_descendant(runtime: &CodexRuntime, mut pid: u32) -> bool {
|
||||
let executable = runtime.executable.to_string_lossy();
|
||||
for _ in 0..32 {
|
||||
if process_command(pid).is_some_and(|command| command.starts_with(executable.as_ref())) {
|
||||
return true;
|
||||
}
|
||||
let Some(parent) = process_parent(pid) else {
|
||||
return false;
|
||||
};
|
||||
if parent <= 1 || parent == pid {
|
||||
return false;
|
||||
}
|
||||
pid = parent;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn listener_pids(port: u16) -> Vec<u32> {
|
||||
let Ok(output) = Command::new("/usr/sbin/lsof")
|
||||
.args(["-nP", &format!("-iTCP:{port}"), "-sTCP:LISTEN", "-t"])
|
||||
.output()
|
||||
else {
|
||||
return Vec::new();
|
||||
};
|
||||
String::from_utf8_lossy(&output.stdout)
|
||||
.lines()
|
||||
.filter_map(|line| line.trim().parse().ok())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn port_belongs_to_codex(runtime: &CodexRuntime, port: u16) -> bool {
|
||||
let pids = listener_pids(port);
|
||||
!pids.is_empty()
|
||||
&& pids
|
||||
.into_iter()
|
||||
.all(|pid| pid_is_codex_descendant(runtime, pid))
|
||||
}
|
||||
|
||||
fn cdp_http_ready(port: u16) -> bool {
|
||||
let Ok(client) = reqwest::blocking::Client::builder()
|
||||
.no_proxy()
|
||||
.timeout(Duration::from_secs(2))
|
||||
.build()
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
client
|
||||
.get(format!("http://127.0.0.1:{port}/json/version"))
|
||||
.send()
|
||||
.is_ok_and(|response| response.status().is_success())
|
||||
}
|
||||
|
||||
fn verified_cdp_endpoint(runtime: &CodexRuntime, port: u16) -> bool {
|
||||
port_belongs_to_codex(runtime, port) && cdp_http_ready(port)
|
||||
}
|
||||
|
||||
fn running_cdp_port(runtime: &CodexRuntime) -> Option<u16> {
|
||||
codex_main_pids(runtime)
|
||||
.into_iter()
|
||||
.filter_map(process_command)
|
||||
.filter_map(|command| debug_port_from_command(&command))
|
||||
.find(|port| verified_cdp_endpoint(runtime, *port))
|
||||
}
|
||||
|
||||
fn select_available_port(preferred: u16) -> Result<u16> {
|
||||
let last = preferred.saturating_add(MAX_CDP_PORT_OFFSET);
|
||||
(preferred..=last)
|
||||
.find(|port| listener_pids(*port).is_empty())
|
||||
.ok_or_else(|| {
|
||||
CodexxError::Config(format!("未找到可用的本机 CDP 端口: {preferred}-{last}"))
|
||||
})
|
||||
}
|
||||
|
||||
fn wait_for_cdp(runtime: &CodexRuntime, port: u16, timeout: Duration) -> Result<()> {
|
||||
let deadline = Instant::now() + timeout;
|
||||
while Instant::now() < deadline {
|
||||
if verified_cdp_endpoint(runtime, port) {
|
||||
return Ok(());
|
||||
}
|
||||
thread::sleep(Duration::from_millis(350));
|
||||
}
|
||||
Err(CodexxError::Config(format!(
|
||||
"Codex 未在 127.0.0.1:{port} 打开经过验证的调试端口"
|
||||
)))
|
||||
}
|
||||
|
||||
fn external_skin_watcher_pid(own_state: Option<&RuntimeStateFile>) -> Option<u32> {
|
||||
process_table().into_iter().find_map(|(pid, command)| {
|
||||
let is_external = command.contains("codex-dream-skin-studio")
|
||||
&& command.contains("injector.mjs")
|
||||
&& command.contains("--watch");
|
||||
(is_external && own_state.is_none_or(|state| state.injector_pid != pid)).then_some(pid)
|
||||
})
|
||||
}
|
||||
|
||||
fn canonical_managed_snapshot(path: &Path) -> Option<PathBuf> {
|
||||
let metadata = fs::symlink_metadata(path).ok()?;
|
||||
if metadata.file_type().is_symlink() || !metadata.is_dir() {
|
||||
return None;
|
||||
}
|
||||
let root = fs::canonicalize(snapshots_root().ok()?).ok()?;
|
||||
let candidate = fs::canonicalize(path).ok()?;
|
||||
candidate.starts_with(&root).then_some(candidate)
|
||||
}
|
||||
|
||||
fn is_managed_injector(path: &Path) -> bool {
|
||||
let Ok(metadata) = fs::symlink_metadata(path) else {
|
||||
return false;
|
||||
};
|
||||
if metadata.file_type().is_symlink() || !metadata.is_file() {
|
||||
return false;
|
||||
}
|
||||
let Ok(root) = runtime_root()
|
||||
.and_then(|path| fs::canonicalize(&path).map_err(|source| io_err(&path, source)))
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
fs::canonicalize(path).is_ok_and(|candidate| {
|
||||
candidate.starts_with(root)
|
||||
&& candidate.file_name().and_then(|name| name.to_str()) == Some("injector.mjs")
|
||||
})
|
||||
}
|
||||
|
||||
fn state_process_matches(state: &RuntimeStateFile, runtime: &CodexRuntime) -> bool {
|
||||
if state.injector_pid == 0
|
||||
|| state.node_path != runtime.node.to_string_lossy()
|
||||
|| !is_managed_injector(Path::new(&state.injector_path))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if canonical_managed_snapshot(Path::new(&state.theme_dir)).is_none() {
|
||||
return false;
|
||||
}
|
||||
let Some(command) = process_command(state.injector_pid) else {
|
||||
return false;
|
||||
};
|
||||
let expected = format!(
|
||||
"{} --watch --port {} --theme-dir {}",
|
||||
state.injector_path, state.port, state.theme_dir
|
||||
);
|
||||
command.starts_with(runtime.node.to_string_lossy().as_ref())
|
||||
&& command.contains(&expected)
|
||||
&& process_started_at(state.injector_pid).as_deref()
|
||||
== Some(state.injector_started_at.as_str())
|
||||
}
|
||||
|
||||
fn stop_recorded_injector(state: &RuntimeStateFile, runtime: &CodexRuntime) -> Result<()> {
|
||||
if state.injector_pid == 0 || !process_alive(state.injector_pid) {
|
||||
return Ok(());
|
||||
}
|
||||
if !state_process_matches(state, runtime) {
|
||||
return Err(CodexxError::Config(format!(
|
||||
"记录的皮肤进程 PID {} 身份不匹配,已拒绝终止",
|
||||
state.injector_pid
|
||||
)));
|
||||
}
|
||||
let pid = state.injector_pid.to_string();
|
||||
let _ = Command::new("/bin/kill").args(["-TERM", &pid]).status();
|
||||
let deadline = Instant::now() + Duration::from_secs(6);
|
||||
while Instant::now() < deadline && state_process_matches(state, runtime) {
|
||||
thread::sleep(Duration::from_millis(150));
|
||||
}
|
||||
if state_process_matches(state, runtime) {
|
||||
let _ = Command::new("/bin/kill").args(["-KILL", &pid]).status();
|
||||
}
|
||||
let deadline = Instant::now() + Duration::from_secs(2);
|
||||
while Instant::now() < deadline && state_process_matches(state, runtime) {
|
||||
thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
if state_process_matches(state, runtime) {
|
||||
return Err(CodexxError::Config(format!(
|
||||
"无法停止皮肤注入器 PID {}",
|
||||
state.injector_pid
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn stop_codex(runtime: &CodexRuntime) -> Result<()> {
|
||||
let _ = Command::new("/usr/bin/osascript")
|
||||
.args(["-e", "tell application id \"com.openai.codex\" to quit"])
|
||||
.status();
|
||||
let deadline = Instant::now() + Duration::from_secs(15);
|
||||
while Instant::now() < deadline && codex_is_running(runtime) {
|
||||
thread::sleep(Duration::from_millis(250));
|
||||
}
|
||||
if !codex_is_running(runtime) {
|
||||
return Ok(());
|
||||
}
|
||||
for pid in codex_main_pids(runtime) {
|
||||
let _ = Command::new("/bin/kill")
|
||||
.args(["-TERM", &pid.to_string()])
|
||||
.status();
|
||||
}
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
while Instant::now() < deadline && codex_is_running(runtime) {
|
||||
thread::sleep(Duration::from_millis(250));
|
||||
}
|
||||
if codex_is_running(runtime) {
|
||||
for pid in codex_main_pids(runtime) {
|
||||
let _ = Command::new("/bin/kill")
|
||||
.args(["-KILL", &pid.to_string()])
|
||||
.status();
|
||||
}
|
||||
thread::sleep(Duration::from_millis(500));
|
||||
}
|
||||
if codex_is_running(runtime) {
|
||||
Err(CodexxError::Config(
|
||||
"无法安全关闭 Codex,换肤操作已中止".to_string(),
|
||||
))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn log_files(prefix: &str) -> Result<(File, File)> {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let root = logs_root()?;
|
||||
ensure_runtime_directory(&root)?;
|
||||
let stdout_path = root.join(format!("{prefix}.log"));
|
||||
let stderr_path = root.join(format!("{prefix}-error.log"));
|
||||
let stdout = OpenOptions::new()
|
||||
.create(true)
|
||||
.write(true)
|
||||
.truncate(true)
|
||||
.open(&stdout_path)
|
||||
.map_err(|source| io_err(&stdout_path, source))?;
|
||||
let stderr = OpenOptions::new()
|
||||
.create(true)
|
||||
.write(true)
|
||||
.truncate(true)
|
||||
.open(&stderr_path)
|
||||
.map_err(|source| io_err(&stderr_path, source))?;
|
||||
fs::set_permissions(&stdout_path, fs::Permissions::from_mode(0o600))
|
||||
.map_err(|source| io_err(&stdout_path, source))?;
|
||||
fs::set_permissions(&stderr_path, fs::Permissions::from_mode(0o600))
|
||||
.map_err(|source| io_err(&stderr_path, source))?;
|
||||
Ok((stdout, stderr))
|
||||
}
|
||||
|
||||
fn launch_codex_with_cdp(runtime: &CodexRuntime, port: u16) -> Result<()> {
|
||||
let (stdout, stderr) = log_files("codex-launch")?;
|
||||
let status = Command::new("/usr/bin/open")
|
||||
.arg("-na")
|
||||
.arg(&runtime.bundle)
|
||||
.arg("--args")
|
||||
.arg("--remote-debugging-address=127.0.0.1")
|
||||
.arg(format!("--remote-debugging-port={port}"))
|
||||
.stdout(Stdio::from(stdout))
|
||||
.stderr(Stdio::from(stderr))
|
||||
.status()
|
||||
.map_err(|source| io_err(&runtime.bundle, source))?;
|
||||
if !status.success() {
|
||||
return Err(CodexxError::Config(
|
||||
"使用本机调试端口启动 Codex 失败".to_string(),
|
||||
));
|
||||
}
|
||||
if wait_for_cdp(runtime, port, Duration::from_secs(12)).is_ok() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if codex_is_running(runtime) {
|
||||
stop_codex(runtime)?;
|
||||
}
|
||||
let (stdout, stderr) = log_files("codex-launch-direct")?;
|
||||
let mut child = Command::new(&runtime.executable)
|
||||
.arg("--remote-debugging-address=127.0.0.1")
|
||||
.arg(format!("--remote-debugging-port={port}"))
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::from(stdout))
|
||||
.stderr(Stdio::from(stderr))
|
||||
.spawn()
|
||||
.map_err(|source| io_err(&runtime.executable, source))?;
|
||||
thread::Builder::new()
|
||||
.name("codex-x-codex-launch-reaper".to_string())
|
||||
.spawn(move || {
|
||||
let _ = child.wait();
|
||||
})
|
||||
.map_err(|error| CodexxError::Config(format!("启动 Codex 进程监控失败: {error}")))?;
|
||||
wait_for_cdp(runtime, port, Duration::from_secs(45))
|
||||
}
|
||||
|
||||
fn launch_codex_normally(runtime: &CodexRuntime) -> Result<()> {
|
||||
let status = Command::new("/usr/bin/open")
|
||||
.arg("-na")
|
||||
.arg(&runtime.bundle)
|
||||
.status()
|
||||
.map_err(|source| io_err(&runtime.bundle, source))?;
|
||||
if status.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(CodexxError::Config(
|
||||
"恢复官方外观后重新启动 Codex 失败".to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn active_state(
|
||||
runtime: &CodexRuntime,
|
||||
assets: &RuntimeAssets,
|
||||
port: u16,
|
||||
theme_id: &str,
|
||||
theme_dir: &Path,
|
||||
pid: u32,
|
||||
started_at: String,
|
||||
) -> RuntimeStateFile {
|
||||
let timestamp = now_rfc3339();
|
||||
RuntimeStateFile {
|
||||
schema_version: RUNTIME_STATE_SCHEMA,
|
||||
runtime_version: RUNTIME_VERSION.to_string(),
|
||||
upstream_commit: UPSTREAM_COMMIT.to_string(),
|
||||
session: "active".to_string(),
|
||||
port,
|
||||
injector_pid: pid,
|
||||
injector_started_at: started_at,
|
||||
injector_path: assets.injector.to_string_lossy().to_string(),
|
||||
node_path: runtime.node.to_string_lossy().to_string(),
|
||||
node_version: runtime.node_version.clone(),
|
||||
codex_bundle: runtime.bundle.to_string_lossy().to_string(),
|
||||
codex_executable: runtime.executable.to_string_lossy().to_string(),
|
||||
codex_version: runtime.version.clone(),
|
||||
codex_team_id: runtime.team_id.clone(),
|
||||
theme_id: theme_id.to_string(),
|
||||
theme_dir: theme_dir.to_string_lossy().to_string(),
|
||||
created_at: timestamp.clone(),
|
||||
updated_at: timestamp,
|
||||
}
|
||||
}
|
||||
|
||||
fn cleanup_snapshot(path: &str, keep: &Path) {
|
||||
let Some(path) = canonical_managed_snapshot(Path::new(path)) else {
|
||||
return;
|
||||
};
|
||||
let keep = canonical_managed_snapshot(keep);
|
||||
if keep.as_ref() != Some(&path) {
|
||||
let _ = fs::remove_dir_all(&path);
|
||||
}
|
||||
}
|
||||
|
||||
fn restore_previous_after_failure(
|
||||
runtime: &CodexRuntime,
|
||||
assets: &RuntimeAssets,
|
||||
previous: Option<&RuntimeStateFile>,
|
||||
) {
|
||||
let Some(previous) = previous else {
|
||||
return;
|
||||
};
|
||||
let theme_dir = Path::new(&previous.theme_dir);
|
||||
if previous.session != "active"
|
||||
|| !theme_dir.is_dir()
|
||||
|| !verified_cdp_endpoint(runtime, previous.port)
|
||||
|| run_injector_once(runtime, assets, previous.port, theme_dir).is_err()
|
||||
{
|
||||
return;
|
||||
}
|
||||
if let Ok((pid, started_at)) = launch_watcher(runtime, assets, previous.port, theme_dir) {
|
||||
let restored = active_state(
|
||||
runtime,
|
||||
assets,
|
||||
previous.port,
|
||||
&previous.theme_id,
|
||||
theme_dir,
|
||||
pid,
|
||||
started_at,
|
||||
);
|
||||
let _ = write_runtime_state(&restored);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn apply_theme(
|
||||
source: &Path,
|
||||
theme_id: &str,
|
||||
restart_existing: bool,
|
||||
) -> Result<SkinRuntimeAction> {
|
||||
let runtime = discover_codex_runtime()?;
|
||||
let assets = ensure_runtime_assets()?;
|
||||
let previous = read_runtime_state()?;
|
||||
if let Some(pid) = external_skin_watcher_pid(previous.as_ref()) {
|
||||
return Err(CodexxError::Config(format!(
|
||||
"检测到外部 Codex Dream Skin 注入器 PID {pid},请先在原工具中暂停或恢复官方外观"
|
||||
)));
|
||||
}
|
||||
|
||||
let running = codex_is_running(&runtime);
|
||||
let verified_port = previous
|
||||
.as_ref()
|
||||
.map(|state| state.port)
|
||||
.filter(|port| verified_cdp_endpoint(&runtime, *port))
|
||||
.or_else(|| running_cdp_port(&runtime));
|
||||
if running && verified_port.is_none() && !restart_existing {
|
||||
return Ok(SkinRuntimeAction::RestartRequired(
|
||||
"Codex 需要重启一次才能打开仅限本机的换肤调试端口".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let snapshot = stage_theme_snapshot(&runtime, &assets, source, theme_id)?;
|
||||
let apply_result = (|| -> Result<RuntimeStateFile> {
|
||||
if let Some(state) = previous.as_ref() {
|
||||
stop_recorded_injector(state, &runtime)?;
|
||||
}
|
||||
let port = if let Some(port) = verified_port {
|
||||
port
|
||||
} else {
|
||||
if running {
|
||||
stop_codex(&runtime)?;
|
||||
}
|
||||
let preferred = previous
|
||||
.as_ref()
|
||||
.map_or(DEFAULT_CDP_PORT, |state| state.port);
|
||||
let port = select_available_port(preferred)?;
|
||||
launch_codex_with_cdp(&runtime, port)?;
|
||||
port
|
||||
};
|
||||
run_injector_once(&runtime, &assets, port, &snapshot)?;
|
||||
let (pid, started_at) = launch_watcher(&runtime, &assets, port, &snapshot)?;
|
||||
let state = active_state(
|
||||
&runtime, &assets, port, theme_id, &snapshot, pid, started_at,
|
||||
);
|
||||
if let Err(error) = write_runtime_state(&state) {
|
||||
let _ = stop_recorded_injector(&state, &runtime);
|
||||
return Err(error);
|
||||
}
|
||||
Ok(state)
|
||||
})();
|
||||
|
||||
match apply_result {
|
||||
Ok(_) => {
|
||||
if let Some(previous) = previous.as_ref() {
|
||||
cleanup_snapshot(&previous.theme_dir, &snapshot);
|
||||
}
|
||||
Ok(SkinRuntimeAction::Applied("已应用 Codex 皮肤".to_string()))
|
||||
}
|
||||
Err(error) => {
|
||||
let _ = fs::remove_dir_all(&snapshot);
|
||||
restore_previous_after_failure(&runtime, &assets, previous.as_ref());
|
||||
Err(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn pause_theme() -> Result<SkinRuntimeAction> {
|
||||
let assets = ensure_runtime_assets()?;
|
||||
let Some(mut state) = read_runtime_state()? else {
|
||||
return Ok(SkinRuntimeAction::Paused(
|
||||
"Codex 皮肤当前没有运行".to_string(),
|
||||
));
|
||||
};
|
||||
let runtime = discover_codex_runtime()?;
|
||||
if let Some(pid) = external_skin_watcher_pid(Some(&state)) {
|
||||
return Err(CodexxError::Config(format!(
|
||||
"检测到外部 Codex Dream Skin 注入器 PID {pid},未处理该进程"
|
||||
)));
|
||||
}
|
||||
stop_recorded_injector(&state, &runtime)?;
|
||||
if verified_cdp_endpoint(&runtime, state.port) {
|
||||
if let Err(error) =
|
||||
remove_live_skin(&runtime, &assets, state.port, Path::new(&state.theme_dir))
|
||||
{
|
||||
restore_previous_after_failure(&runtime, &assets, Some(&state));
|
||||
return Err(error);
|
||||
}
|
||||
}
|
||||
state.session = "paused".to_string();
|
||||
state.injector_pid = 0;
|
||||
state.injector_started_at.clear();
|
||||
state.updated_at = now_rfc3339();
|
||||
write_runtime_state(&state)?;
|
||||
Ok(SkinRuntimeAction::Paused(
|
||||
"已关闭 Codex 皮肤;Codex 保持打开".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
pub(super) fn restore_official(restart_existing: bool) -> Result<SkinRuntimeAction> {
|
||||
let assets = ensure_runtime_assets()?;
|
||||
let Some(state) = read_runtime_state()? else {
|
||||
return Ok(SkinRuntimeAction::Restored(
|
||||
"Codex 当前已使用官方外观".to_string(),
|
||||
));
|
||||
};
|
||||
let runtime = discover_codex_runtime()?;
|
||||
if let Some(pid) = external_skin_watcher_pid(Some(&state)) {
|
||||
return Err(CodexxError::Config(format!(
|
||||
"检测到外部 Codex Dream Skin 注入器 PID {pid},未处理该进程"
|
||||
)));
|
||||
}
|
||||
let running = codex_is_running(&runtime);
|
||||
let debug_ready = verified_cdp_endpoint(&runtime, state.port);
|
||||
if running && (debug_ready || state.session == "active") && !restart_existing {
|
||||
return Ok(SkinRuntimeAction::RestartRequired(
|
||||
"完整恢复需要重启 Codex,以关闭本机调试端口并清除当前渲染状态".to_string(),
|
||||
));
|
||||
}
|
||||
stop_recorded_injector(&state, &runtime)?;
|
||||
if debug_ready {
|
||||
remove_live_skin(&runtime, &assets, state.port, Path::new(&state.theme_dir))?;
|
||||
}
|
||||
if running && (debug_ready || state.session == "active") {
|
||||
stop_codex(&runtime)?;
|
||||
launch_codex_normally(&runtime)?;
|
||||
}
|
||||
remove_runtime_state()?;
|
||||
cleanup_snapshot(&state.theme_dir, Path::new(""));
|
||||
Ok(SkinRuntimeAction::Restored(
|
||||
"已恢复 Codex 官方外观并关闭 Codex-X 管理的换肤运行时".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
pub(super) fn runtime_status() -> SkinRuntimeStatus {
|
||||
if let Err(error) = ensure_runtime_assets() {
|
||||
return SkinRuntimeStatus {
|
||||
supported: true,
|
||||
active: false,
|
||||
phase: "error".to_string(),
|
||||
port: None,
|
||||
theme_id: None,
|
||||
message: error.to_string(),
|
||||
};
|
||||
}
|
||||
let state = match read_runtime_state() {
|
||||
Ok(Some(state)) => state,
|
||||
Ok(None) => {
|
||||
return SkinRuntimeStatus {
|
||||
supported: true,
|
||||
active: false,
|
||||
phase: "inactive".to_string(),
|
||||
port: None,
|
||||
theme_id: None,
|
||||
message: "尚未应用 Codex 皮肤".to_string(),
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
return SkinRuntimeStatus {
|
||||
supported: true,
|
||||
active: false,
|
||||
phase: "error".to_string(),
|
||||
port: None,
|
||||
theme_id: None,
|
||||
message: error.to_string(),
|
||||
}
|
||||
}
|
||||
};
|
||||
if state.session == "paused" {
|
||||
return SkinRuntimeStatus {
|
||||
supported: true,
|
||||
active: false,
|
||||
phase: "paused".to_string(),
|
||||
port: Some(state.port),
|
||||
theme_id: Some(state.theme_id),
|
||||
message: "皮肤已暂停,Codex 保持打开".to_string(),
|
||||
};
|
||||
}
|
||||
let runtime = match discover_codex_runtime() {
|
||||
Ok(runtime) => runtime,
|
||||
Err(error) => {
|
||||
return SkinRuntimeStatus {
|
||||
supported: true,
|
||||
active: false,
|
||||
phase: "unavailable".to_string(),
|
||||
port: Some(state.port),
|
||||
theme_id: Some(state.theme_id),
|
||||
message: error.to_string(),
|
||||
}
|
||||
}
|
||||
};
|
||||
let active =
|
||||
state_process_matches(&state, &runtime) && verified_cdp_endpoint(&runtime, state.port);
|
||||
SkinRuntimeStatus {
|
||||
supported: true,
|
||||
active,
|
||||
phase: if active { "active" } else { "stale" }.to_string(),
|
||||
port: Some(state.port),
|
||||
theme_id: Some(state.theme_id),
|
||||
message: if active {
|
||||
"Codex 皮肤运行中".to_string()
|
||||
} else {
|
||||
"皮肤运行状态已失效,可重新应用或恢复官方外观".to_string()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn debug_port_parser_accepts_only_bounded_numeric_arguments() {
|
||||
assert_eq!(
|
||||
debug_port_from_command(
|
||||
"/Applications/ChatGPT.app/Contents/MacOS/ChatGPT --remote-debugging-port=9341"
|
||||
),
|
||||
Some(9341)
|
||||
);
|
||||
assert_eq!(
|
||||
debug_port_from_command("Codex --remote-debugging-port=80"),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
debug_port_from_command("Codex --remote-debugging-port=not-a-port"),
|
||||
None
|
||||
);
|
||||
assert_eq!(debug_port_from_command("Codex"), None);
|
||||
}
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
use super::assets::RuntimeAssets;
|
||||
use super::macos::{log_files, process_alive, process_started_at, snapshots_root};
|
||||
use super::macos_system::CodexRuntime;
|
||||
use super::node_runtime;
|
||||
use crate::error::{CodexxError, Result};
|
||||
use crate::file_io::io_err;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Stdio};
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
pub(super) fn stage_theme_snapshot(
|
||||
runtime: &CodexRuntime,
|
||||
assets: &RuntimeAssets,
|
||||
source: &Path,
|
||||
theme_id: &str,
|
||||
) -> Result<PathBuf> {
|
||||
node_runtime::stage_theme_snapshot(&runtime.node, assets, &snapshots_root()?, source, theme_id)
|
||||
}
|
||||
|
||||
pub(super) fn run_injector_once(
|
||||
runtime: &CodexRuntime,
|
||||
assets: &RuntimeAssets,
|
||||
port: u16,
|
||||
theme_dir: &Path,
|
||||
) -> Result<()> {
|
||||
node_runtime::run_injector_once(&runtime.node, assets, port, theme_dir)
|
||||
}
|
||||
|
||||
pub(super) fn remove_live_skin(
|
||||
runtime: &CodexRuntime,
|
||||
assets: &RuntimeAssets,
|
||||
port: u16,
|
||||
theme_dir: &Path,
|
||||
) -> Result<()> {
|
||||
node_runtime::remove_live_skin(&runtime.node, assets, port, theme_dir)
|
||||
}
|
||||
|
||||
pub(super) fn launch_watcher(
|
||||
runtime: &CodexRuntime,
|
||||
assets: &RuntimeAssets,
|
||||
port: u16,
|
||||
theme_dir: &Path,
|
||||
) -> Result<(u32, String)> {
|
||||
let (stdout, stderr) = log_files("injector")?;
|
||||
let mut child = Command::new(&runtime.node)
|
||||
.arg(&assets.injector)
|
||||
.args(["--watch", "--port"])
|
||||
.arg(port.to_string())
|
||||
.arg("--theme-dir")
|
||||
.arg(theme_dir)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::from(stdout))
|
||||
.stderr(Stdio::from(stderr))
|
||||
.spawn()
|
||||
.map_err(|source| io_err(&assets.injector, source))?;
|
||||
let pid = child.id();
|
||||
thread::Builder::new()
|
||||
.name("codex-x-skin-injector-reaper".to_string())
|
||||
.spawn(move || {
|
||||
let _ = child.wait();
|
||||
})
|
||||
.map_err(|error| CodexxError::Config(format!("启动皮肤进程监控失败: {error}")))?;
|
||||
thread::sleep(Duration::from_millis(180));
|
||||
if !process_alive(pid) {
|
||||
return Err(CodexxError::Config(
|
||||
"皮肤注入器启动后立即退出,请检查运行日志".to_string(),
|
||||
));
|
||||
}
|
||||
let started_at = process_started_at(pid)
|
||||
.ok_or_else(|| CodexxError::Config("无法记录皮肤注入器启动时间".to_string()))?;
|
||||
Ok((pid, started_at))
|
||||
}
|
||||
@@ -1,199 +0,0 @@
|
||||
use super::node_runtime::command_detail;
|
||||
use crate::error::{CodexxError, Result};
|
||||
use crate::file_io::io_err;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
const EXPECTED_BUNDLE_ID: &str = "com.openai.codex";
|
||||
const EXPECTED_TEAM_ID: &str = "2DC432GLL2";
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(super) struct CodexRuntime {
|
||||
pub(super) bundle: PathBuf,
|
||||
pub(super) executable: PathBuf,
|
||||
pub(super) version: String,
|
||||
pub(super) node: PathBuf,
|
||||
pub(super) node_version: String,
|
||||
pub(super) team_id: String,
|
||||
}
|
||||
|
||||
fn output_text(program: &Path, args: &[&str], label: &str) -> Result<String> {
|
||||
let output = Command::new(program)
|
||||
.args(args)
|
||||
.output()
|
||||
.map_err(|source| io_err(program, source))?;
|
||||
if !output.status.success() {
|
||||
let detail = command_detail(&output);
|
||||
return Err(CodexxError::Config(if detail.is_empty() {
|
||||
format!("{label}失败")
|
||||
} else {
|
||||
format!("{label}失败: {detail}")
|
||||
}));
|
||||
}
|
||||
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
|
||||
}
|
||||
|
||||
fn plist_value(bundle: &Path, key: &str) -> Result<String> {
|
||||
let plist = bundle.join("Contents/Info.plist");
|
||||
output_text(
|
||||
Path::new("/usr/bin/plutil"),
|
||||
&["-extract", key, "raw", "-o", "-", &plist.to_string_lossy()],
|
||||
"读取 Codex 应用信息",
|
||||
)
|
||||
}
|
||||
|
||||
fn bundle_candidates() -> Result<Vec<PathBuf>> {
|
||||
let home = crate::paths::home_dir()?;
|
||||
let mut candidates = vec![
|
||||
PathBuf::from("/Applications/ChatGPT.app"),
|
||||
home.join("Applications/ChatGPT.app"),
|
||||
PathBuf::from("/Applications/Codex.app"),
|
||||
home.join("Applications/Codex.app"),
|
||||
];
|
||||
if let Ok(output) = Command::new("/usr/bin/mdfind")
|
||||
.arg(format!(
|
||||
"kMDItemCFBundleIdentifier == \"{EXPECTED_BUNDLE_ID}\""
|
||||
))
|
||||
.output()
|
||||
{
|
||||
if output.status.success() {
|
||||
candidates.extend(
|
||||
String::from_utf8_lossy(&output.stdout)
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.filter(|line| !line.is_empty())
|
||||
.map(PathBuf::from),
|
||||
);
|
||||
}
|
||||
}
|
||||
candidates.dedup();
|
||||
Ok(candidates)
|
||||
}
|
||||
|
||||
fn codesign_team_id(path: &Path) -> Result<String> {
|
||||
let output = Command::new("/usr/bin/codesign")
|
||||
.args(["-dv", "--verbose=4"])
|
||||
.arg(path)
|
||||
.output()
|
||||
.map_err(|source| io_err(path, source))?;
|
||||
let combined = format!(
|
||||
"{}\n{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
combined
|
||||
.lines()
|
||||
.find_map(|line| line.trim().strip_prefix("TeamIdentifier="))
|
||||
.map(str::to_string)
|
||||
.ok_or_else(|| CodexxError::Config("无法读取 Codex 签名 Team ID".to_string()))
|
||||
}
|
||||
|
||||
fn verify_signature(path: &Path, deep: bool) -> Result<()> {
|
||||
let mut command = Command::new("/usr/bin/codesign");
|
||||
command.arg("--verify");
|
||||
if deep {
|
||||
command.arg("--deep");
|
||||
}
|
||||
let output = command
|
||||
.arg("--strict")
|
||||
.arg(path)
|
||||
.output()
|
||||
.map_err(|source| io_err(path, source))?;
|
||||
if output.status.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(CodexxError::Config(format!(
|
||||
"官方 Codex 代码签名校验失败: {}",
|
||||
path.display()
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn discover_codex_runtime() -> Result<CodexRuntime> {
|
||||
let mut bundle = None;
|
||||
for candidate in bundle_candidates()? {
|
||||
if !candidate.join("Contents/Info.plist").is_file() {
|
||||
continue;
|
||||
}
|
||||
if plist_value(&candidate, "CFBundleIdentifier")
|
||||
.ok()
|
||||
.as_deref()
|
||||
== Some(EXPECTED_BUNDLE_ID)
|
||||
{
|
||||
bundle = Some(candidate);
|
||||
break;
|
||||
}
|
||||
}
|
||||
let bundle = bundle.ok_or_else(|| {
|
||||
CodexxError::Config("未找到官方 Codex Desktop (com.openai.codex)".to_string())
|
||||
})?;
|
||||
let executable_name = plist_value(&bundle, "CFBundleExecutable")?;
|
||||
if executable_name.is_empty() || executable_name.contains('/') || executable_name.contains('\\')
|
||||
{
|
||||
return Err(CodexxError::Config(
|
||||
"Codex 应用声明了无效的可执行文件名".to_string(),
|
||||
));
|
||||
}
|
||||
let executable = bundle.join("Contents/MacOS").join(executable_name);
|
||||
let node = bundle.join("Contents/Resources/cua_node/bin/node");
|
||||
if !executable.is_file() || !node.is_file() {
|
||||
return Err(CodexxError::Config(
|
||||
"Codex 可执行文件或内置 Node.js 不存在,请更新或重新安装官方 Codex".to_string(),
|
||||
));
|
||||
}
|
||||
verify_signature(&bundle, true)?;
|
||||
verify_signature(&node, false)?;
|
||||
let team_id = codesign_team_id(&bundle)?;
|
||||
let node_team_id = codesign_team_id(&node)?;
|
||||
if team_id != EXPECTED_TEAM_ID || node_team_id != team_id {
|
||||
return Err(CodexxError::Config(format!(
|
||||
"Codex 签名身份不匹配,拒绝启动换肤运行时: {team_id}"
|
||||
)));
|
||||
}
|
||||
let node_version = output_text(&node, &["--version"], "读取 Codex Node.js 版本")?;
|
||||
let node_major = node_version
|
||||
.trim_start_matches('v')
|
||||
.split('.')
|
||||
.next()
|
||||
.and_then(|value| value.parse::<u32>().ok())
|
||||
.ok_or_else(|| CodexxError::Config(format!("无法解析 Node.js 版本: {node_version}")))?;
|
||||
if node_major < 20 {
|
||||
return Err(CodexxError::Config(format!(
|
||||
"Codex 内置 Node.js 版本过低: {node_version},需要 20 或更高版本"
|
||||
)));
|
||||
}
|
||||
let machine_arch = output_text(Path::new("/usr/bin/uname"), &["-m"], "读取系统架构")?;
|
||||
let node_file = output_text(
|
||||
Path::new("/usr/bin/file"),
|
||||
&[&node.to_string_lossy()],
|
||||
"读取 Codex Node.js 架构",
|
||||
)?;
|
||||
if !node_file.contains(&machine_arch) {
|
||||
return Err(CodexxError::Config(format!(
|
||||
"Codex 内置 Node.js 与当前 Mac 架构不匹配: {machine_arch}"
|
||||
)));
|
||||
}
|
||||
Ok(CodexRuntime {
|
||||
version: plist_value(&bundle, "CFBundleShortVersionString")?,
|
||||
bundle,
|
||||
executable,
|
||||
node,
|
||||
node_version,
|
||||
team_id,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn command_detail_is_bounded() {
|
||||
let output = Command::new("/usr/bin/printf")
|
||||
.arg("%0900d")
|
||||
.arg("1")
|
||||
.output()
|
||||
.expect("create fixture output");
|
||||
assert!(command_detail(&output).chars().count() <= 800);
|
||||
}
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
|
||||
use crate::error::CodexxError;
|
||||
use crate::error::Result;
|
||||
use serde::Serialize;
|
||||
use std::path::Path;
|
||||
|
||||
mod assets;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod macos;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod macos_injector;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod macos_system;
|
||||
mod node_runtime;
|
||||
#[cfg(any(target_os = "windows", feature = "windows-runtime-check"))]
|
||||
#[cfg_attr(feature = "windows-runtime-check", allow(dead_code))]
|
||||
mod windows;
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct SkinRuntimeStatus {
|
||||
pub(crate) supported: bool,
|
||||
pub(crate) active: bool,
|
||||
pub(crate) phase: String,
|
||||
pub(crate) port: Option<u16>,
|
||||
pub(crate) theme_id: Option<String>,
|
||||
pub(crate) message: String,
|
||||
}
|
||||
|
||||
impl SkinRuntimeStatus {
|
||||
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
|
||||
fn unsupported() -> Self {
|
||||
Self {
|
||||
supported: false,
|
||||
active: false,
|
||||
phase: "unsupported".to_string(),
|
||||
port: None,
|
||||
theme_id: None,
|
||||
message: "当前版本的 Codex 实机换肤仅支持 macOS 和 Windows".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) enum SkinRuntimeAction {
|
||||
Applied(String),
|
||||
RestartRequired(String),
|
||||
Paused(String),
|
||||
Restored(String),
|
||||
}
|
||||
|
||||
pub(crate) fn skin_runtime_status() -> SkinRuntimeStatus {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
macos::runtime_status()
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
windows::runtime_status()
|
||||
}
|
||||
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
|
||||
{
|
||||
SkinRuntimeStatus::unsupported()
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn apply_skin_runtime(
|
||||
theme_dir: &Path,
|
||||
theme_id: &str,
|
||||
restart_existing: bool,
|
||||
) -> Result<SkinRuntimeAction> {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
macos::apply_theme(theme_dir, theme_id, restart_existing)
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
windows::apply_theme(theme_dir, theme_id, restart_existing)
|
||||
}
|
||||
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
|
||||
{
|
||||
let _ = (theme_dir, theme_id, restart_existing);
|
||||
Err(CodexxError::Config(
|
||||
"当前版本的 Codex 实机换肤仅支持 macOS 和 Windows".to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn pause_skin_runtime() -> Result<SkinRuntimeAction> {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
macos::pause_theme()
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
windows::pause_theme()
|
||||
}
|
||||
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
|
||||
{
|
||||
Err(CodexxError::Config(
|
||||
"当前版本的 Codex 实机换肤仅支持 macOS 和 Windows".to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn restore_skin_runtime(restart_existing: bool) -> Result<SkinRuntimeAction> {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
macos::restore_official(restart_existing)
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
windows::restore_official(restart_existing)
|
||||
}
|
||||
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
|
||||
{
|
||||
let _ = restart_existing;
|
||||
Err(CodexxError::Config(
|
||||
"当前版本的 Codex 实机换肤仅支持 macOS 和 Windows".to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -1,234 +0,0 @@
|
||||
use super::assets::{ensure_runtime_directory, RuntimeAssets};
|
||||
use crate::error::{CodexxError, Result};
|
||||
use crate::file_io::io_err;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Stdio};
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
const COMMAND_POLL_INTERVAL: Duration = Duration::from_millis(25);
|
||||
const THEME_STAGE_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
const PAYLOAD_CHECK_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
const INJECTOR_ONCE_TIMEOUT: Duration = Duration::from_secs(35);
|
||||
const INJECTOR_REMOVE_TIMEOUT: Duration = Duration::from_secs(20);
|
||||
|
||||
pub(super) fn command_detail(output: &std::process::Output) -> String {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
let detail = if stderr.is_empty() { stdout } else { stderr };
|
||||
if detail.chars().count() <= 800 {
|
||||
detail
|
||||
} else {
|
||||
detail
|
||||
.chars()
|
||||
.rev()
|
||||
.take(800)
|
||||
.collect::<String>()
|
||||
.chars()
|
||||
.rev()
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn wait_for_output(
|
||||
mut child: std::process::Child,
|
||||
program: &Path,
|
||||
label: &str,
|
||||
timeout: Duration,
|
||||
) -> Result<std::process::Output> {
|
||||
let deadline = Instant::now() + timeout;
|
||||
loop {
|
||||
match child.try_wait().map_err(|source| io_err(program, source))? {
|
||||
Some(_) => {
|
||||
return child
|
||||
.wait_with_output()
|
||||
.map_err(|source| io_err(program, source))
|
||||
}
|
||||
None if Instant::now() >= deadline => {
|
||||
let _ = child.kill();
|
||||
let output = child
|
||||
.wait_with_output()
|
||||
.map_err(|source| io_err(program, source))?;
|
||||
let detail = command_detail(&output);
|
||||
let suffix = if detail.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(": {detail}")
|
||||
};
|
||||
return Err(CodexxError::Config(format!(
|
||||
"{label}超时({} 秒){suffix}",
|
||||
timeout.as_secs()
|
||||
)));
|
||||
}
|
||||
None => thread::sleep(COMMAND_POLL_INTERVAL),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn run_node(
|
||||
node: &Path,
|
||||
script: &Path,
|
||||
args: &[String],
|
||||
label: &str,
|
||||
timeout: Duration,
|
||||
) -> Result<String> {
|
||||
let child = Command::new(node)
|
||||
.arg(script)
|
||||
.args(args)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|source| io_err(script, source))?;
|
||||
let output = wait_for_output(child, script, label, timeout)?;
|
||||
if output.status.success() {
|
||||
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
|
||||
} else {
|
||||
let detail = command_detail(&output);
|
||||
Err(CodexxError::Config(if detail.is_empty() {
|
||||
format!("{label}失败")
|
||||
} else {
|
||||
format!("{label}失败: {detail}")
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn stage_theme_snapshot(
|
||||
node: &Path,
|
||||
assets: &RuntimeAssets,
|
||||
snapshots_root: &Path,
|
||||
source: &Path,
|
||||
theme_id: &str,
|
||||
) -> Result<PathBuf> {
|
||||
ensure_runtime_directory(snapshots_root)?;
|
||||
let snapshot = snapshots_root.join(format!(
|
||||
"{}-{}-{}",
|
||||
theme_id,
|
||||
std::process::id(),
|
||||
chrono::Local::now()
|
||||
.timestamp_nanos_opt()
|
||||
.unwrap_or_default()
|
||||
));
|
||||
ensure_runtime_directory(&snapshot)?;
|
||||
let result = (|| -> Result<()> {
|
||||
run_node(
|
||||
node,
|
||||
&assets.stage_theme,
|
||||
&[
|
||||
source.to_string_lossy().to_string(),
|
||||
snapshot.to_string_lossy().to_string(),
|
||||
],
|
||||
"暂存主题",
|
||||
THEME_STAGE_TIMEOUT,
|
||||
)?;
|
||||
run_node(
|
||||
node,
|
||||
&assets.injector,
|
||||
&[
|
||||
"--check-payload".to_string(),
|
||||
"--theme-dir".to_string(),
|
||||
snapshot.to_string_lossy().to_string(),
|
||||
],
|
||||
"校验主题注入负载",
|
||||
PAYLOAD_CHECK_TIMEOUT,
|
||||
)?;
|
||||
Ok(())
|
||||
})();
|
||||
if result.is_err() {
|
||||
let _ = fs::remove_dir_all(&snapshot);
|
||||
}
|
||||
result.map(|_| snapshot)
|
||||
}
|
||||
|
||||
pub(super) fn run_injector_once(
|
||||
node: &Path,
|
||||
assets: &RuntimeAssets,
|
||||
port: u16,
|
||||
theme_dir: &Path,
|
||||
) -> Result<()> {
|
||||
run_node(
|
||||
node,
|
||||
&assets.injector,
|
||||
&[
|
||||
"--once".to_string(),
|
||||
"--port".to_string(),
|
||||
port.to_string(),
|
||||
"--theme-dir".to_string(),
|
||||
theme_dir.to_string_lossy().to_string(),
|
||||
"--timeout-ms".to_string(),
|
||||
"20000".to_string(),
|
||||
],
|
||||
"注入并验证 Codex 皮肤",
|
||||
INJECTOR_ONCE_TIMEOUT,
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn remove_live_skin(
|
||||
node: &Path,
|
||||
assets: &RuntimeAssets,
|
||||
port: u16,
|
||||
theme_dir: &Path,
|
||||
) -> Result<()> {
|
||||
run_node(
|
||||
node,
|
||||
&assets.injector,
|
||||
&[
|
||||
"--remove".to_string(),
|
||||
"--port".to_string(),
|
||||
port.to_string(),
|
||||
"--theme-dir".to_string(),
|
||||
theme_dir.to_string_lossy().to_string(),
|
||||
"--timeout-ms".to_string(),
|
||||
"10000".to_string(),
|
||||
],
|
||||
"移除 Codex 皮肤",
|
||||
INJECTOR_REMOVE_TIMEOUT,
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn wait_for_output_collects_a_completed_child() {
|
||||
let child = Command::new("/bin/echo")
|
||||
.arg("skin-ready")
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.expect("spawn echo fixture");
|
||||
let output = wait_for_output(
|
||||
child,
|
||||
Path::new("/bin/echo"),
|
||||
"测试皮肤命令",
|
||||
Duration::from_secs(1),
|
||||
)
|
||||
.expect("collect output");
|
||||
assert!(output.status.success());
|
||||
assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "skin-ready");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wait_for_output_terminates_a_stuck_child() {
|
||||
let child = Command::new("/bin/sleep")
|
||||
.arg("5")
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.expect("spawn sleep fixture");
|
||||
let started = Instant::now();
|
||||
let error = wait_for_output(
|
||||
child,
|
||||
Path::new("/bin/sleep"),
|
||||
"测试皮肤命令",
|
||||
Duration::from_millis(80),
|
||||
)
|
||||
.expect_err("timeout stuck child");
|
||||
assert!(error.to_string().contains("超时"));
|
||||
assert!(started.elapsed() < Duration::from_secs(2));
|
||||
}
|
||||
}
|
||||
@@ -1,869 +0,0 @@
|
||||
use super::assets::{ensure_runtime_assets, ensure_runtime_directory, RuntimeAssets};
|
||||
use super::node_runtime;
|
||||
use super::{SkinRuntimeAction, SkinRuntimeStatus};
|
||||
use crate::error::{CodexxError, Result};
|
||||
use crate::file_io::{atomic_write, io_err};
|
||||
use crate::now_rfc3339;
|
||||
use crate::skins::skins_root;
|
||||
use serde::{de::DeserializeOwned, Deserialize, Serialize};
|
||||
use std::env;
|
||||
use std::fs::{self, File, OpenOptions};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Stdio};
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
use std::os::windows::process::CommandExt;
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
|
||||
const DEFAULT_CDP_PORT: u16 = 9341;
|
||||
const RUNTIME_STATE_SCHEMA: u32 = 1;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct RuntimeStateFile {
|
||||
schema_version: u32,
|
||||
platform: String,
|
||||
session: String,
|
||||
port: u16,
|
||||
browser_id: String,
|
||||
injector_pid: u32,
|
||||
injector_started_at: String,
|
||||
injector_path: String,
|
||||
node_path: String,
|
||||
node_version: String,
|
||||
codex_package_root: String,
|
||||
codex_executable: String,
|
||||
codex_version: String,
|
||||
codex_package_full_name: String,
|
||||
codex_package_family_name: String,
|
||||
codex_app_user_model_id: String,
|
||||
theme_id: String,
|
||||
theme_dir: String,
|
||||
created_at: String,
|
||||
updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct CodexRuntime {
|
||||
package_root: PathBuf,
|
||||
executable: PathBuf,
|
||||
version: String,
|
||||
package_full_name: String,
|
||||
package_family_name: String,
|
||||
app_user_model_id: String,
|
||||
running: bool,
|
||||
debug_port: Option<u16>,
|
||||
browser_id: Option<String>,
|
||||
watcher_pids: Vec<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct PortResult {
|
||||
port: u16,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct LaunchResult {
|
||||
port: u16,
|
||||
browser_id: String,
|
||||
strategy: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct VerifyPortResult {
|
||||
verified: bool,
|
||||
browser_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ProcessInfo {
|
||||
alive: bool,
|
||||
path: Option<String>,
|
||||
command_line: Option<String>,
|
||||
started_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ActiveResult {
|
||||
active: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct NodeRuntime {
|
||||
path: PathBuf,
|
||||
version: String,
|
||||
}
|
||||
|
||||
struct ActiveStateContext<'a> {
|
||||
runtime: &'a CodexRuntime,
|
||||
node: &'a NodeRuntime,
|
||||
assets: &'a RuntimeAssets,
|
||||
port: u16,
|
||||
browser_id: String,
|
||||
theme_id: &'a str,
|
||||
theme_dir: &'a Path,
|
||||
injector_pid: u32,
|
||||
injector_started_at: String,
|
||||
}
|
||||
|
||||
fn runtime_root() -> Result<PathBuf> {
|
||||
Ok(skins_root()?.join("runtime"))
|
||||
}
|
||||
|
||||
fn state_path() -> Result<PathBuf> {
|
||||
Ok(runtime_root()?.join("state.json"))
|
||||
}
|
||||
|
||||
fn snapshots_root() -> Result<PathBuf> {
|
||||
Ok(runtime_root()?.join("themes"))
|
||||
}
|
||||
|
||||
fn logs_root() -> Result<PathBuf> {
|
||||
Ok(runtime_root()?.join("logs"))
|
||||
}
|
||||
|
||||
fn powershell_path() -> PathBuf {
|
||||
env::var_os("SystemRoot")
|
||||
.map(PathBuf::from)
|
||||
.map(|root| {
|
||||
root.join("System32")
|
||||
.join("WindowsPowerShell")
|
||||
.join("v1.0")
|
||||
.join("powershell.exe")
|
||||
})
|
||||
.filter(|path| path.is_file())
|
||||
.unwrap_or_else(|| PathBuf::from("powershell.exe"))
|
||||
}
|
||||
|
||||
fn configure_background_command(command: &mut Command) {
|
||||
#[cfg(target_os = "windows")]
|
||||
command.creation_flags(CREATE_NO_WINDOW);
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
let _ = command;
|
||||
}
|
||||
|
||||
fn parse_last_json<T: DeserializeOwned>(stdout: &[u8], label: &str) -> Result<T> {
|
||||
let text = String::from_utf8_lossy(stdout);
|
||||
text.lines()
|
||||
.rev()
|
||||
.map(str::trim)
|
||||
.filter(|line| !line.is_empty())
|
||||
.find_map(|line| serde_json::from_str(line).ok())
|
||||
.ok_or_else(|| CodexxError::Config(format!("{label}没有返回有效结果")))
|
||||
}
|
||||
|
||||
fn run_adapter<T: DeserializeOwned>(
|
||||
assets: &RuntimeAssets,
|
||||
action: &str,
|
||||
args: &[String],
|
||||
timeout: Duration,
|
||||
) -> Result<T> {
|
||||
let powershell = powershell_path();
|
||||
let mut command = Command::new(&powershell);
|
||||
command
|
||||
.args(["-NoLogo", "-NoProfile", "-NonInteractive", "-File"])
|
||||
.arg(&assets.windows_adapter)
|
||||
.args(["-Action", action])
|
||||
.args(args)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
configure_background_command(&mut command);
|
||||
let child = command
|
||||
.spawn()
|
||||
.map_err(|source| io_err(&powershell, source))?;
|
||||
let output = node_runtime::wait_for_output(child, &powershell, action, timeout)?;
|
||||
if !output.status.success() {
|
||||
let detail = node_runtime::command_detail(&output);
|
||||
return Err(CodexxError::Config(if detail.is_empty() {
|
||||
format!("Windows 皮肤操作失败: {action}")
|
||||
} else {
|
||||
format!("Windows 皮肤操作失败: {detail}")
|
||||
}));
|
||||
}
|
||||
parse_last_json(&output.stdout, action)
|
||||
}
|
||||
|
||||
fn inspect_codex(assets: &RuntimeAssets) -> Result<CodexRuntime> {
|
||||
run_adapter(assets, "inspect", &[], Duration::from_secs(15))
|
||||
}
|
||||
|
||||
fn select_port(assets: &RuntimeAssets, preferred: u16) -> Result<u16> {
|
||||
let result: PortResult = run_adapter(
|
||||
assets,
|
||||
"selectPort",
|
||||
&["-Port".to_string(), preferred.to_string()],
|
||||
Duration::from_secs(10),
|
||||
)?;
|
||||
Ok(result.port)
|
||||
}
|
||||
|
||||
fn verify_port(assets: &RuntimeAssets, port: u16) -> Result<VerifyPortResult> {
|
||||
run_adapter(
|
||||
assets,
|
||||
"verifyPort",
|
||||
&["-Port".to_string(), port.to_string()],
|
||||
Duration::from_secs(10),
|
||||
)
|
||||
}
|
||||
|
||||
fn stop_codex(assets: &RuntimeAssets) -> Result<()> {
|
||||
let _: serde_json::Value = run_adapter(assets, "stopCodex", &[], Duration::from_secs(25))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn launch_codex_normally(assets: &RuntimeAssets) -> Result<()> {
|
||||
let _: serde_json::Value = run_adapter(assets, "launchNormal", &[], Duration::from_secs(15))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn launch_codex_with_cdp(assets: &RuntimeAssets, port: u16) -> Result<LaunchResult> {
|
||||
let result: LaunchResult = run_adapter(
|
||||
assets,
|
||||
"launch",
|
||||
&["-Port".to_string(), port.to_string()],
|
||||
Duration::from_secs(60),
|
||||
)?;
|
||||
if result.port != port || result.browser_id.is_empty() {
|
||||
return Err(CodexxError::Config(
|
||||
"Windows Codex 启动器返回了无效的调试端口身份".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn read_runtime_state() -> Result<Option<RuntimeStateFile>> {
|
||||
let path = state_path()?;
|
||||
let metadata = match fs::symlink_metadata(&path) {
|
||||
Ok(metadata) => metadata,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
|
||||
Err(error) => return Err(io_err(&path, error)),
|
||||
};
|
||||
if metadata.file_type().is_symlink() || !metadata.is_file() || metadata.len() > 256 * 1024 {
|
||||
return Err(CodexxError::Config(
|
||||
"皮肤运行状态文件不是安全的普通文件,已停止自动处理".to_string(),
|
||||
));
|
||||
}
|
||||
let bytes = fs::read(&path).map_err(|source| io_err(&path, source))?;
|
||||
let state: RuntimeStateFile = serde_json::from_slice(&bytes)
|
||||
.map_err(|error| CodexxError::Config(format!("解析皮肤运行状态失败: {error}")))?;
|
||||
if state.schema_version != RUNTIME_STATE_SCHEMA
|
||||
|| state.platform != "windows"
|
||||
|| !(1024..=65535).contains(&state.port)
|
||||
{
|
||||
return Err(CodexxError::Config(
|
||||
"皮肤运行状态版本或端口无效,已停止自动处理".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(Some(state))
|
||||
}
|
||||
|
||||
fn write_runtime_state(state: &RuntimeStateFile) -> Result<()> {
|
||||
let path = state_path()?;
|
||||
let text = serde_json::to_string_pretty(state)
|
||||
.map_err(|error| CodexxError::Config(format!("序列化皮肤运行状态失败: {error}")))?;
|
||||
atomic_write(&path, format!("{text}\n").as_bytes())
|
||||
}
|
||||
|
||||
fn remove_runtime_state() -> Result<()> {
|
||||
let path = state_path()?;
|
||||
match fs::remove_file(&path) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||
Err(error) => Err(io_err(&path, error)),
|
||||
}
|
||||
}
|
||||
|
||||
fn node_version(path: &Path) -> Result<String> {
|
||||
let mut command = Command::new(path);
|
||||
command.arg("--version");
|
||||
configure_background_command(&mut command);
|
||||
let output = command.output().map_err(|source| io_err(path, source))?;
|
||||
if !output.status.success() {
|
||||
return Err(CodexxError::Config(format!(
|
||||
"无法运行 Windows 皮肤 Node.js: {}",
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
let version = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
let major = version
|
||||
.trim_start_matches('v')
|
||||
.split('.')
|
||||
.next()
|
||||
.and_then(|value| value.parse::<u32>().ok())
|
||||
.ok_or_else(|| CodexxError::Config(format!("无法解析 Node.js 版本: {version}")))?;
|
||||
if major < 22 {
|
||||
return Err(CodexxError::Config(format!(
|
||||
"Windows 皮肤运行时需要 Node.js 22 或更高版本,当前为 {version}"
|
||||
)));
|
||||
}
|
||||
Ok(version)
|
||||
}
|
||||
|
||||
fn resolve_node_candidate(path: &Path) -> Option<PathBuf> {
|
||||
if let Ok(canonical) = fs::canonicalize(path) {
|
||||
return Some(canonical);
|
||||
}
|
||||
if path.components().count() != 1 {
|
||||
return None;
|
||||
}
|
||||
let mut command = Command::new("where.exe");
|
||||
command.arg(path);
|
||||
configure_background_command(&mut command);
|
||||
let output = command.output().ok()?;
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
String::from_utf8_lossy(&output.stdout)
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.filter(|line| !line.is_empty())
|
||||
.map(PathBuf::from)
|
||||
.find_map(|candidate| fs::canonicalize(candidate).ok())
|
||||
}
|
||||
|
||||
fn discover_node_runtime() -> Result<NodeRuntime> {
|
||||
let mut candidates = Vec::new();
|
||||
if let Some(path) = env::var_os("CODEX_X_SKIN_NODE") {
|
||||
candidates.push(PathBuf::from(path));
|
||||
}
|
||||
if let Ok(executable) = env::current_exe() {
|
||||
if let Some(parent) = executable.parent() {
|
||||
candidates.push(parent.join("skin-runtime").join("node").join("node.exe"));
|
||||
}
|
||||
}
|
||||
candidates.push(PathBuf::from("node.exe"));
|
||||
for candidate in candidates {
|
||||
if let Some(path) = resolve_node_candidate(&candidate) {
|
||||
if let Ok(version) = node_version(&path) {
|
||||
return Ok(NodeRuntime { path, version });
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(CodexxError::Config(
|
||||
"Windows 皮肤运行时缺少内置 Node.js;请重新安装 Codex-X".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
fn canonical_managed_snapshot(path: &Path) -> Option<PathBuf> {
|
||||
let metadata = fs::symlink_metadata(path).ok()?;
|
||||
if metadata.file_type().is_symlink() || !metadata.is_dir() {
|
||||
return None;
|
||||
}
|
||||
let root = fs::canonicalize(snapshots_root().ok()?).ok()?;
|
||||
let candidate = fs::canonicalize(path).ok()?;
|
||||
candidate.starts_with(&root).then_some(candidate)
|
||||
}
|
||||
|
||||
fn cleanup_snapshot(path: &str, keep: &Path) {
|
||||
let Some(path) = canonical_managed_snapshot(Path::new(path)) else {
|
||||
return;
|
||||
};
|
||||
let keep = canonical_managed_snapshot(keep);
|
||||
if keep.as_ref() != Some(&path) {
|
||||
let _ = fs::remove_dir_all(path);
|
||||
}
|
||||
}
|
||||
|
||||
fn process_info(assets: &RuntimeAssets, pid: u32) -> Result<ProcessInfo> {
|
||||
run_adapter(
|
||||
assets,
|
||||
"processInfo",
|
||||
&["-TargetPid".to_string(), pid.to_string()],
|
||||
Duration::from_secs(10),
|
||||
)
|
||||
}
|
||||
|
||||
fn injector_active(assets: &RuntimeAssets) -> Result<bool> {
|
||||
let result: ActiveResult = run_adapter(
|
||||
assets,
|
||||
"injectorStatus",
|
||||
&[
|
||||
"-StatePath".to_string(),
|
||||
state_path()?.to_string_lossy().to_string(),
|
||||
],
|
||||
Duration::from_secs(10),
|
||||
)?;
|
||||
Ok(result.active)
|
||||
}
|
||||
|
||||
fn write_adapter_state(state: &RuntimeStateFile) -> Result<PathBuf> {
|
||||
let root = runtime_root()?;
|
||||
ensure_runtime_directory(&root)?;
|
||||
let path = root.join(format!(
|
||||
".process-state-{}-{}.json",
|
||||
std::process::id(),
|
||||
state.injector_pid
|
||||
));
|
||||
let text = serde_json::to_string(state)
|
||||
.map_err(|error| CodexxError::Config(format!("序列化皮肤进程状态失败: {error}")))?;
|
||||
atomic_write(&path, text.as_bytes())?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
fn stop_recorded_injector(assets: &RuntimeAssets, state: &RuntimeStateFile) -> Result<()> {
|
||||
if state.injector_pid == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
let info = process_info(assets, state.injector_pid)?;
|
||||
if !info.alive {
|
||||
return Ok(());
|
||||
}
|
||||
let adapter_state = write_adapter_state(state)?;
|
||||
let result = run_adapter::<serde_json::Value>(
|
||||
assets,
|
||||
"stopInjector",
|
||||
&[
|
||||
"-StatePath".to_string(),
|
||||
adapter_state.to_string_lossy().to_string(),
|
||||
],
|
||||
Duration::from_secs(15),
|
||||
);
|
||||
let _ = fs::remove_file(&adapter_state);
|
||||
result.map(|_| ())
|
||||
}
|
||||
|
||||
fn log_files(prefix: &str) -> Result<(File, File)> {
|
||||
let root = logs_root()?;
|
||||
ensure_runtime_directory(&root)?;
|
||||
let stdout_path = root.join(format!("{prefix}.log"));
|
||||
let stderr_path = root.join(format!("{prefix}-error.log"));
|
||||
let stdout = OpenOptions::new()
|
||||
.create(true)
|
||||
.write(true)
|
||||
.truncate(true)
|
||||
.open(&stdout_path)
|
||||
.map_err(|source| io_err(&stdout_path, source))?;
|
||||
let stderr = OpenOptions::new()
|
||||
.create(true)
|
||||
.write(true)
|
||||
.truncate(true)
|
||||
.open(&stderr_path)
|
||||
.map_err(|source| io_err(&stderr_path, source))?;
|
||||
Ok((stdout, stderr))
|
||||
}
|
||||
|
||||
fn launch_watcher(
|
||||
node: &NodeRuntime,
|
||||
assets: &RuntimeAssets,
|
||||
port: u16,
|
||||
theme_dir: &Path,
|
||||
) -> Result<(u32, String)> {
|
||||
let (stdout, stderr) = log_files("injector")?;
|
||||
let mut command = Command::new(&node.path);
|
||||
command
|
||||
.arg(&assets.injector)
|
||||
.args(["--watch", "--port"])
|
||||
.arg(port.to_string())
|
||||
.arg("--theme-dir")
|
||||
.arg(theme_dir)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::from(stdout))
|
||||
.stderr(Stdio::from(stderr));
|
||||
configure_background_command(&mut command);
|
||||
let mut child = command
|
||||
.spawn()
|
||||
.map_err(|source| io_err(&assets.injector, source))?;
|
||||
let pid = child.id();
|
||||
thread::sleep(Duration::from_millis(250));
|
||||
if child
|
||||
.try_wait()
|
||||
.map_err(|source| io_err(&assets.injector, source))?
|
||||
.is_some()
|
||||
{
|
||||
return Err(CodexxError::Config(
|
||||
"皮肤注入器启动后立即退出,请检查运行日志".to_string(),
|
||||
));
|
||||
}
|
||||
let info = process_info(assets, pid)?;
|
||||
let started_at = info
|
||||
.started_at
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| CodexxError::Config("无法记录皮肤注入器启动时间".to_string()))?;
|
||||
let expected_node = node.path.to_string_lossy();
|
||||
let path_matches = info.path.as_deref().is_some_and(|path| {
|
||||
Path::new(path)
|
||||
.canonicalize()
|
||||
.is_ok_and(|actual| actual == node.path)
|
||||
});
|
||||
let command_matches = info.command_line.as_deref().is_some_and(|command| {
|
||||
command.contains(assets.injector.to_string_lossy().as_ref())
|
||||
&& command.contains("--watch")
|
||||
&& command.contains(&format!("--port {port}"))
|
||||
});
|
||||
if !info.alive
|
||||
|| (!path_matches
|
||||
&& !expected_node.eq_ignore_ascii_case(info.path.as_deref().unwrap_or("")))
|
||||
|| !command_matches
|
||||
{
|
||||
let _ = child.kill();
|
||||
return Err(CodexxError::Config(
|
||||
"Windows 皮肤注入器进程身份校验失败".to_string(),
|
||||
));
|
||||
}
|
||||
drop(child);
|
||||
Ok((pid, started_at))
|
||||
}
|
||||
|
||||
fn active_state(context: ActiveStateContext<'_>) -> RuntimeStateFile {
|
||||
let timestamp = now_rfc3339();
|
||||
RuntimeStateFile {
|
||||
schema_version: RUNTIME_STATE_SCHEMA,
|
||||
platform: "windows".to_string(),
|
||||
session: "active".to_string(),
|
||||
port: context.port,
|
||||
browser_id: context.browser_id,
|
||||
injector_pid: context.injector_pid,
|
||||
injector_started_at: context.injector_started_at,
|
||||
injector_path: context.assets.injector.to_string_lossy().to_string(),
|
||||
node_path: context.node.path.to_string_lossy().to_string(),
|
||||
node_version: context.node.version.clone(),
|
||||
codex_package_root: context.runtime.package_root.to_string_lossy().to_string(),
|
||||
codex_executable: context.runtime.executable.to_string_lossy().to_string(),
|
||||
codex_version: context.runtime.version.clone(),
|
||||
codex_package_full_name: context.runtime.package_full_name.clone(),
|
||||
codex_package_family_name: context.runtime.package_family_name.clone(),
|
||||
codex_app_user_model_id: context.runtime.app_user_model_id.clone(),
|
||||
theme_id: context.theme_id.to_string(),
|
||||
theme_dir: context.theme_dir.to_string_lossy().to_string(),
|
||||
created_at: timestamp.clone(),
|
||||
updated_at: timestamp,
|
||||
}
|
||||
}
|
||||
|
||||
fn restore_previous_after_failure(
|
||||
runtime: &CodexRuntime,
|
||||
node: &NodeRuntime,
|
||||
assets: &RuntimeAssets,
|
||||
previous: Option<&RuntimeStateFile>,
|
||||
) {
|
||||
let Some(previous) = previous else { return };
|
||||
let theme_dir = Path::new(&previous.theme_dir);
|
||||
let verified = verify_port(assets, previous.port).is_ok_and(|result| result.verified);
|
||||
if previous.session != "active"
|
||||
|| !theme_dir.is_dir()
|
||||
|| !verified
|
||||
|| node_runtime::run_injector_once(&node.path, assets, previous.port, theme_dir).is_err()
|
||||
{
|
||||
return;
|
||||
}
|
||||
if let Ok((pid, started_at)) = launch_watcher(node, assets, previous.port, theme_dir) {
|
||||
let restored = active_state(ActiveStateContext {
|
||||
runtime,
|
||||
node,
|
||||
assets,
|
||||
port: previous.port,
|
||||
browser_id: previous.browser_id.clone(),
|
||||
theme_id: &previous.theme_id,
|
||||
theme_dir,
|
||||
injector_pid: pid,
|
||||
injector_started_at: started_at,
|
||||
});
|
||||
let _ = write_runtime_state(&restored);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn apply_theme(
|
||||
source: &Path,
|
||||
theme_id: &str,
|
||||
restart_existing: bool,
|
||||
) -> Result<SkinRuntimeAction> {
|
||||
let assets = ensure_runtime_assets()?;
|
||||
let runtime = inspect_codex(&assets)?;
|
||||
let node = discover_node_runtime()?;
|
||||
let previous = read_runtime_state()?;
|
||||
if let Some(pid) = runtime.watcher_pids.iter().copied().find(|pid| {
|
||||
previous
|
||||
.as_ref()
|
||||
.is_none_or(|state| state.injector_pid != *pid)
|
||||
}) {
|
||||
return Err(CodexxError::Config(format!(
|
||||
"检测到外部 Codex 换肤注入器 PID {pid},请先在原工具中关闭皮肤"
|
||||
)));
|
||||
}
|
||||
if runtime.running && runtime.debug_port.is_none() && !restart_existing {
|
||||
return Ok(SkinRuntimeAction::RestartRequired(
|
||||
"Codex 需要重启一次才能打开仅限本机的换肤调试端口".to_string(),
|
||||
));
|
||||
}
|
||||
let snapshot = node_runtime::stage_theme_snapshot(
|
||||
&node.path,
|
||||
&assets,
|
||||
&snapshots_root()?,
|
||||
source,
|
||||
theme_id,
|
||||
)?;
|
||||
let mut launched_new = false;
|
||||
let apply_result = (|| -> Result<RuntimeStateFile> {
|
||||
if let Some(state) = previous.as_ref() {
|
||||
stop_recorded_injector(&assets, state)?;
|
||||
}
|
||||
let (port, browser_id) = if let Some(port) = runtime.debug_port {
|
||||
let browser_id = runtime.browser_id.clone().ok_or_else(|| {
|
||||
CodexxError::Config("无法验证当前 Windows Codex 调试会话".to_string())
|
||||
})?;
|
||||
(port, browser_id)
|
||||
} else {
|
||||
if runtime.running {
|
||||
stop_codex(&assets)?;
|
||||
}
|
||||
let preferred = previous
|
||||
.as_ref()
|
||||
.map_or(DEFAULT_CDP_PORT, |state| state.port);
|
||||
let port = select_port(&assets, preferred)?;
|
||||
let launch = launch_codex_with_cdp(&assets, port)?;
|
||||
launched_new = true;
|
||||
let _strategy = launch.strategy;
|
||||
(port, launch.browser_id)
|
||||
};
|
||||
node_runtime::run_injector_once(&node.path, &assets, port, &snapshot)?;
|
||||
let (pid, started_at) = launch_watcher(&node, &assets, port, &snapshot)?;
|
||||
let state = active_state(ActiveStateContext {
|
||||
runtime: &runtime,
|
||||
node: &node,
|
||||
assets: &assets,
|
||||
port,
|
||||
browser_id,
|
||||
theme_id,
|
||||
theme_dir: &snapshot,
|
||||
injector_pid: pid,
|
||||
injector_started_at: started_at,
|
||||
});
|
||||
if let Err(error) = write_runtime_state(&state) {
|
||||
let _ = stop_recorded_injector(&assets, &state);
|
||||
return Err(error);
|
||||
}
|
||||
Ok(state)
|
||||
})();
|
||||
match apply_result {
|
||||
Ok(_) => {
|
||||
if let Some(previous) = previous.as_ref() {
|
||||
cleanup_snapshot(&previous.theme_dir, &snapshot);
|
||||
}
|
||||
Ok(SkinRuntimeAction::Applied(
|
||||
"已应用 Windows Codex 皮肤".to_string(),
|
||||
))
|
||||
}
|
||||
Err(error) => {
|
||||
let _ = fs::remove_dir_all(&snapshot);
|
||||
restore_previous_after_failure(&runtime, &node, &assets, previous.as_ref());
|
||||
if launched_new && previous.is_none() {
|
||||
let _ = stop_codex(&assets);
|
||||
let _ = launch_codex_normally(&assets);
|
||||
}
|
||||
Err(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn pause_theme() -> Result<SkinRuntimeAction> {
|
||||
let assets = ensure_runtime_assets()?;
|
||||
let Some(mut state) = read_runtime_state()? else {
|
||||
return Ok(SkinRuntimeAction::Paused(
|
||||
"Codex 皮肤当前没有运行".to_string(),
|
||||
));
|
||||
};
|
||||
let node = discover_node_runtime()?;
|
||||
stop_recorded_injector(&assets, &state)?;
|
||||
if verify_port(&assets, state.port).is_ok_and(|result| result.verified) {
|
||||
node_runtime::remove_live_skin(
|
||||
&node.path,
|
||||
&assets,
|
||||
state.port,
|
||||
Path::new(&state.theme_dir),
|
||||
)?;
|
||||
}
|
||||
state.session = "paused".to_string();
|
||||
state.injector_pid = 0;
|
||||
state.injector_started_at.clear();
|
||||
state.updated_at = now_rfc3339();
|
||||
write_runtime_state(&state)?;
|
||||
Ok(SkinRuntimeAction::Paused(
|
||||
"已关闭 Codex 皮肤;Codex 保持打开".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
pub(super) fn restore_official(restart_existing: bool) -> Result<SkinRuntimeAction> {
|
||||
let assets = ensure_runtime_assets()?;
|
||||
let Some(state) = read_runtime_state()? else {
|
||||
return Ok(SkinRuntimeAction::Restored(
|
||||
"Codex 当前已使用官方外观".to_string(),
|
||||
));
|
||||
};
|
||||
let node = discover_node_runtime()?;
|
||||
let runtime = inspect_codex(&assets)?;
|
||||
let debug_ready = verify_port(&assets, state.port).is_ok_and(|result| result.verified);
|
||||
if runtime.running && (debug_ready || state.session == "active") && !restart_existing {
|
||||
return Ok(SkinRuntimeAction::RestartRequired(
|
||||
"完整恢复需要重启 Codex,以关闭本机调试端口并清除当前渲染状态".to_string(),
|
||||
));
|
||||
}
|
||||
stop_recorded_injector(&assets, &state)?;
|
||||
if debug_ready {
|
||||
node_runtime::remove_live_skin(
|
||||
&node.path,
|
||||
&assets,
|
||||
state.port,
|
||||
Path::new(&state.theme_dir),
|
||||
)?;
|
||||
}
|
||||
if runtime.running && (debug_ready || state.session == "active") {
|
||||
stop_codex(&assets)?;
|
||||
launch_codex_normally(&assets)?;
|
||||
}
|
||||
remove_runtime_state()?;
|
||||
cleanup_snapshot(&state.theme_dir, Path::new(""));
|
||||
Ok(SkinRuntimeAction::Restored(
|
||||
"已恢复 Windows Codex 官方外观并关闭换肤运行时".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
pub(super) fn runtime_status() -> SkinRuntimeStatus {
|
||||
let assets = match ensure_runtime_assets() {
|
||||
Ok(assets) => assets,
|
||||
Err(error) => {
|
||||
return SkinRuntimeStatus {
|
||||
supported: true,
|
||||
active: false,
|
||||
phase: "error".to_string(),
|
||||
port: None,
|
||||
theme_id: None,
|
||||
message: error.to_string(),
|
||||
}
|
||||
}
|
||||
};
|
||||
let state = match read_runtime_state() {
|
||||
Ok(Some(state)) => state,
|
||||
Ok(None) => {
|
||||
return SkinRuntimeStatus {
|
||||
supported: true,
|
||||
active: false,
|
||||
phase: "inactive".to_string(),
|
||||
port: None,
|
||||
theme_id: None,
|
||||
message: "尚未应用 Codex 皮肤".to_string(),
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
return SkinRuntimeStatus {
|
||||
supported: true,
|
||||
active: false,
|
||||
phase: "error".to_string(),
|
||||
port: None,
|
||||
theme_id: None,
|
||||
message: error.to_string(),
|
||||
}
|
||||
}
|
||||
};
|
||||
if state.session == "paused" {
|
||||
return SkinRuntimeStatus {
|
||||
supported: true,
|
||||
active: false,
|
||||
phase: "paused".to_string(),
|
||||
port: Some(state.port),
|
||||
theme_id: Some(state.theme_id),
|
||||
message: "皮肤已关闭,Codex 保持打开".to_string(),
|
||||
};
|
||||
}
|
||||
if let Err(error) = inspect_codex(&assets) {
|
||||
return SkinRuntimeStatus {
|
||||
supported: true,
|
||||
active: false,
|
||||
phase: "unavailable".to_string(),
|
||||
port: Some(state.port),
|
||||
theme_id: Some(state.theme_id),
|
||||
message: error.to_string(),
|
||||
};
|
||||
}
|
||||
let active = injector_active(&assets).unwrap_or(false)
|
||||
&& verify_port(&assets, state.port).is_ok_and(|result| {
|
||||
result.verified && result.browser_id.as_deref() == Some(state.browser_id.as_str())
|
||||
});
|
||||
SkinRuntimeStatus {
|
||||
supported: true,
|
||||
active,
|
||||
phase: if active { "active" } else { "stale" }.to_string(),
|
||||
port: Some(state.port),
|
||||
theme_id: Some(state.theme_id),
|
||||
message: if active {
|
||||
"Windows Codex 皮肤运行中".to_string()
|
||||
} else {
|
||||
"皮肤运行状态已失效,可重新应用主题".to_string()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn test_state() -> RuntimeStateFile {
|
||||
RuntimeStateFile {
|
||||
schema_version: 1,
|
||||
platform: "windows".to_string(),
|
||||
session: "active".to_string(),
|
||||
port: 9341,
|
||||
browser_id: "browser-1".to_string(),
|
||||
injector_pid: 42,
|
||||
injector_started_at: "2026-07-24T00:00:00.0000000Z".to_string(),
|
||||
injector_path: r"C:\Users\test\.codexx\runtime\injector.mjs".to_string(),
|
||||
node_path: r"C:\Program Files\Codex-X\skin-runtime\node\node.exe".to_string(),
|
||||
node_version: "v22.23.1".to_string(),
|
||||
codex_package_root: r"C:\Program Files\WindowsApps\OpenAI.Codex_1.0.0.0_x64"
|
||||
.to_string(),
|
||||
codex_executable:
|
||||
r"C:\Program Files\WindowsApps\OpenAI.Codex_1.0.0.0_x64\app\ChatGPT.exe".to_string(),
|
||||
codex_version: "1.0.0.0".to_string(),
|
||||
codex_package_full_name: "OpenAI.Codex_1.0.0.0_x64__test".to_string(),
|
||||
codex_package_family_name: "OpenAI.Codex_test".to_string(),
|
||||
codex_app_user_model_id: "OpenAI.Codex_test!App".to_string(),
|
||||
theme_id: "theme".to_string(),
|
||||
theme_dir: r"C:\Users\test\.codexx\runtime\themes\theme".to_string(),
|
||||
created_at: "2026-07-24T00:00:00Z".to_string(),
|
||||
updated_at: "2026-07-24T00:00:00Z".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_last_json_line_after_powershell_warnings() {
|
||||
let result: PortResult = parse_last_json(
|
||||
b"WARNING: package activation fallback\r\n{\"port\":9342}\r\n",
|
||||
"test",
|
||||
)
|
||||
.expect("parse adapter JSON");
|
||||
assert_eq!(result.port, 9342);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_state_uses_powershell_contract_field_names() {
|
||||
let value = serde_json::to_value(test_state()).expect("serialize state");
|
||||
assert_eq!(value["platform"], "windows");
|
||||
assert_eq!(value["injectorPid"], 42);
|
||||
assert_eq!(
|
||||
value["injectorPath"],
|
||||
r"C:\Users\test\.codexx\runtime\injector.mjs"
|
||||
);
|
||||
assert_eq!(
|
||||
value["nodePath"],
|
||||
r"C:\Program Files\Codex-X\skin-runtime\node\node.exe"
|
||||
);
|
||||
assert_eq!(value["browserId"], "browser-1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adapter_keeps_store_identity_and_loopback_guards() {
|
||||
let source = include_str!("../../resources/skin-runtime/windows/codexx-windows.ps1");
|
||||
assert!(source.contains("Get-DreamSkinCodexInstall"));
|
||||
assert!(source.contains("Start-DreamSkinCodexForDebugging"));
|
||||
assert!(source.contains("--remote-debugging-address=127.0.0.1"));
|
||||
assert!(source.contains("Get-DreamSkinVerifiedCdpIdentity"));
|
||||
assert!(!source.contains("takeown"));
|
||||
}
|
||||
}
|
||||
@@ -1,276 +0,0 @@
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
static FIXTURE_COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
fn temp_theme_dir(name: &str) -> PathBuf {
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"codex-x-skin-{name}-{}-{}",
|
||||
std::process::id(),
|
||||
FIXTURE_COUNTER.fetch_add(1, Ordering::Relaxed)
|
||||
));
|
||||
let _ = fs::remove_dir_all(&path);
|
||||
fs::create_dir_all(&path).expect("create theme fixture");
|
||||
path
|
||||
}
|
||||
|
||||
fn write_theme_fixture(dir: &Path, manifest: &str) {
|
||||
fs::write(dir.join("theme.json"), manifest).expect("write manifest");
|
||||
fs::write(dir.join("background.jpg"), [0xff, 0xd8, 0xff, 0xd9]).expect("write image");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manifest_accepts_adaptive_fields_and_preserves_extensions() {
|
||||
let dir = temp_theme_dir("adaptive");
|
||||
write_theme_fixture(
|
||||
&dir,
|
||||
r##"{
|
||||
"schemaVersion": 1,
|
||||
"id": "adaptive",
|
||||
"name": "Adaptive",
|
||||
"image": "background.jpg",
|
||||
"appearance": "auto",
|
||||
"art": { "focusX": 0.72, "safeArea": "left", "taskMode": "ambient" },
|
||||
"promoTitle": "kept"
|
||||
}"##,
|
||||
);
|
||||
|
||||
let manifest = read_manifest(&dir).expect("parse adaptive manifest");
|
||||
|
||||
assert!(manifest.colors.is_none());
|
||||
assert_eq!(
|
||||
manifest.art.as_ref().and_then(|art| art.focus_x),
|
||||
Some(0.72)
|
||||
);
|
||||
assert_eq!(
|
||||
manifest.extra.get("promoTitle"),
|
||||
Some(&Value::String("kept".to_string()))
|
||||
);
|
||||
let serialized = serde_json::to_value(manifest).expect("serialize manifest");
|
||||
assert_eq!(serialized["promoTitle"], "kept");
|
||||
fs::remove_dir_all(dir).expect("remove theme fixture");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manifest_rejects_out_of_range_art_focus() {
|
||||
let dir = temp_theme_dir("focus");
|
||||
write_theme_fixture(
|
||||
&dir,
|
||||
r#"{
|
||||
"schemaVersion": 1,
|
||||
"id": "bad-focus",
|
||||
"name": "Bad focus",
|
||||
"image": "background.jpg",
|
||||
"art": { "focusX": 2 }
|
||||
}"#,
|
||||
);
|
||||
|
||||
let error = read_manifest(&dir).expect_err("reject invalid focus");
|
||||
|
||||
assert!(error.to_string().contains("focusX"));
|
||||
fs::remove_dir_all(dir).expect("remove theme fixture");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn manifest_rejects_symlinked_images() {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let dir = temp_theme_dir("symlink");
|
||||
let outside = dir.with_extension("outside.jpg");
|
||||
fs::write(&outside, [0xff, 0xd8, 0xff, 0xd9]).expect("write outside image");
|
||||
fs::write(
|
||||
dir.join("theme.json"),
|
||||
r#"{"schemaVersion":1,"id":"linked","name":"Linked","image":"background.jpg"}"#,
|
||||
)
|
||||
.expect("write manifest");
|
||||
symlink(&outside, dir.join("background.jpg")).expect("create image symlink");
|
||||
|
||||
let error = read_manifest(&dir).expect_err("reject image symlink");
|
||||
|
||||
assert!(error.to_string().contains("普通文件"));
|
||||
fs::remove_dir_all(dir).expect("remove theme fixture");
|
||||
fs::remove_file(outside).expect("remove outside image");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zip_import_rejects_parent_traversal() {
|
||||
let mut archive = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
archive
|
||||
.start_file("../theme.json", SimpleFileOptions::default())
|
||||
.expect("start unsafe entry");
|
||||
archive.write_all(b"{}").expect("write unsafe entry");
|
||||
let bytes = archive.finish().expect("finish archive").into_inner();
|
||||
|
||||
let error = import_skin_theme_zip_inner("unsafe.zip".to_string(), bytes)
|
||||
.expect_err("reject traversal archive");
|
||||
|
||||
assert!(error.to_string().contains("越界路径"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn theme_id_length_is_bounded() {
|
||||
let error =
|
||||
normalize_theme_id(&"a".repeat(MAX_THEME_ID_BYTES + 1)).expect_err("reject long theme id");
|
||||
assert!(error.to_string().contains("主题 ID"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uploaded_image_requires_matching_supported_signature() {
|
||||
assert_eq!(
|
||||
uploaded_image_extension(
|
||||
"wallpaper.png",
|
||||
&[0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a]
|
||||
)
|
||||
.expect("accept PNG signature"),
|
||||
"png"
|
||||
);
|
||||
assert_eq!(
|
||||
uploaded_image_extension("wallpaper.jpeg", &[0xff, 0xd8, 0xff, 0xd9])
|
||||
.expect("accept JPEG signature"),
|
||||
"jpg"
|
||||
);
|
||||
assert!(uploaded_image_extension("wallpaper.png", b"not an image")
|
||||
.expect_err("reject disguised image")
|
||||
.to_string()
|
||||
.contains("格式不匹配"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_theme_names_and_ids_are_safe_and_unique() {
|
||||
assert_eq!(uploaded_theme_name(" sakura night.webp "), "sakura night");
|
||||
let root = temp_theme_dir("unique-image-id");
|
||||
fs::create_dir_all(root.join("image-sakura-night")).expect("create existing theme");
|
||||
assert_eq!(
|
||||
unique_theme_id_in(&root, "image-sakura-night").expect("create unique id"),
|
||||
"image-sakura-night-2"
|
||||
);
|
||||
fs::remove_dir_all(root).expect("remove unique id fixture");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_upload_creates_an_adaptive_theme_without_overwriting() {
|
||||
let file_name = format!(
|
||||
"direct-image-test-{}.png",
|
||||
FIXTURE_COUNTER.fetch_add(1, Ordering::Relaxed)
|
||||
);
|
||||
let bytes = vec![0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a];
|
||||
let result = create_skin_theme_from_image_inner(file_name, bytes)
|
||||
.expect("create adaptive theme from image");
|
||||
let theme = result
|
||||
.state
|
||||
.themes
|
||||
.iter()
|
||||
.find(|theme| theme.name.starts_with("direct-image-test-"))
|
||||
.expect("created theme is listed");
|
||||
let theme_dir = themes_root().expect("theme root").join(&theme.id);
|
||||
let manifest = read_manifest(&theme_dir).expect("read created theme");
|
||||
|
||||
assert_eq!(manifest.appearance.as_deref(), Some("auto"));
|
||||
assert_eq!(manifest.surface_opacity, Some(IMAGE_THEME_SURFACE_OPACITY));
|
||||
assert!(manifest.colors.is_none());
|
||||
assert!(theme.adaptive);
|
||||
assert_eq!(theme.surface_opacity, IMAGE_THEME_SURFACE_OPACITY);
|
||||
assert_eq!(
|
||||
manifest
|
||||
.art
|
||||
.as_ref()
|
||||
.and_then(|art| art.safe_area.as_deref()),
|
||||
Some("auto")
|
||||
);
|
||||
fs::remove_dir_all(theme_dir).expect("remove created image theme");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imported_theme_metadata_can_be_edited_without_changing_theme_assets() {
|
||||
let id = format!(
|
||||
"metadata-edit-test-{}",
|
||||
FIXTURE_COUNTER.fetch_add(1, Ordering::Relaxed)
|
||||
);
|
||||
let theme_dir = themes_root().expect("theme root").join(&id);
|
||||
fs::create_dir_all(&theme_dir).expect("create editable theme");
|
||||
fs::write(theme_dir.join("background.jpg"), [0xff, 0xd8, 0xff, 0xd9])
|
||||
.expect("write editable theme image");
|
||||
let manifest = SkinThemeManifest {
|
||||
schema_version: 1,
|
||||
id: id.clone(),
|
||||
name: "Before".to_string(),
|
||||
brand_subtitle: "Keep brand".to_string(),
|
||||
tagline: "Before tagline".to_string(),
|
||||
project_prefix: String::new(),
|
||||
project_label: String::new(),
|
||||
status_text: String::new(),
|
||||
quote: "Keep quote".to_string(),
|
||||
image: "background.jpg".to_string(),
|
||||
appearance: Some("auto".to_string()),
|
||||
surface_opacity: None,
|
||||
art: Some(SkinThemeArt {
|
||||
safe_area: Some("left".to_string()),
|
||||
..SkinThemeArt::default()
|
||||
}),
|
||||
colors: None,
|
||||
extra: BTreeMap::from([("customField".to_string(), Value::Bool(true))]),
|
||||
};
|
||||
write_manifest(&theme_dir, &manifest).expect("write editable manifest");
|
||||
|
||||
let result = update_skin_theme_settings_inner(
|
||||
id.clone(),
|
||||
" After ".to_string(),
|
||||
" Updated tagline ".to_string(),
|
||||
0.55,
|
||||
)
|
||||
.expect("update metadata");
|
||||
let updated = read_manifest(&theme_dir).expect("read updated manifest");
|
||||
|
||||
assert_eq!(updated.name, "After");
|
||||
assert_eq!(updated.tagline, "Updated tagline");
|
||||
assert_eq!(updated.brand_subtitle, "Keep brand");
|
||||
assert_eq!(updated.quote, "Keep quote");
|
||||
assert_eq!(updated.image, "background.jpg");
|
||||
assert_eq!(updated.surface_opacity, Some(0.55));
|
||||
assert_eq!(
|
||||
updated.art.and_then(|art| art.safe_area),
|
||||
Some("left".to_string())
|
||||
);
|
||||
assert_eq!(updated.extra.get("customField"), Some(&Value::Bool(true)));
|
||||
assert_eq!(
|
||||
result
|
||||
.state
|
||||
.themes
|
||||
.iter()
|
||||
.find(|theme| theme.id == id)
|
||||
.map(|theme| theme.name.as_str()),
|
||||
Some("After")
|
||||
);
|
||||
fs::remove_dir_all(theme_dir).expect("remove editable theme");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builtin_catalog_is_not_embedded_but_source_remains_valid() {
|
||||
let assets = builtin_skin_assets();
|
||||
assert!(assets.is_empty());
|
||||
let manifest: SkinThemeManifest = serde_json::from_str(include_str!(
|
||||
"../resources/skin-presets/shiina-mashiro-blossom/theme.json"
|
||||
))
|
||||
.expect("parse retained Shiina theme source");
|
||||
assert_eq!(manifest.id, BUILTIN_SKIN_ID);
|
||||
assert_eq!(manifest.name, "椎名真白·樱花画室");
|
||||
assert_eq!(manifest.quote, "安静创作,也会发光");
|
||||
assert!(retired_builtin_skin_ids()
|
||||
.iter()
|
||||
.all(|retired| *retired != manifest.id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_destination_adds_zip_extension_and_rejects_other_types() {
|
||||
let dir = temp_theme_dir("export-destination");
|
||||
let without_extension = dir.join("my-theme");
|
||||
let normalized = normalize_export_destination(without_extension.to_string_lossy().as_ref())
|
||||
.expect("append zip extension");
|
||||
assert_eq!(normalized, dir.join("my-theme.zip"));
|
||||
|
||||
let error = normalize_export_destination(dir.join("my-theme.txt").to_string_lossy().as_ref())
|
||||
.expect_err("reject non-zip extension");
|
||||
assert!(error.to_string().contains(".zip"));
|
||||
fs::remove_dir_all(dir).expect("remove export fixture");
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,8 @@
|
||||
use crate::backups::{latest_backup, BackupEntry};
|
||||
use crate::backups::BackupEntry;
|
||||
#[cfg(test)]
|
||||
use crate::config_migration::migrate_legacy_prompt_config;
|
||||
use crate::error::Result;
|
||||
use crate::file_io::{
|
||||
harden_sensitive_file_permissions, io_err, json_err, parse_toml_document,
|
||||
read_to_string_if_exists,
|
||||
};
|
||||
use crate::file_io::{io_err, json_err, parse_toml_document, read_to_string_if_exists};
|
||||
use crate::prompts::{
|
||||
agents_path, managed_agents_template_key, prompt_template_key_for_instruction,
|
||||
};
|
||||
@@ -69,7 +67,13 @@ fn redacted_auth_preview(path: &Path) -> Result<Option<Value>> {
|
||||
return Ok(None);
|
||||
}
|
||||
let text = fs::read_to_string(path).map_err(|e| io_err(path, e))?;
|
||||
let mut value: Value = serde_json::from_str(&text).map_err(|e| json_err(path, e))?;
|
||||
// Keep the UI responsive when another tool or an interrupted write leaves
|
||||
// auth.json malformed. Mutating flows validate or replace auth explicitly;
|
||||
// the read-only state path should still report the rest of the config.
|
||||
let mut value: Value = match serde_json::from_str(&text) {
|
||||
Ok(value) => value,
|
||||
Err(_) => return Ok(None),
|
||||
};
|
||||
if let Some(obj) = value.as_object_mut() {
|
||||
for (key, val) in obj.iter_mut() {
|
||||
let lower = key.to_ascii_lowercase();
|
||||
@@ -175,6 +179,7 @@ pub(crate) fn active_saved_provider_id_from_config(
|
||||
(matches.len() == 1).then(|| matches[0].id.clone())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn build_state(codex_dir: PathBuf) -> Result<CodexState> {
|
||||
migrate_legacy_prompt_config(&codex_dir)?;
|
||||
build_state_after_migration(codex_dir)
|
||||
@@ -183,8 +188,6 @@ pub(crate) fn build_state(codex_dir: PathBuf) -> Result<CodexState> {
|
||||
pub(crate) fn build_state_after_migration(codex_dir: PathBuf) -> Result<CodexState> {
|
||||
let cfg = config_path(&codex_dir);
|
||||
let auth = auth_path(&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");
|
||||
@@ -236,6 +239,8 @@ pub(crate) fn build_state_after_migration(codex_dir: PathBuf) -> Result<CodexSta
|
||||
config_text: text,
|
||||
auth_preview: redacted_auth_preview(&auth)?,
|
||||
auth_text: read_to_string_if_exists(&auth)?,
|
||||
last_backup: latest_backup()?,
|
||||
// Backup discovery can traverse years of history. Keep core state fast;
|
||||
// restore actions resolve history only when the user requests it.
|
||||
last_backup: None,
|
||||
})
|
||||
}
|
||||
|
||||
+1209
-110
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Codex-X",
|
||||
"version": "0.3.6",
|
||||
"version": "0.3.11",
|
||||
"identifier": "com.yynxxxxx.codexx",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
@@ -29,11 +29,7 @@
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": "default-src 'self'; img-src 'self' data: asset: http://asset.localhost https://asset.localhost; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src 'self' ipc: http://ipc.localhost",
|
||||
"assetProtocol": {
|
||||
"enable": true,
|
||||
"scope": ["$HOME/.codexx/codex-x-skins/**"]
|
||||
}
|
||||
"csp": "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src 'self' ipc: http://ipc.localhost"
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"bundle": {
|
||||
"resources": {
|
||||
"runtime/windows/node/*": "skin-runtime/node/"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -310,70 +310,6 @@ export function UpdateDialog({
|
||||
);
|
||||
}
|
||||
|
||||
export type SkinRestartDialogProps = {
|
||||
open: boolean;
|
||||
lang: Lang;
|
||||
themeName?: string | null;
|
||||
busy: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void | Promise<void>;
|
||||
};
|
||||
|
||||
export function SkinRestartDialog({
|
||||
open,
|
||||
lang,
|
||||
themeName,
|
||||
busy,
|
||||
onClose,
|
||||
onConfirm,
|
||||
}: SkinRestartDialogProps) {
|
||||
const isChinese = lang === "zh";
|
||||
const title = isChinese ? "重启 Codex 并应用皮肤" : "Restart Codex and apply skin";
|
||||
const description = isChinese
|
||||
? `Codex 当前未启用换肤调试端口。继续后会关闭并重新打开 Codex${themeName ? `,然后应用“${themeName}”` : ""}。`
|
||||
: `Codex is not running with the skin debug endpoint. It will be closed and reopened${themeName ? `, then “${themeName}” will be applied` : ""}.`;
|
||||
const warning = isChinese
|
||||
? "继续前请保存 Codex 中尚未发送的输入和正在进行的工作。本机调试端口仅绑定 127.0.0.1,但启用期间仍应避免运行不可信的本机程序。"
|
||||
: "Save unsent input and in-progress work in Codex first. The debug endpoint binds only to 127.0.0.1, but untrusted local programs should still be avoided while it is active.";
|
||||
|
||||
return (
|
||||
<ModalShell
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title={title}
|
||||
description={description}
|
||||
size="sm"
|
||||
closeOnBackdrop={!busy}
|
||||
closeOnEscape={!busy}
|
||||
showCloseButton={!busy}
|
||||
closeLabel={isChinese ? "关闭" : "Close"}
|
||||
className="cx-skin-restart-dialog"
|
||||
footer={(
|
||||
<>
|
||||
<Button variant="secondary" onClick={onClose} disabled={busy}>
|
||||
{isChinese ? "取消" : "Cancel"}
|
||||
</Button>
|
||||
<Button
|
||||
icon={busy ? <Loader2 size={16} className="cx-dialog-spin" /> : <RotateCcw size={16} />}
|
||||
onClick={() => void onConfirm()}
|
||||
disabled={busy}
|
||||
data-initial-focus
|
||||
>
|
||||
{busy
|
||||
? (isChinese ? "正在处理" : "Working")
|
||||
: (isChinese ? "确认重启" : "Restart Codex")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<div className="cx-skin-restart-warning">
|
||||
<AlertCircle size={20} aria-hidden="true" />
|
||||
<p>{warning}</p>
|
||||
</div>
|
||||
</ModalShell>
|
||||
);
|
||||
}
|
||||
|
||||
export type StartupWizardDialogProps = {
|
||||
open: boolean;
|
||||
closing: boolean;
|
||||
@@ -430,6 +366,7 @@ export function StartupWizardDialog({
|
||||
value={configDir || diagnostics.codexDir}
|
||||
onChange={(event) => onConfigDirChange(event.target.value)}
|
||||
placeholder="~/.codex"
|
||||
disabled={loading}
|
||||
spellCheck={false}
|
||||
/>
|
||||
<Button
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
LayoutDashboard,
|
||||
LoaderCircle,
|
||||
Moon,
|
||||
Palette,
|
||||
RotateCcw,
|
||||
Settings,
|
||||
Sparkles,
|
||||
@@ -29,7 +28,6 @@ export type AppTab =
|
||||
| "provider"
|
||||
| "sessions"
|
||||
| "skillsMcp"
|
||||
| "skins"
|
||||
| "instruction"
|
||||
| "toml"
|
||||
| "settings"
|
||||
@@ -46,7 +44,6 @@ const NAV_ITEMS: readonly NavItem[] = [
|
||||
{ id: "provider", icon: Zap, label: { zh: "供应商", en: "Providers" } },
|
||||
{ id: "sessions", icon: History, label: { zh: "会话管理", en: "Sessions" } },
|
||||
{ id: "skillsMcp", icon: Blocks, label: { zh: "技能和MCP", en: "Skills & MCP" } },
|
||||
{ id: "skins", icon: Palette, label: { zh: "皮肤中心", en: "Skins" } },
|
||||
{ id: "instruction", icon: Sparkles, label: { zh: "指令提示词", en: "Prompts" } },
|
||||
{ id: "toml", icon: FileCode2, label: { zh: "TOML", en: "TOML" } },
|
||||
{ id: "settings", icon: Settings, label: { zh: "设置", en: "Settings" } },
|
||||
@@ -65,7 +62,6 @@ export type AppShellProps = {
|
||||
updatePhase?: AppUpdaterPhase;
|
||||
onOpenUpdate?: () => void;
|
||||
isMacRuntime?: boolean;
|
||||
skinCenterEnabled?: boolean;
|
||||
children: ReactNode;
|
||||
sidebarFooter?: ReactNode;
|
||||
className?: string;
|
||||
@@ -84,7 +80,6 @@ export function AppShell({
|
||||
updatePhase = "idle",
|
||||
onOpenUpdate,
|
||||
isMacRuntime = false,
|
||||
skinCenterEnabled = true,
|
||||
children,
|
||||
sidebarFooter,
|
||||
className,
|
||||
@@ -160,7 +155,7 @@ export function AppShell({
|
||||
</div>
|
||||
|
||||
<nav className="cx-sidebar-nav" aria-label={navigationLabel}>
|
||||
{NAV_ITEMS.filter((item) => item.id !== "skins" || skinCenterEnabled).map((item) => {
|
||||
{NAV_ITEMS.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = activeTab === item.id;
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
ChevronRight,
|
||||
Edit2,
|
||||
FileText,
|
||||
Palette,
|
||||
Plus,
|
||||
Settings2,
|
||||
Trash2,
|
||||
@@ -32,7 +31,6 @@ type PromptCategoryManagerProps = {
|
||||
onRenameCategory: (categoryId: string, name: string) => boolean;
|
||||
onDeleteCategory: (categoryId: string) => boolean;
|
||||
onMovePrompt: (promptKey: string, categoryId: string) => void;
|
||||
subject?: "prompt" | "skin";
|
||||
};
|
||||
|
||||
export function PromptCategoryManager({
|
||||
@@ -46,7 +44,6 @@ export function PromptCategoryManager({
|
||||
onRenameCategory,
|
||||
onDeleteCategory,
|
||||
onMovePrompt,
|
||||
subject = "prompt",
|
||||
}: PromptCategoryManagerProps) {
|
||||
const isChinese = lang === "zh";
|
||||
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set());
|
||||
@@ -75,38 +72,11 @@ export function PromptCategoryManager({
|
||||
return grouped;
|
||||
}, [categories, categoryForPrompt, prompts]);
|
||||
|
||||
const subjectCopy = isChinese
|
||||
? subject === "skin"
|
||||
? {
|
||||
deleteTitle: "删除皮肤分类",
|
||||
deleteDescription: (name: string) => `“${name}”中的皮肤会移到默认分类,皮肤本身不会被删除。`,
|
||||
empty: "该分类下暂无皮肤",
|
||||
move: (title: string) => `移动“${title}”到分类`,
|
||||
}
|
||||
: {
|
||||
deleteTitle: "删除提示词分类",
|
||||
deleteDescription: (name: string) => `“${name}”中的提示词会移到默认分类,提示词本身不会被删除。`,
|
||||
empty: "该分类下暂无提示词",
|
||||
move: (title: string) => `移动“${title}”到分类`,
|
||||
}
|
||||
: subject === "skin"
|
||||
? {
|
||||
deleteTitle: "Delete skin category",
|
||||
deleteDescription: (name: string) => `Skins in “${name}” will move to the default category. The skins are not deleted.`,
|
||||
empty: "No skins in this category",
|
||||
move: (title: string) => `Move “${title}” to category`,
|
||||
}
|
||||
: {
|
||||
deleteTitle: "Delete prompt category",
|
||||
deleteDescription: (name: string) => `Prompts in “${name}” will move to the default category. The prompts are not deleted.`,
|
||||
empty: "No prompts in this category",
|
||||
move: (title: string) => `Move “${title}” to category`,
|
||||
};
|
||||
const copy = isChinese
|
||||
? {
|
||||
title: "分类管理",
|
||||
deleteTitle: subjectCopy.deleteTitle,
|
||||
deleteDescription: subjectCopy.deleteDescription,
|
||||
deleteTitle: "删除提示词分类",
|
||||
deleteDescription: (name: string) => `“${name}”中的提示词会移到默认分类,提示词本身不会被删除。`,
|
||||
addCategory: "新增分类",
|
||||
namePlaceholder: "输入分类名称",
|
||||
cancel: "取消",
|
||||
@@ -116,16 +86,16 @@ export function PromptCategoryManager({
|
||||
edit: "编辑分类",
|
||||
remove: "删除分类",
|
||||
keepOne: "至少保留一个分类",
|
||||
empty: subjectCopy.empty,
|
||||
move: subjectCopy.move,
|
||||
empty: "该分类下暂无提示词",
|
||||
move: (title: string) => `移动“${title}”到分类`,
|
||||
duplicate: `分类名称不能为空、重复或超过 ${PROMPT_CATEGORY_NAME_MAX_LENGTH} 个字符`,
|
||||
expand: (name: string) => `展开${name}`,
|
||||
collapse: (name: string) => `收起${name}`,
|
||||
}
|
||||
: {
|
||||
title: "Manage categories",
|
||||
deleteTitle: subjectCopy.deleteTitle,
|
||||
deleteDescription: subjectCopy.deleteDescription,
|
||||
deleteTitle: "Delete prompt category",
|
||||
deleteDescription: (name: string) => `Prompts in “${name}” will move to the default category. The prompts are not deleted.`,
|
||||
addCategory: "Add category",
|
||||
namePlaceholder: "Category name",
|
||||
cancel: "Cancel",
|
||||
@@ -135,14 +105,12 @@ export function PromptCategoryManager({
|
||||
edit: "Edit category",
|
||||
remove: "Delete category",
|
||||
keepOne: "Keep at least one category",
|
||||
empty: subjectCopy.empty,
|
||||
move: subjectCopy.move,
|
||||
empty: "No prompts in this category",
|
||||
move: (title: string) => `Move “${title}” to category`,
|
||||
duplicate: `Category names cannot be empty, duplicated, or longer than ${PROMPT_CATEGORY_NAME_MAX_LENGTH} characters`,
|
||||
expand: (name: string) => `Expand ${name}`,
|
||||
collapse: (name: string) => `Collapse ${name}`,
|
||||
};
|
||||
const ItemIcon = subject === "skin" ? Palette : FileText;
|
||||
|
||||
const close = () => {
|
||||
if (categoryToDelete) {
|
||||
setCategoryToDelete(null);
|
||||
@@ -292,7 +260,7 @@ export function PromptCategoryManager({
|
||||
<div className="cx-prompt-category-prompts">
|
||||
{categoryPrompts.length ? categoryPrompts.map((prompt) => (
|
||||
<div className="cx-prompt-category-prompt" key={prompt.key}>
|
||||
<ItemIcon size={14} aria-hidden="true" />
|
||||
<FileText size={14} aria-hidden="true" />
|
||||
<span title={prompt.title}>{prompt.title}</span>
|
||||
<select
|
||||
value={category.id}
|
||||
|
||||
@@ -1,150 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Loader2, Pencil, Save } from "lucide-react";
|
||||
|
||||
import type { Lang, SkinThemeSummary } from "../types";
|
||||
import { Button, ModalShell } from "./ui";
|
||||
|
||||
export type SkinThemeEditorProps = {
|
||||
lang: Lang;
|
||||
open: boolean;
|
||||
theme: SkinThemeSummary | null;
|
||||
busy: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (id: string, name: string, tagline: string, surfaceOpacity: number) => Promise<boolean>;
|
||||
};
|
||||
|
||||
function getCopy(lang: Lang) {
|
||||
return lang === "zh"
|
||||
? {
|
||||
title: "编辑皮肤",
|
||||
description: "调整皮肤信息和壁纸上方界面的透明度。",
|
||||
builtinDescription: "调整壁纸上方界面的透明度。",
|
||||
name: "皮肤名称",
|
||||
namePlaceholder: "输入皮肤名称",
|
||||
tagline: "皮肤简介",
|
||||
taglinePlaceholder: "简要描述这套皮肤",
|
||||
transparency: "界面透明度",
|
||||
cancel: "取消",
|
||||
save: "保存",
|
||||
saving: "保存中",
|
||||
close: "关闭",
|
||||
}
|
||||
: {
|
||||
title: "Edit skin",
|
||||
description: "Change skin details and the transparency of surfaces above the wallpaper.",
|
||||
builtinDescription: "Adjust the transparency of surfaces above the wallpaper.",
|
||||
name: "Skin name",
|
||||
namePlaceholder: "Enter a skin name",
|
||||
tagline: "Description",
|
||||
taglinePlaceholder: "Briefly describe this skin",
|
||||
transparency: "Interface transparency",
|
||||
cancel: "Cancel",
|
||||
save: "Save",
|
||||
saving: "Saving",
|
||||
close: "Close",
|
||||
};
|
||||
}
|
||||
|
||||
export function SkinThemeEditor({
|
||||
lang,
|
||||
open,
|
||||
theme,
|
||||
busy,
|
||||
onClose,
|
||||
onSave,
|
||||
}: SkinThemeEditorProps) {
|
||||
const copy = getCopy(lang);
|
||||
const [name, setName] = useState("");
|
||||
const [tagline, setTagline] = useState("");
|
||||
const [surfaceOpacity, setSurfaceOpacity] = useState(1);
|
||||
const editableMetadata = theme?.source !== "builtin";
|
||||
const transparency = Math.round((1 - surfaceOpacity) * 100);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !theme) return;
|
||||
setName(theme.name);
|
||||
setTagline(theme.tagline);
|
||||
setSurfaceOpacity(theme.surfaceOpacity);
|
||||
}, [open, theme]);
|
||||
|
||||
const close = () => {
|
||||
if (!busy) onClose();
|
||||
};
|
||||
const save = async () => {
|
||||
if (!theme || (editableMetadata && !name.trim()) || busy) return;
|
||||
if (await onSave(theme.id, name, tagline, surfaceOpacity)) onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<ModalShell
|
||||
open={open && Boolean(theme)}
|
||||
onClose={close}
|
||||
title={<span className="cx-skin-editor-title"><Pencil size={18} aria-hidden="true" />{copy.title}</span>}
|
||||
description={editableMetadata ? copy.description : copy.builtinDescription}
|
||||
closeLabel={copy.close}
|
||||
closeOnBackdrop={!busy}
|
||||
closeOnEscape={!busy}
|
||||
className="cx-skin-editor-modal"
|
||||
bodyClassName="cx-skin-editor-body"
|
||||
footer={(
|
||||
<>
|
||||
<Button variant="secondary" onClick={close} disabled={busy}>{copy.cancel}</Button>
|
||||
<Button
|
||||
icon={busy ? <Loader2 className="cx-skins-spin" /> : <Save />}
|
||||
onClick={() => void save()}
|
||||
disabled={busy || (editableMetadata && !name.trim())}
|
||||
>
|
||||
{busy ? copy.saving : copy.save}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
{editableMetadata && (
|
||||
<>
|
||||
<label className="cx-skin-editor-field">
|
||||
<span>{copy.name}</span>
|
||||
<input
|
||||
value={name}
|
||||
onChange={(event) => setName(event.currentTarget.value)}
|
||||
placeholder={copy.namePlaceholder}
|
||||
maxLength={80}
|
||||
disabled={busy}
|
||||
data-initial-focus
|
||||
/>
|
||||
</label>
|
||||
<label className="cx-skin-editor-field">
|
||||
<span>{copy.tagline}</span>
|
||||
<textarea
|
||||
value={tagline}
|
||||
onChange={(event) => setTagline(event.currentTarget.value)}
|
||||
placeholder={copy.taglinePlaceholder}
|
||||
maxLength={160}
|
||||
disabled={busy}
|
||||
rows={4}
|
||||
/>
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
<label className="cx-skin-editor-field">
|
||||
<span>{copy.transparency}</span>
|
||||
<div className="cx-skin-editor-range">
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="65"
|
||||
step="1"
|
||||
value={transparency}
|
||||
onChange={(event) => {
|
||||
const value = Number(event.currentTarget.value);
|
||||
setSurfaceOpacity(Number((1 - value / 100).toFixed(2)));
|
||||
}}
|
||||
disabled={busy}
|
||||
aria-label={copy.transparency}
|
||||
data-initial-focus={!editableMetadata || undefined}
|
||||
/>
|
||||
<output>{transparency}%</output>
|
||||
</div>
|
||||
</label>
|
||||
</ModalShell>
|
||||
);
|
||||
}
|
||||
@@ -1,260 +0,0 @@
|
||||
import React from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { save } from "@tauri-apps/plugin-dialog";
|
||||
|
||||
import type { AppTab } from "../components/AppShell";
|
||||
import type {
|
||||
Lang,
|
||||
SkinActionResult,
|
||||
SkinCenterState,
|
||||
SkinExportResult,
|
||||
} from "../types";
|
||||
|
||||
export type SkinRestartRequest = {
|
||||
themeId?: string;
|
||||
themeName?: string;
|
||||
};
|
||||
|
||||
function exportFileName(value: string) {
|
||||
const normalized = value
|
||||
.trim()
|
||||
.replace(/[<>:"/\\|?*\u0000-\u001f]/g, "-")
|
||||
.replace(/[. ]+$/g, "")
|
||||
.slice(0, 80);
|
||||
return `${normalized || "codex-x-theme"}.zip`;
|
||||
}
|
||||
|
||||
type UseSkinCenterOptions = {
|
||||
enabled: boolean;
|
||||
lang: Lang;
|
||||
tab: AppTab;
|
||||
ready: boolean;
|
||||
setActionBusy: React.Dispatch<React.SetStateAction<string>>;
|
||||
setError: React.Dispatch<React.SetStateAction<string>>;
|
||||
setToast: React.Dispatch<React.SetStateAction<string>>;
|
||||
};
|
||||
|
||||
export function useSkinCenter({
|
||||
enabled,
|
||||
lang,
|
||||
tab,
|
||||
ready,
|
||||
setActionBusy,
|
||||
setError,
|
||||
setToast,
|
||||
}: UseSkinCenterOptions) {
|
||||
const [state, setState] = React.useState<SkinCenterState | null>(null);
|
||||
const [restartRequest, setRestartRequest] = React.useState<SkinRestartRequest | null>(null);
|
||||
const [pauseBusy, setPauseBusy] = React.useState(false);
|
||||
const zipInputRef = React.useRef<HTMLInputElement | null>(null);
|
||||
const imageInputRef = React.useRef<HTMLInputElement | null>(null);
|
||||
const loadedRef = React.useRef(false);
|
||||
const pauseInFlightRef = React.useRef(false);
|
||||
|
||||
const load = React.useCallback(async ({
|
||||
quiet = false,
|
||||
notify = false,
|
||||
}: { quiet?: boolean; notify?: boolean } = {}) => {
|
||||
if (!enabled) return;
|
||||
if (!quiet) {
|
||||
setActionBusy("loadSkins");
|
||||
setError("");
|
||||
}
|
||||
try {
|
||||
setState(await invoke<SkinCenterState>("get_skin_center_state"));
|
||||
if (notify) {
|
||||
setToast(lang === "zh" ? "皮肤库已刷新" : "Skin library refreshed");
|
||||
}
|
||||
} catch (error) {
|
||||
if (!quiet) setError(String(error));
|
||||
} finally {
|
||||
if (!quiet) setActionBusy("");
|
||||
}
|
||||
}, [enabled, lang, setActionBusy, setError, setToast]);
|
||||
|
||||
const refresh = React.useCallback(() => load({ notify: true }), [load]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!enabled || tab !== "skins" || loadedRef.current) return;
|
||||
loadedRef.current = true;
|
||||
void load();
|
||||
}, [enabled, load, tab]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!enabled || !ready || loadedRef.current) return;
|
||||
loadedRef.current = true;
|
||||
void load({ quiet: true });
|
||||
}, [enabled, load, ready]);
|
||||
|
||||
const importZip = async (file?: File | null): Promise<string | null> => {
|
||||
if (!file) return null;
|
||||
if (!file.name.toLowerCase().endsWith(".zip")) {
|
||||
setError(lang === "zh" ? "请选择 .zip 主题包" : "Please choose a .zip theme pack");
|
||||
return null;
|
||||
}
|
||||
if (file.size > 24 * 1024 * 1024) {
|
||||
setError(lang === "zh" ? "主题包不能超过 24MB" : "Theme ZIP must be smaller than 24MB");
|
||||
return null;
|
||||
}
|
||||
setActionBusy("importSkinZip");
|
||||
setError("");
|
||||
try {
|
||||
const previousIds = new Set(state?.themes.map((theme) => theme.id) ?? []);
|
||||
const bytes = Array.from(new Uint8Array(await file.arrayBuffer()));
|
||||
const result = await invoke<SkinActionResult>("import_skin_theme_zip", {
|
||||
fileName: file.name,
|
||||
bytes,
|
||||
});
|
||||
setState(result.state);
|
||||
setToast(result.message);
|
||||
return result.state.themes.find((theme) => !previousIds.has(theme.id))?.id ?? null;
|
||||
} catch (error) {
|
||||
setError(String(error));
|
||||
return null;
|
||||
} finally {
|
||||
setActionBusy("");
|
||||
if (zipInputRef.current) zipInputRef.current.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
const createFromImage = async (file?: File | null): Promise<string | null> => {
|
||||
if (!file) return null;
|
||||
if (!/\.(png|jpe?g|webp)$/i.test(file.name)) {
|
||||
setError(lang === "zh"
|
||||
? "请选择 PNG、JPEG 或 WebP 图片"
|
||||
: "Please choose a PNG, JPEG, or WebP image");
|
||||
return null;
|
||||
}
|
||||
if (file.size > 16 * 1024 * 1024) {
|
||||
setError(lang === "zh" ? "主题图片不能超过 16MB" : "Theme image must be smaller than 16MB");
|
||||
return null;
|
||||
}
|
||||
setActionBusy("createSkinFromImage");
|
||||
setError("");
|
||||
try {
|
||||
const previousIds = new Set(state?.themes.map((theme) => theme.id) ?? []);
|
||||
const bytes = Array.from(new Uint8Array(await file.arrayBuffer()));
|
||||
const result = await invoke<SkinActionResult>("create_skin_theme_from_image", {
|
||||
fileName: file.name,
|
||||
bytes,
|
||||
});
|
||||
setState(result.state);
|
||||
setToast(result.message);
|
||||
return result.state.themes.find((theme) => !previousIds.has(theme.id))?.id ?? null;
|
||||
} catch (error) {
|
||||
setError(String(error));
|
||||
return null;
|
||||
} finally {
|
||||
setActionBusy("");
|
||||
if (imageInputRef.current) imageInputRef.current.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
const apply = async (id: string, restartExisting = false) => {
|
||||
setActionBusy(`skin:${id}`);
|
||||
setError("");
|
||||
try {
|
||||
const result = await invoke<SkinActionResult>("enable_skin_theme", { id, restartExisting });
|
||||
setState(result.state);
|
||||
if (result.restartRequired) {
|
||||
const themeName = result.state.themes.find((theme) => theme.id === id)?.name;
|
||||
setRestartRequest({ themeId: id, themeName });
|
||||
} else {
|
||||
setRestartRequest(null);
|
||||
setToast(result.message);
|
||||
}
|
||||
} catch (error) {
|
||||
setError(String(error));
|
||||
void load({ quiet: true });
|
||||
} finally {
|
||||
setActionBusy("");
|
||||
}
|
||||
};
|
||||
|
||||
const updateSettings = async (
|
||||
id: string,
|
||||
name: string,
|
||||
tagline: string,
|
||||
surfaceOpacity: number,
|
||||
): Promise<boolean> => {
|
||||
setActionBusy(`skinEdit:${id}`);
|
||||
setError("");
|
||||
try {
|
||||
const result = await invoke<SkinActionResult>("update_skin_theme_settings", {
|
||||
id,
|
||||
name,
|
||||
tagline,
|
||||
surfaceOpacity,
|
||||
});
|
||||
setState(result.state);
|
||||
setToast(result.message);
|
||||
return true;
|
||||
} catch (error) {
|
||||
setError(String(error));
|
||||
return false;
|
||||
} finally {
|
||||
setActionBusy("");
|
||||
}
|
||||
};
|
||||
|
||||
const pause = async () => {
|
||||
if (pauseInFlightRef.current) return;
|
||||
pauseInFlightRef.current = true;
|
||||
setPauseBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const result = await invoke<SkinActionResult>("pause_skin_theme");
|
||||
setState(result.state);
|
||||
setToast(result.message);
|
||||
} catch (error) {
|
||||
setError(String(error));
|
||||
void load({ quiet: true });
|
||||
} finally {
|
||||
pauseInFlightRef.current = false;
|
||||
setPauseBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const confirmRestart = async () => {
|
||||
if (restartRequest?.themeId) {
|
||||
await apply(restartRequest.themeId, true);
|
||||
}
|
||||
};
|
||||
|
||||
const exportTheme = async (id: string) => {
|
||||
setError("");
|
||||
try {
|
||||
const theme = state?.themes.find((item) => item.id === id);
|
||||
const destinationPath = await save({
|
||||
title: lang === "zh" ? "导出皮肤主题" : "Export skin theme",
|
||||
defaultPath: exportFileName(theme?.name || id),
|
||||
filters: [{ name: "ZIP", extensions: ["zip"] }],
|
||||
});
|
||||
if (!destinationPath) return;
|
||||
setActionBusy(`skinExport:${id}`);
|
||||
const result = await invoke<SkinExportResult>("export_skin_theme", { id, destinationPath });
|
||||
setToast(result.message || (lang === "zh" ? `主题包已导出:${result.path}` : `Theme exported: ${result.path}`));
|
||||
} catch (error) {
|
||||
setError(String(error));
|
||||
} finally {
|
||||
setActionBusy("");
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
state,
|
||||
restartRequest,
|
||||
pauseBusy,
|
||||
zipInputRef,
|
||||
imageInputRef,
|
||||
refresh,
|
||||
importZip,
|
||||
createFromImage,
|
||||
updateSettings,
|
||||
apply,
|
||||
pause,
|
||||
confirmRestart,
|
||||
closeRestart: () => setRestartRequest(null),
|
||||
exportTheme,
|
||||
};
|
||||
}
|
||||
+502
-219
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,11 @@
|
||||
import {
|
||||
ArrowUpRight,
|
||||
CheckCircle2,
|
||||
CircleAlert,
|
||||
Code2,
|
||||
FileText,
|
||||
KeyRound,
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
@@ -14,6 +16,7 @@ export type OverviewLanguage = "zh" | "en";
|
||||
|
||||
export type OverviewPageProps = {
|
||||
lang: OverviewLanguage;
|
||||
ready: boolean;
|
||||
model?: string | null;
|
||||
configDir: string;
|
||||
resolvedCodexDir: string;
|
||||
@@ -70,6 +73,7 @@ function ConfigRow({ label, value }: ConfigRowProps) {
|
||||
|
||||
export function OverviewPage({
|
||||
lang,
|
||||
ready,
|
||||
model,
|
||||
configDir,
|
||||
resolvedCodexDir,
|
||||
@@ -121,6 +125,8 @@ export function OverviewPage({
|
||||
model: "模型",
|
||||
providerName: "供应商标识",
|
||||
instructionFile: "指令文件",
|
||||
reading: "正在读取",
|
||||
readFailed: "读取失败",
|
||||
}
|
||||
: {
|
||||
eyebrow: "CODEX CONFIG MANAGER",
|
||||
@@ -153,20 +159,29 @@ export function OverviewPage({
|
||||
model: "Model",
|
||||
providerName: "Provider",
|
||||
instructionFile: "Instruction file",
|
||||
reading: "Loading",
|
||||
readFailed: "Load failed",
|
||||
};
|
||||
|
||||
const displayModel = model?.trim() || text.modelMissing;
|
||||
const displayProvider = providerLabel?.trim() || modelProvider?.trim() || text.official;
|
||||
const displayModelProvider = modelProvider?.trim() || text.notConfigured;
|
||||
const displayDirectory = resolvedCodexDir.trim() || configDir.trim() || text.notConfigured;
|
||||
const displayConfigPath = configPath?.trim() || text.notConfigured;
|
||||
const displayInstructionPath = instructionPath?.trim() || text.notConfigured;
|
||||
const unresolvedStatus = loading ? text.reading : text.readFailed;
|
||||
const displayModel = ready ? (model?.trim() || text.modelMissing) : unresolvedStatus;
|
||||
const displayProvider = ready
|
||||
? (providerLabel?.trim() || modelProvider?.trim() || text.official)
|
||||
: unresolvedStatus;
|
||||
const displayModelProvider = ready ? (modelProvider?.trim() || text.notConfigured) : unresolvedStatus;
|
||||
const displayDirectory = ready
|
||||
? (resolvedCodexDir.trim() || configDir.trim() || text.notConfigured)
|
||||
: (configDir.trim() || unresolvedStatus);
|
||||
const displayConfigPath = ready ? (configPath?.trim() || text.notConfigured) : unresolvedStatus;
|
||||
const displayInstructionPath = ready ? (instructionPath?.trim() || text.notConfigured) : unresolvedStatus;
|
||||
const updateVersion = latestVersion?.trim() || "";
|
||||
const homeInputValue = configDir || resolvedCodexDir;
|
||||
const authAvailable = authExists || officialAuthAvailable;
|
||||
const authStatus = authExists
|
||||
? text.authFile
|
||||
: officialAuthAvailable ? text.officialAuth : text.noAuth;
|
||||
const homeInputValue = configDir;
|
||||
const authAvailable = ready && (authExists || officialAuthAvailable);
|
||||
const authStatus = ready
|
||||
? authExists
|
||||
? text.authFile
|
||||
: officialAuthAvailable ? text.officialAuth : text.noAuth
|
||||
: unresolvedStatus;
|
||||
|
||||
return (
|
||||
<section className="cx-overview-page" aria-label={isChinese ? "概览" : "Overview"}>
|
||||
@@ -187,6 +202,7 @@ export function OverviewPage({
|
||||
value={homeInputValue}
|
||||
onChange={(event) => onConfigDirChange(event.target.value)}
|
||||
placeholder={text.directoryPlaceholder}
|
||||
disabled={loading}
|
||||
spellCheck={false}
|
||||
aria-label={text.codexHome}
|
||||
/>
|
||||
@@ -217,20 +233,20 @@ export function OverviewPage({
|
||||
<StatusCard
|
||||
icon={FileText}
|
||||
label={text.config}
|
||||
value={configExists ? text.found : text.missing}
|
||||
tone={configExists ? "success" : "muted"}
|
||||
value={ready ? (configExists ? text.found : text.missing) : unresolvedStatus}
|
||||
tone={ready && configExists ? "success" : "muted"}
|
||||
/>
|
||||
<StatusCard
|
||||
icon={Code2}
|
||||
label={text.provider}
|
||||
value={displayProvider}
|
||||
tone={modelProvider ? "active" : "muted"}
|
||||
tone={ready && modelProvider ? "active" : "muted"}
|
||||
/>
|
||||
<StatusCard
|
||||
icon={Sparkles}
|
||||
label={text.instruction}
|
||||
value={instructionEnabled ? text.enabled : text.disabled}
|
||||
tone={instructionEnabled ? "success" : "muted"}
|
||||
value={ready ? (instructionEnabled ? text.enabled : text.disabled) : unresolvedStatus}
|
||||
tone={ready && instructionEnabled ? "success" : "muted"}
|
||||
/>
|
||||
<StatusCard
|
||||
icon={KeyRound}
|
||||
@@ -246,9 +262,13 @@ export function OverviewPage({
|
||||
<p className="cx-overview-section-label">{text.liveStatus}</p>
|
||||
<h3>{text.currentConfig}</h3>
|
||||
</div>
|
||||
<span className={`cx-overview-instruction-pill${instructionEnabled ? " cx-overview-instruction-pill--active" : ""}`}>
|
||||
<CheckCircle2 size={14} strokeWidth={2} aria-hidden="true" />
|
||||
{instructionEnabled ? text.on : text.off}
|
||||
<span className={`cx-overview-instruction-pill${ready && instructionEnabled ? " cx-overview-instruction-pill--active" : ""}`}>
|
||||
{!ready && loading
|
||||
? <Loader2 className="cx-overview-spin" size={14} strokeWidth={2} aria-hidden="true" />
|
||||
: !ready
|
||||
? <CircleAlert size={14} strokeWidth={2} aria-hidden="true" />
|
||||
: <CheckCircle2 size={14} strokeWidth={2} aria-hidden="true" />}
|
||||
{ready ? (instructionEnabled ? text.on : text.off) : unresolvedStatus}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -54,6 +54,7 @@ export type ProviderFormValue = {
|
||||
export type OfficialFormValue = {
|
||||
model: string;
|
||||
authJson: string;
|
||||
configText: string;
|
||||
};
|
||||
|
||||
export type ProviderCopy = {
|
||||
@@ -81,6 +82,7 @@ export type ProviderCopy = {
|
||||
authPathLabel: string;
|
||||
officialCurrentLabel: string;
|
||||
officialAuthLabel: string;
|
||||
officialTomlLabel: string;
|
||||
officialSaveLabel: string;
|
||||
restoreOfficialLabel: string;
|
||||
resetOfficialLabel: string;
|
||||
@@ -134,6 +136,7 @@ export type ProvidersPageProps = {
|
||||
providerForm: ProviderFormValue;
|
||||
officialForm: OfficialFormValue;
|
||||
officialAuthRef?: Ref<HTMLTextAreaElement>;
|
||||
officialTomlRef?: Ref<HTMLTextAreaElement>;
|
||||
officialInfo: ProviderOfficialInfo;
|
||||
providerAuthPreview: ReactNode;
|
||||
providerTomlDraft: string;
|
||||
@@ -152,6 +155,7 @@ export type ProvidersPageProps = {
|
||||
onCancelMode: () => void;
|
||||
onOfficialModelChange: (value: string) => void;
|
||||
onOfficialAuthChange: (value: string) => void;
|
||||
onOfficialConfigChange: (value: string) => void;
|
||||
onSaveOfficial: () => void;
|
||||
onApiKeyChange: (value: string) => void;
|
||||
onBaseUrlChange: (value: string) => void;
|
||||
@@ -239,6 +243,7 @@ function ListPage({
|
||||
}: 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);
|
||||
const providerActionsBusy = loading || Boolean(actionBusy);
|
||||
|
||||
const closeDeleteDialog = () => {
|
||||
if (!deleting) setProviderToDelete(null);
|
||||
@@ -267,12 +272,12 @@ function ListPage({
|
||||
type="button"
|
||||
className="cx-providers-button cx-providers-button--secondary"
|
||||
onClick={onImportCcSwitch}
|
||||
disabled={loading || Boolean(actionBusy)}
|
||||
disabled={providerActionsBusy}
|
||||
>
|
||||
{actionBusy === "importCcSwitch" ? <Loader2 size={15} className="cx-providers-spin" aria-hidden="true" /> : <RefreshCw size={15} aria-hidden="true" />}
|
||||
{copy.importLabel}
|
||||
</button>
|
||||
<button type="button" className="cx-providers-button cx-providers-button--dark" onClick={onAddProvider} disabled={loading}>
|
||||
<button type="button" className="cx-providers-button cx-providers-button--dark" onClick={onAddProvider} disabled={providerActionsBusy}>
|
||||
<Plus size={15} aria-hidden="true" />
|
||||
{copy.addLabel}
|
||||
</button>
|
||||
@@ -306,7 +311,7 @@ function ListPage({
|
||||
type="button"
|
||||
className="cx-providers-button cx-providers-button--small cx-providers-button--secondary"
|
||||
onClick={() => onEnableProvider(row)}
|
||||
disabled={loading || row.isCurrent}
|
||||
disabled={providerActionsBusy || row.isCurrent}
|
||||
>
|
||||
{copy.enableLabel}
|
||||
</button>
|
||||
@@ -315,7 +320,7 @@ function ListPage({
|
||||
icon={RotateCcw}
|
||||
label={copy.restoreOfficialLabel}
|
||||
onClick={onRestoreOfficial}
|
||||
disabled={loading}
|
||||
disabled={providerActionsBusy}
|
||||
/>
|
||||
)}
|
||||
{row.testable !== false && (
|
||||
@@ -323,14 +328,14 @@ function ListPage({
|
||||
icon={isTesting ? Loader2 : Activity}
|
||||
label={copy.testLabel}
|
||||
onClick={() => onTestProvider(row)}
|
||||
disabled={loading || isTesting}
|
||||
disabled={providerActionsBusy || isTesting}
|
||||
/>
|
||||
)}
|
||||
{row.editable !== false && (
|
||||
<ActionIconButton icon={PencilLine} label={copy.editLabel} onClick={() => onEditProvider(row)} disabled={loading} />
|
||||
<ActionIconButton icon={PencilLine} label={copy.editLabel} onClick={() => onEditProvider(row)} disabled={providerActionsBusy} />
|
||||
)}
|
||||
{row.deletable && (
|
||||
<ActionIconButton icon={Trash2} label={copy.removeLabel} onClick={() => setProviderToDelete(row)} disabled={loading} danger />
|
||||
<ActionIconButton icon={Trash2} label={copy.removeLabel} onClick={() => setProviderToDelete(row)} disabled={providerActionsBusy} danger />
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
@@ -374,7 +379,7 @@ function ListPage({
|
||||
);
|
||||
}
|
||||
|
||||
function ModeHeader({ eyebrow, title, description, cancelLabel, onCancel }: { eyebrow: string; title: string; description: string; cancelLabel: string; onCancel: () => void }) {
|
||||
function ModeHeader({ eyebrow, title, description, cancelLabel, onCancel, disabled = false }: { eyebrow: string; title: string; description: string; cancelLabel: string; onCancel: () => void; disabled?: boolean }) {
|
||||
return (
|
||||
<header className="cx-providers-form-header">
|
||||
<div>
|
||||
@@ -382,7 +387,7 @@ function ModeHeader({ eyebrow, title, description, cancelLabel, onCancel }: { ey
|
||||
<h2>{title}</h2>
|
||||
<p>{description}</p>
|
||||
</div>
|
||||
<button type="button" className="cx-providers-button cx-providers-button--secondary" onClick={onCancel}>
|
||||
<button type="button" className="cx-providers-button cx-providers-button--secondary" onClick={onCancel} disabled={disabled}>
|
||||
<ArrowLeft size={15} aria-hidden="true" />
|
||||
{cancelLabel}
|
||||
</button>
|
||||
@@ -394,16 +399,20 @@ function OfficialForm({
|
||||
copy,
|
||||
officialForm,
|
||||
officialAuthRef,
|
||||
officialTomlRef,
|
||||
officialInfo,
|
||||
loading,
|
||||
actionBusy,
|
||||
onCancelMode,
|
||||
onOfficialModelChange,
|
||||
onOfficialAuthChange,
|
||||
onOfficialConfigChange,
|
||||
onSaveOfficial,
|
||||
onRestoreOfficial,
|
||||
onResetOfficial,
|
||||
}: Pick<ProvidersPageProps, "copy" | "officialForm" | "officialAuthRef" | "officialInfo" | "loading" | "onCancelMode" | "onOfficialModelChange" | "onOfficialAuthChange" | "onSaveOfficial" | "onRestoreOfficial" | "onResetOfficial">) {
|
||||
}: Pick<ProvidersPageProps, "copy" | "officialForm" | "officialAuthRef" | "officialTomlRef" | "officialInfo" | "loading" | "actionBusy" | "onCancelMode" | "onOfficialModelChange" | "onOfficialAuthChange" | "onOfficialConfigChange" | "onSaveOfficial" | "onRestoreOfficial" | "onResetOfficial">) {
|
||||
const [resetConfirmOpen, setResetConfirmOpen] = useState(false);
|
||||
const formBusy = loading || actionBusy === "loadOfficialDraft";
|
||||
|
||||
const confirmReset = () => {
|
||||
setResetConfirmOpen(false);
|
||||
@@ -412,33 +421,44 @@ function OfficialForm({
|
||||
|
||||
return (
|
||||
<>
|
||||
<ModeHeader eyebrow={copy.officialEyebrow} title={copy.officialTitle} description={copy.officialHint} cancelLabel={copy.cancelLabel} onCancel={onCancelMode} />
|
||||
<ModeHeader eyebrow={copy.officialEyebrow} title={copy.officialTitle} description={copy.officialHint} cancelLabel={copy.cancelLabel} onCancel={onCancelMode} disabled={formBusy} />
|
||||
<div className="cx-providers-info-grid">
|
||||
<div><span>{copy.officialUrlLabel}</span><code>{officialInfo.officialUrl}</code></div>
|
||||
<div><span>{copy.authPathLabel}</span><code>{officialInfo.authPath}</code></div>
|
||||
<div><span>{copy.officialCurrentLabel}</span><code>{officialInfo.current}</code></div>
|
||||
</div>
|
||||
<div className="cx-providers-form-grid cx-providers-form-grid--single">
|
||||
<Field label={copy.modelLabel}><input value={officialForm.model} onChange={(event) => onOfficialModelChange(event.target.value)} /></Field>
|
||||
<Field label={copy.modelLabel}><input value={officialForm.model} onChange={(event) => onOfficialModelChange(event.target.value)} disabled={formBusy} /></Field>
|
||||
</div>
|
||||
<Field label={copy.officialTomlLabel} className="cx-providers-editor-field">
|
||||
<textarea
|
||||
ref={officialTomlRef}
|
||||
className="cx-providers-code-editor cx-providers-toml-editor"
|
||||
value={officialForm.configText}
|
||||
onChange={(event) => onOfficialConfigChange(event.target.value)}
|
||||
disabled={formBusy}
|
||||
spellCheck={false}
|
||||
/>
|
||||
</Field>
|
||||
<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)}
|
||||
disabled={formBusy}
|
||||
wrap="soft"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</Field>
|
||||
<div className="cx-providers-form-actions cx-providers-form-actions--save cx-providers-official-actions">
|
||||
<button type="button" className="cx-providers-button cx-providers-button--secondary" onClick={onRestoreOfficial} disabled={loading}>
|
||||
<button type="button" className="cx-providers-button cx-providers-button--secondary" onClick={onRestoreOfficial} disabled={formBusy}>
|
||||
<RotateCcw size={15} aria-hidden="true" />{copy.restoreOfficialLabel}
|
||||
</button>
|
||||
<button type="button" className="cx-providers-button cx-providers-button--secondary" onClick={() => setResetConfirmOpen(true)} disabled={loading}>
|
||||
<button type="button" className="cx-providers-button cx-providers-button--secondary" onClick={() => setResetConfirmOpen(true)} disabled={formBusy}>
|
||||
<FilePlus2 size={15} aria-hidden="true" />{copy.resetOfficialLabel}
|
||||
</button>
|
||||
<button type="button" className="cx-providers-button cx-providers-button--primary" onClick={onSaveOfficial} disabled={loading}><CheckCircle2 size={15} aria-hidden="true" />{copy.officialSaveLabel}</button>
|
||||
<button type="button" className="cx-providers-button cx-providers-button--primary" onClick={onSaveOfficial} disabled={formBusy}><CheckCircle2 size={15} aria-hidden="true" />{copy.officialSaveLabel}</button>
|
||||
</div>
|
||||
<ModalShell
|
||||
open={resetConfirmOpen}
|
||||
@@ -456,7 +476,7 @@ function OfficialForm({
|
||||
>
|
||||
<div className="cx-provider-delete-warning">
|
||||
<span aria-hidden="true"><AlertTriangle size={22} /></span>
|
||||
<strong>auth.json</strong>
|
||||
<strong>config.toml + auth.json</strong>
|
||||
</div>
|
||||
</ModalShell>
|
||||
</>
|
||||
@@ -489,6 +509,7 @@ function ProviderForm({
|
||||
}: Pick<ProvidersPageProps, "copy" | "providerForm" | "loading" | "editingProviderId" | "providerAuthPreview" | "providerTomlDraft" | "providerTomlRef" | "apiKeyVisible" | "availableModels" | "fetchingModels" | "onCancelMode" | "onApiKeyChange" | "onBaseUrlChange" | "onProviderNameChange" | "onProviderModelChange" | "onFetchModels" | "onWireApiChange" | "onRequiresAuthChange" | "onToggleApiKeyVisibility" | "onProviderTomlDraftChange" | "onResetProviderToml" | "onSaveProvider">) {
|
||||
const modelListId = useId();
|
||||
const canFetchModels = Boolean(providerForm.baseUrl.trim() && providerForm.apiKey.trim());
|
||||
const formBusy = loading || fetchingModels;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -498,6 +519,7 @@ function ProviderForm({
|
||||
description={copy.formHint}
|
||||
cancelLabel={copy.cancelLabel}
|
||||
onCancel={onCancelMode}
|
||||
disabled={formBusy}
|
||||
/>
|
||||
|
||||
<section className="cx-providers-form-section">
|
||||
@@ -512,14 +534,15 @@ function ProviderForm({
|
||||
value={providerForm.apiKey}
|
||||
onChange={(event) => onApiKeyChange(event.target.value)}
|
||||
placeholder={copy.apiKeyPlaceholder}
|
||||
disabled={formBusy}
|
||||
/>
|
||||
<button type="button" onClick={onToggleApiKeyVisibility} title={apiKeyVisible ? copy.hideApiKeyLabel : copy.showApiKeyLabel} aria-label={apiKeyVisible ? copy.hideApiKeyLabel : copy.showApiKeyLabel}>
|
||||
<button type="button" onClick={onToggleApiKeyVisibility} title={apiKeyVisible ? copy.hideApiKeyLabel : copy.showApiKeyLabel} aria-label={apiKeyVisible ? copy.hideApiKeyLabel : copy.showApiKeyLabel} disabled={formBusy}>
|
||||
{apiKeyVisible ? <EyeOff size={15} aria-hidden="true" /> : <Eye size={15} aria-hidden="true" />}
|
||||
</button>
|
||||
</div>
|
||||
</Field>
|
||||
<Field label={copy.baseUrlLabel} className="cx-providers-field--full"><input value={providerForm.baseUrl} onChange={(event) => onBaseUrlChange(event.target.value)} /></Field>
|
||||
<Field label={copy.nameLabel}><input value={providerForm.providerName} onChange={(event) => onProviderNameChange(event.target.value)} /></Field>
|
||||
<Field label={copy.baseUrlLabel} className="cx-providers-field--full"><input value={providerForm.baseUrl} onChange={(event) => onBaseUrlChange(event.target.value)} disabled={formBusy} /></Field>
|
||||
<Field label={copy.nameLabel}><input value={providerForm.providerName} onChange={(event) => onProviderNameChange(event.target.value)} disabled={formBusy} /></Field>
|
||||
<Field label={copy.modelLabel}>
|
||||
<div className="cx-providers-model-input-row">
|
||||
<input
|
||||
@@ -527,6 +550,7 @@ function ProviderForm({
|
||||
list={availableModels.length ? modelListId : undefined}
|
||||
aria-label={copy.modelLabel}
|
||||
onChange={(event) => onProviderModelChange(event.target.value)}
|
||||
disabled={formBusy}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@@ -551,6 +575,7 @@ function ProviderForm({
|
||||
className="cx-providers-model-select"
|
||||
value=""
|
||||
aria-label={copy.chooseModelLabel(availableModels.length)}
|
||||
disabled={formBusy}
|
||||
onChange={(event) => {
|
||||
if (event.target.value) onProviderModelChange(event.target.value);
|
||||
}}
|
||||
@@ -562,7 +587,7 @@ function ProviderForm({
|
||||
)}
|
||||
</Field>
|
||||
<Field label={copy.wireApiLabel}>
|
||||
<select value={providerForm.wireApi} onChange={(event) => onWireApiChange(event.target.value)}>
|
||||
<select value={providerForm.wireApi} onChange={(event) => onWireApiChange(event.target.value)} disabled={formBusy}>
|
||||
<option value="responses">responses</option>
|
||||
<option value="chat">chat</option>
|
||||
</select>
|
||||
@@ -572,6 +597,7 @@ function ProviderForm({
|
||||
checked={providerForm.requiresOpenaiAuth}
|
||||
onCheckedChange={onRequiresAuthChange}
|
||||
label={copy.requiresAuthLabel}
|
||||
disabled={formBusy}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
@@ -584,13 +610,13 @@ function ProviderForm({
|
||||
<section className="cx-providers-form-section">
|
||||
<div className="cx-providers-section-heading cx-providers-section-heading--with-action">
|
||||
<div><h3>{copy.tomlTitle}</h3><p>{copy.tomlDescription}</p></div>
|
||||
<button type="button" className="cx-providers-button cx-providers-button--secondary cx-providers-button--small" onClick={onResetProviderToml}><RefreshCw size={14} aria-hidden="true" />{copy.resetTomlLabel}</button>
|
||||
<button type="button" className="cx-providers-button cx-providers-button--secondary cx-providers-button--small" onClick={onResetProviderToml} disabled={formBusy}><RefreshCw size={14} aria-hidden="true" />{copy.resetTomlLabel}</button>
|
||||
</div>
|
||||
<textarea ref={providerTomlRef} className="cx-providers-code-editor cx-providers-toml-editor" value={providerTomlDraft} onChange={(event) => onProviderTomlDraftChange(event.target.value)} spellCheck={false} />
|
||||
<textarea ref={providerTomlRef} className="cx-providers-code-editor cx-providers-toml-editor" value={providerTomlDraft} onChange={(event) => onProviderTomlDraftChange(event.target.value)} disabled={formBusy} spellCheck={false} />
|
||||
</section>
|
||||
|
||||
<div className="cx-providers-form-actions cx-providers-form-actions--save">
|
||||
<button type="button" className="cx-providers-button cx-providers-button--primary" onClick={onSaveProvider} disabled={loading}>
|
||||
<button type="button" className="cx-providers-button cx-providers-button--primary" onClick={onSaveProvider} disabled={formBusy}>
|
||||
{loading ? <Loader2 size={15} className="cx-providers-spin" aria-hidden="true" /> : <CheckCircle2 size={15} aria-hidden="true" />}
|
||||
{loading ? copy.savingLabel : copy.saveLabel}
|
||||
</button>
|
||||
|
||||
@@ -1,475 +0,0 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import type { CSSProperties, ChangeEvent, RefObject } from "react";
|
||||
import { convertFileSrc } from "@tauri-apps/api/core";
|
||||
import {
|
||||
Check,
|
||||
Download,
|
||||
ImagePlus,
|
||||
Loader2,
|
||||
Palette,
|
||||
Pencil,
|
||||
Play,
|
||||
Power,
|
||||
RefreshCw,
|
||||
Settings2,
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
|
||||
import { PromptCategoryManager } from "../components/PromptCategoryManager";
|
||||
import type { PromptCategoryItem } from "../components/PromptCategoryManager";
|
||||
import { PageTransition } from "../components/PageTransition";
|
||||
import { SkinThemeEditor } from "../components/SkinThemeEditor";
|
||||
import { Button, IconButton, StatusBadge, cx } from "../components/ui";
|
||||
import { useManagedCategories } from "../promptCategories";
|
||||
import type { ManagedCategoryOptions } from "../promptCategories";
|
||||
import type { Lang, SkinCenterState, SkinThemeSummary } from "../types";
|
||||
import "../styles/skins-page.css";
|
||||
|
||||
type MaybeAsyncAction = () => void | Promise<void>;
|
||||
|
||||
const SKIN_CATEGORY_OPTIONS: ManagedCategoryOptions = {
|
||||
storageKey: "codexx.skinCategories.v1",
|
||||
defaultCategories: (lang) => lang === "zh"
|
||||
? [
|
||||
{ id: "anime", name: "动漫" },
|
||||
{ id: "scenery", name: "风景" },
|
||||
{ id: "minimal", name: "简约" },
|
||||
]
|
||||
: [
|
||||
{ id: "anime", name: "Anime" },
|
||||
{ id: "scenery", name: "Scenery" },
|
||||
{ id: "minimal", name: "Minimal" },
|
||||
],
|
||||
};
|
||||
|
||||
export type SkinsPageProps = {
|
||||
lang: Lang;
|
||||
state: SkinCenterState | null;
|
||||
actionBusy: string;
|
||||
pauseBusy: boolean;
|
||||
zipInputRef: RefObject<HTMLInputElement>;
|
||||
imageInputRef: RefObject<HTMLInputElement>;
|
||||
onLoad: MaybeAsyncAction;
|
||||
onImportZip: (file?: File | null) => Promise<string | null>;
|
||||
onCreateFromImage: (file?: File | null) => Promise<string | null>;
|
||||
onUpdateThemeSettings: (id: string, name: string, tagline: string, surfaceOpacity: number) => Promise<boolean>;
|
||||
onEnableTheme: (id: string) => void | Promise<void>;
|
||||
onExportTheme: (id: string) => void | Promise<void>;
|
||||
onPauseTheme: MaybeAsyncAction;
|
||||
};
|
||||
|
||||
function getCopy(lang: Lang) {
|
||||
return lang === "zh"
|
||||
? {
|
||||
eyebrow: "SKIN CENTER",
|
||||
title: "皮肤中心",
|
||||
description: "在 Codex-X 中管理主题包,并直接应用到官方 Codex Desktop。",
|
||||
refresh: "刷新",
|
||||
refreshingButton: "刷新中",
|
||||
refreshing: "正在刷新主题库...",
|
||||
importZip: "导入主题包",
|
||||
createFromImage: "从图片创建皮肤",
|
||||
creatingFromImage: "正在创建",
|
||||
pause: "关闭皮肤",
|
||||
pausing: "关闭中",
|
||||
loading: "正在读取皮肤库...",
|
||||
builtin: "内置",
|
||||
imported: "导入",
|
||||
selected: "已选择",
|
||||
applied: "已启用",
|
||||
apply: "启用",
|
||||
export: "导出",
|
||||
edit: "编辑",
|
||||
applying: "启用中",
|
||||
exporting: "导出中",
|
||||
current: "当前皮肤",
|
||||
runtime: "Codex 运行状态",
|
||||
noCurrent: "尚未启用皮肤",
|
||||
noThemes: "还没有皮肤。选择一张图片即可创建。",
|
||||
manageCategories: "分类管理",
|
||||
emptyCategory: "该分类下暂无皮肤",
|
||||
statusReady: "主题库已就绪",
|
||||
selectTheme: (name: string) => `选择皮肤:${name}`,
|
||||
}
|
||||
: {
|
||||
eyebrow: "SKIN CENTER",
|
||||
title: "Skin Center",
|
||||
description: "Manage theme packs in Codex-X and apply them directly to the official Codex Desktop app.",
|
||||
refresh: "Refresh",
|
||||
refreshingButton: "Refreshing",
|
||||
refreshing: "Refreshing skin library...",
|
||||
importZip: "Import theme ZIP",
|
||||
createFromImage: "Create from image",
|
||||
creatingFromImage: "Creating",
|
||||
pause: "Turn off skin",
|
||||
pausing: "Turning off",
|
||||
loading: "Loading skin library...",
|
||||
builtin: "Built-in",
|
||||
imported: "Imported",
|
||||
selected: "Selected",
|
||||
applied: "Enabled",
|
||||
apply: "Enable",
|
||||
export: "Export",
|
||||
edit: "Edit",
|
||||
applying: "Enabling",
|
||||
exporting: "Exporting",
|
||||
current: "Current skin",
|
||||
runtime: "Codex runtime",
|
||||
noCurrent: "No skin enabled yet",
|
||||
noThemes: "No skins yet. Choose an image to create one.",
|
||||
manageCategories: "Manage categories",
|
||||
emptyCategory: "No skins in this category",
|
||||
statusReady: "Theme library ready",
|
||||
selectTheme: (name: string) => `Select skin: ${name}`,
|
||||
};
|
||||
}
|
||||
|
||||
function run(action: MaybeAsyncAction) {
|
||||
void action();
|
||||
}
|
||||
|
||||
function sourceLabel(theme: SkinThemeSummary, copy: ReturnType<typeof getCopy>) {
|
||||
return theme.source === "builtin" ? copy.builtin : copy.imported;
|
||||
}
|
||||
|
||||
function ThemeCard({
|
||||
theme,
|
||||
copy,
|
||||
runtimeActive,
|
||||
activeThemeId,
|
||||
selected,
|
||||
onSelect,
|
||||
}: {
|
||||
theme: SkinThemeSummary;
|
||||
copy: ReturnType<typeof getCopy>;
|
||||
runtimeActive: boolean;
|
||||
activeThemeId?: string | null;
|
||||
selected: boolean;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
const applied = runtimeActive && activeThemeId === theme.id;
|
||||
const focusX = Math.round((theme.art.focusX ?? 0.5) * 100);
|
||||
const focusY = Math.round((theme.art.focusY ?? 0.5) * 100);
|
||||
const safeArea = theme.art.safeArea === "left" || theme.art.safeArea === "right" || theme.art.safeArea === "center"
|
||||
? theme.art.safeArea
|
||||
: "center";
|
||||
const style = {
|
||||
"--skin-image": `url("${convertFileSrc(theme.imagePath || `${theme.directory}/${theme.image}`)}")`,
|
||||
"--skin-bg": theme.colors.background,
|
||||
"--skin-panel": theme.colors.panel,
|
||||
"--skin-accent": theme.colors.accent,
|
||||
"--skin-secondary": theme.colors.secondary,
|
||||
"--skin-highlight": theme.colors.highlight,
|
||||
"--skin-text": theme.colors.text,
|
||||
"--skin-muted": theme.colors.muted,
|
||||
"--skin-line": theme.colors.line,
|
||||
"--skin-focus-x": `${focusX}%`,
|
||||
"--skin-focus-y": `${focusY}%`,
|
||||
} as CSSProperties;
|
||||
|
||||
return (
|
||||
<article
|
||||
className={cx(
|
||||
"cx-skins-card",
|
||||
(theme.enabled || applied) && "cx-skins-card--enabled",
|
||||
selected && "cx-skins-card--selected",
|
||||
)}
|
||||
style={style}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-pressed={selected}
|
||||
aria-label={copy.selectTheme(theme.name)}
|
||||
onClick={onSelect}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
onSelect();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className={cx("cx-skins-preview", theme.adaptive && "cx-skins-preview--adaptive")} aria-hidden="true">
|
||||
<div className={cx("cx-skins-preview-app", `cx-skins-preview-app--${safeArea}`)}>
|
||||
<aside className="cx-skins-preview-sidebar">
|
||||
<div className="cx-skins-preview-brand"><i /><span /></div>
|
||||
<div className="cx-skins-preview-nav">
|
||||
<span /><span className="is-active" /><span /><span />
|
||||
</div>
|
||||
<div className="cx-skins-preview-profile" />
|
||||
</aside>
|
||||
<div className="cx-skins-preview-main">
|
||||
<div className="cx-skins-preview-topbar"><span /><i /></div>
|
||||
<div className="cx-skins-preview-chat">
|
||||
<div className="cx-skins-preview-message"><i /><span /></div>
|
||||
<div className="cx-skins-preview-message cx-skins-preview-message--user"><span /></div>
|
||||
</div>
|
||||
<div className="cx-skins-preview-composer"><span /><i /></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="cx-skins-card-body">
|
||||
<div className="cx-skins-card-title">
|
||||
<div>
|
||||
<strong>{theme.name}</strong>
|
||||
<span>{theme.tagline}</span>
|
||||
</div>
|
||||
<StatusBadge tone={applied ? "success" : theme.enabled ? "info" : "neutral"} dot={applied || theme.enabled}>
|
||||
{applied ? copy.applied : theme.enabled ? copy.selected : sourceLabel(theme, copy)}
|
||||
</StatusBadge>
|
||||
</div>
|
||||
<p aria-hidden={!theme.quote}>{theme.quote || "\u00a0"}</p>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
export function SkinsPage({
|
||||
lang,
|
||||
state,
|
||||
actionBusy,
|
||||
pauseBusy,
|
||||
zipInputRef,
|
||||
imageInputRef,
|
||||
onLoad,
|
||||
onImportZip,
|
||||
onCreateFromImage,
|
||||
onUpdateThemeSettings,
|
||||
onEnableTheme,
|
||||
onExportTheme,
|
||||
onPauseTheme,
|
||||
}: SkinsPageProps) {
|
||||
const copy = getCopy(lang);
|
||||
const themes = state?.themes ?? [];
|
||||
const current = themes.find((theme) => theme.enabled);
|
||||
const loading = actionBusy === "loadSkins";
|
||||
const controlsBusy = Boolean(actionBusy) || pauseBusy;
|
||||
const runtime = state?.runtime;
|
||||
const [categoryManagerOpen, setCategoryManagerOpen] = useState(false);
|
||||
const [editorOpen, setEditorOpen] = useState(false);
|
||||
const [selectedThemeId, setSelectedThemeId] = useState<string | null>(null);
|
||||
const skinCategories = useManagedCategories(lang, SKIN_CATEGORY_OPTIONS);
|
||||
const categoryItems = useMemo<PromptCategoryItem[]>(() => themes.map((theme) => ({
|
||||
key: `skin:${theme.id.trim().toLowerCase()}`,
|
||||
title: theme.name,
|
||||
})), [themes]);
|
||||
const visibleThemes = themes.filter((theme) =>
|
||||
skinCategories.categoryForPrompt(`skin:${theme.id.trim().toLowerCase()}`) === skinCategories.activeCategoryId);
|
||||
const selectedTheme = themes.find((theme) => theme.id === selectedThemeId);
|
||||
const selectedThemeApplied = Boolean(
|
||||
selectedTheme && runtime?.active && runtime.themeId === selectedTheme.id,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedThemeId && !themes.some((theme) => theme.id === selectedThemeId)) {
|
||||
setSelectedThemeId(null);
|
||||
}
|
||||
}, [selectedThemeId, themes]);
|
||||
|
||||
const selectCreatedTheme = (themeId: string | null) => {
|
||||
if (!themeId) return;
|
||||
const categoryId = skinCategories.categoryForPrompt(`skin:${themeId.trim().toLowerCase()}`);
|
||||
skinCategories.setActiveCategoryId(categoryId);
|
||||
setSelectedThemeId(themeId);
|
||||
};
|
||||
|
||||
const handleZipChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
void onImportZip(event.currentTarget.files?.[0] ?? null).then(selectCreatedTheme);
|
||||
};
|
||||
|
||||
const handleImageChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
void onCreateFromImage(event.currentTarget.files?.[0] ?? null).then(selectCreatedTheme);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className={cx("cx-skins-page", loading && "cx-skins-page--refreshing")} aria-busy={loading}>
|
||||
<PromptCategoryManager
|
||||
open={categoryManagerOpen}
|
||||
lang={lang}
|
||||
categories={skinCategories.categories}
|
||||
prompts={categoryItems}
|
||||
categoryForPrompt={skinCategories.categoryForPrompt}
|
||||
onClose={() => setCategoryManagerOpen(false)}
|
||||
onAddCategory={skinCategories.addCategory}
|
||||
onRenameCategory={skinCategories.renameCategory}
|
||||
onDeleteCategory={skinCategories.deleteCategory}
|
||||
onMovePrompt={skinCategories.movePrompt}
|
||||
subject="skin"
|
||||
/>
|
||||
<SkinThemeEditor
|
||||
lang={lang}
|
||||
open={editorOpen}
|
||||
theme={selectedTheme || null}
|
||||
busy={Boolean(selectedTheme && actionBusy === `skinEdit:${selectedTheme.id}`)}
|
||||
onClose={() => setEditorOpen(false)}
|
||||
onSave={onUpdateThemeSettings}
|
||||
/>
|
||||
<input
|
||||
ref={zipInputRef}
|
||||
className="cx-skins-file-input"
|
||||
type="file"
|
||||
accept=".zip,application/zip"
|
||||
onChange={handleZipChange}
|
||||
/>
|
||||
<input
|
||||
ref={imageInputRef}
|
||||
className="cx-skins-file-input"
|
||||
type="file"
|
||||
accept=".png,.jpg,.jpeg,.webp,image/png,image/jpeg,image/webp"
|
||||
onChange={handleImageChange}
|
||||
/>
|
||||
|
||||
<header className="cx-skins-header">
|
||||
<div className="cx-skins-heading">
|
||||
<p><Palette size={15} aria-hidden="true" />{copy.eyebrow}</p>
|
||||
<h2>{copy.title}</h2>
|
||||
<span>{copy.description}</span>
|
||||
</div>
|
||||
<div className="cx-skins-actions">
|
||||
{selectedTheme && (
|
||||
<>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => setEditorOpen(true)}
|
||||
disabled={controlsBusy}
|
||||
icon={<Pencil size={15} />}
|
||||
>
|
||||
{copy.edit}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => run(() => onEnableTheme(selectedTheme.id))}
|
||||
disabled={controlsBusy || selectedThemeApplied}
|
||||
icon={actionBusy === `skin:${selectedTheme.id}`
|
||||
? <Loader2 className="cx-skins-spin" size={15} />
|
||||
: selectedThemeApplied ? <Check size={15} /> : <Play size={15} />}
|
||||
>
|
||||
{actionBusy === `skin:${selectedTheme.id}`
|
||||
? copy.applying
|
||||
: selectedThemeApplied ? copy.applied : copy.apply}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => run(() => onExportTheme(selectedTheme.id))}
|
||||
disabled={controlsBusy}
|
||||
icon={actionBusy === `skinExport:${selectedTheme.id}`
|
||||
? <Loader2 className="cx-skins-spin" size={15} />
|
||||
: <Download size={15} />}
|
||||
>
|
||||
{actionBusy === `skinExport:${selectedTheme.id}` ? copy.exporting : copy.export}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => run(onPauseTheme)}
|
||||
disabled={pauseBusy}
|
||||
icon={pauseBusy ? <Loader2 className="cx-skins-spin" size={15} /> : <Power size={15} />}
|
||||
>
|
||||
{pauseBusy ? copy.pausing : copy.pause}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => zipInputRef.current?.click()}
|
||||
disabled={controlsBusy || !state}
|
||||
icon={actionBusy === "importSkinZip" ? <Loader2 className="cx-skins-spin" size={15} /> : <Upload size={15} />}
|
||||
>
|
||||
{copy.importZip}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => imageInputRef.current?.click()}
|
||||
disabled={controlsBusy || !state}
|
||||
icon={actionBusy === "createSkinFromImage"
|
||||
? <Loader2 className="cx-skins-spin" size={15} />
|
||||
: <ImagePlus size={15} />}
|
||||
>
|
||||
{actionBusy === "createSkinFromImage" ? copy.creatingFromImage : copy.createFromImage}
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="cx-skins-status" aria-label={copy.statusReady}>
|
||||
<div>
|
||||
<span>{copy.current}</span>
|
||||
<strong>{current?.name || copy.noCurrent}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>{copy.runtime}</span>
|
||||
<strong title={runtime?.message || ""}>{runtime?.message || "-"}</strong>
|
||||
</div>
|
||||
<IconButton
|
||||
className="cx-skins-refresh-button"
|
||||
icon={loading ? <Loader2 className="cx-skins-spin" /> : <RefreshCw />}
|
||||
label={loading ? copy.refreshingButton : copy.refresh}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => run(onLoad)}
|
||||
disabled={controlsBusy}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{loading && state && (
|
||||
<div className="cx-skins-refresh-status" role="status" aria-live="polite">
|
||||
<Loader2 className="cx-skins-spin" size={15} aria-hidden="true" />
|
||||
<span>{copy.refreshing}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{themes.length > 0 && (
|
||||
<div className="cx-prompt-category-toolbar cx-skins-category-toolbar">
|
||||
<div className="cx-skills-tabs cx-prompt-category-tabs" role="group" aria-label={copy.manageCategories}>
|
||||
{skinCategories.categories.map((category) => (
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={category.id === skinCategories.activeCategoryId}
|
||||
className={cx(
|
||||
"cx-skills-tab",
|
||||
"cx-prompt-category-tab",
|
||||
category.id === skinCategories.activeCategoryId && "cx-skills-tab--active",
|
||||
)}
|
||||
key={category.id}
|
||||
onClick={() => {
|
||||
setSelectedThemeId(null);
|
||||
skinCategories.setActiveCategoryId(category.id);
|
||||
}}
|
||||
>
|
||||
{category.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button type="button" className="cx-prompt-category-manage" onClick={() => setCategoryManagerOpen(true)}>
|
||||
<Settings2 size={16} aria-hidden="true" />{copy.manageCategories}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<PageTransition pageKey={`skins-category:${skinCategories.activeCategoryId}`}>
|
||||
{themes.length === 0 ? (
|
||||
<div className="cx-skins-empty">
|
||||
<Palette size={24} strokeWidth={1.7} aria-hidden="true" />
|
||||
<span>{loading ? copy.loading : copy.noThemes}</span>
|
||||
</div>
|
||||
) : visibleThemes.length === 0 ? (
|
||||
<div className="cx-skins-empty cx-skins-empty--category">
|
||||
<Palette size={24} strokeWidth={1.7} aria-hidden="true" />
|
||||
<span>{copy.emptyCategory}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className={cx("cx-skins-grid", `cx-skins-grid--${Math.min(visibleThemes.length, 4)}`)}>
|
||||
{visibleThemes.map((theme) => (
|
||||
<ThemeCard
|
||||
key={theme.id}
|
||||
theme={theme}
|
||||
copy={copy}
|
||||
runtimeActive={Boolean(runtime?.active)}
|
||||
activeThemeId={runtime?.themeId}
|
||||
selected={theme.id === selectedThemeId}
|
||||
onSelect={() => setSelectedThemeId(theme.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</PageTransition>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -355,28 +355,6 @@
|
||||
animation: cx-app-dialog-spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
.cx-skin-restart-warning {
|
||||
display: grid;
|
||||
grid-template-columns: 24px minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
align-items: start;
|
||||
padding: 12px;
|
||||
border: 1px solid #ead6a9;
|
||||
border-radius: 8px;
|
||||
color: #76531c;
|
||||
background: #fff9eb;
|
||||
}
|
||||
|
||||
.cx-skin-restart-warning svg {
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.cx-skin-restart-warning p {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
@keyframes cx-app-dialog-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@@ -426,6 +426,26 @@
|
||||
transform-origin: center top;
|
||||
}
|
||||
|
||||
.cx-state-loading {
|
||||
min-width: 0;
|
||||
min-height: 280px;
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
color: var(--ui-text-muted);
|
||||
font-size: 13px;
|
||||
font-weight: 620;
|
||||
line-height: 1.45;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.cx-state-loading svg {
|
||||
flex: 0 0 auto;
|
||||
color: var(--ui-accent);
|
||||
}
|
||||
|
||||
.cx-page-transition--exit,
|
||||
.cx-page-transition--enter {
|
||||
will-change: opacity, transform;
|
||||
|
||||
@@ -345,67 +345,6 @@
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
/* Skins */
|
||||
:root[data-theme="dark"] .cx-skins-page {
|
||||
--cx-skins-text: var(--cx-dark-text);
|
||||
--cx-skins-muted: var(--cx-dark-muted);
|
||||
--cx-skins-line: var(--cx-dark-border);
|
||||
--cx-skins-surface: var(--cx-dark-surface);
|
||||
--cx-skins-surface-muted: var(--cx-dark-surface-recessed);
|
||||
--cx-skins-blue: var(--cx-dark-blue);
|
||||
--cx-skins-blue-soft: var(--cx-dark-blue-surface);
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .cx-skins-header {
|
||||
border-color: var(--cx-dark-border);
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .cx-skins-heading h2,
|
||||
:root[data-theme="dark"] .cx-skins-status strong,
|
||||
:root[data-theme="dark"] .cx-skins-card-title strong {
|
||||
color: #f1f2f4;
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .cx-skins-status,
|
||||
:root[data-theme="dark"] .cx-skins-card {
|
||||
background: var(--cx-dark-surface);
|
||||
border-color: var(--cx-dark-border);
|
||||
box-shadow: 0 5px 18px rgba(0, 0, 0, 0.14);
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .cx-skins-card--enabled {
|
||||
border-color: color-mix(in srgb, var(--skin-accent) 48%, var(--cx-dark-border));
|
||||
box-shadow: 0 8px 24px color-mix(in srgb, var(--skin-accent) 18%, transparent);
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .cx-skins-card--selected {
|
||||
border-color: var(--cx-dark-blue);
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--cx-dark-blue) 28%, transparent),
|
||||
0 10px 26px rgba(0, 0, 0, 0.22);
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .cx-skins-card-body p {
|
||||
color: #c7cbd2;
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .cx-skins-empty {
|
||||
color: var(--cx-dark-muted);
|
||||
background: var(--cx-dark-surface-recessed);
|
||||
border-color: var(--cx-dark-border);
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .cx-skins-refresh-status {
|
||||
color: #a9c6ff;
|
||||
background: var(--cx-dark-blue-surface);
|
||||
border-color: #4d6691;
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .cx-skin-restart-warning {
|
||||
border-color: #6f5a2f;
|
||||
color: #ead8ac;
|
||||
background: #302817;
|
||||
}
|
||||
|
||||
/* Providers */
|
||||
:root[data-theme="dark"] .cx-providers {
|
||||
--cx-provider-text: var(--cx-dark-text);
|
||||
|
||||
@@ -714,6 +714,10 @@
|
||||
min-height: 360px;
|
||||
}
|
||||
|
||||
.cx-providers-editor-field + .cx-providers-editor-field {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.cx-providers-form-actions {
|
||||
padding: 2px 0 4px;
|
||||
}
|
||||
|
||||
@@ -1,648 +0,0 @@
|
||||
.cx-skins-page,
|
||||
.cx-skins-page * {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.cx-skins-pane {
|
||||
min-width: 0;
|
||||
min-height: 100%;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.cx-skins-page {
|
||||
--cx-skins-text: #202226;
|
||||
--cx-skins-muted: #737982;
|
||||
--cx-skins-line: #e1e4e8;
|
||||
--cx-skins-surface: #ffffff;
|
||||
--cx-skins-surface-muted: #f7f8fa;
|
||||
--cx-skins-blue: #2563eb;
|
||||
--cx-skins-blue-soft: #edf3ff;
|
||||
min-width: 0;
|
||||
min-height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
color: var(--cx-skins-text);
|
||||
}
|
||||
|
||||
.cx-skins-page .ui-button {
|
||||
min-height: 40px;
|
||||
padding-right: 15px;
|
||||
padding-left: 15px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 6px rgba(32, 34, 38, 0.06);
|
||||
font-size: 13px;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.cx-skins-page .ui-button--primary {
|
||||
background: var(--cx-skins-blue);
|
||||
border-color: var(--cx-skins-blue);
|
||||
box-shadow: 0 5px 12px rgba(37, 99, 235, 0.18);
|
||||
}
|
||||
|
||||
.cx-skins-header {
|
||||
min-width: 0;
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
padding: 4px 0 20px;
|
||||
border-bottom: 1px solid var(--cx-skins-line);
|
||||
}
|
||||
|
||||
.cx-skins-heading {
|
||||
min-width: 260px;
|
||||
}
|
||||
|
||||
.cx-skins-heading > p {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
margin: 0 0 7px;
|
||||
color: var(--cx-skins-blue);
|
||||
font-size: 11px;
|
||||
font-weight: 720;
|
||||
letter-spacing: 0;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.cx-skins-heading h2 {
|
||||
margin: 0;
|
||||
color: #181a1f;
|
||||
font-size: 24px;
|
||||
font-weight: 740;
|
||||
letter-spacing: 0;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.cx-skins-heading > span {
|
||||
display: block;
|
||||
max-width: 760px;
|
||||
margin-top: 7px;
|
||||
color: var(--cx-skins-muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.cx-skins-actions {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.cx-skins-file-input {
|
||||
position: fixed;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
clip-path: inset(50%);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cx-skins-spin {
|
||||
animation: cx-skins-spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
.cx-skins-status {
|
||||
min-height: 52px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
padding: 0 16px;
|
||||
border: 1px solid var(--cx-skins-line);
|
||||
border-radius: 8px;
|
||||
background: var(--cx-skins-surface-muted);
|
||||
}
|
||||
|
||||
.cx-skins-status > div {
|
||||
min-width: 0;
|
||||
flex: 1 1 0;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.cx-skins-status > div:first-child {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.cx-skins-status > div + div {
|
||||
border-left: 1px solid var(--cx-skins-line);
|
||||
}
|
||||
|
||||
.cx-skins-status span {
|
||||
color: var(--cx-skins-muted);
|
||||
font-size: 11px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.cx-skins-status strong {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: #252932;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cx-skins-refresh-button {
|
||||
margin-left: 8px;
|
||||
color: var(--cx-skins-muted);
|
||||
}
|
||||
|
||||
.cx-skins-refresh-status {
|
||||
min-height: 34px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0 11px;
|
||||
border: 1px solid #cdddfd;
|
||||
border-radius: 8px;
|
||||
color: #2454ad;
|
||||
background: #f3f7ff;
|
||||
font-size: 12px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.cx-skins-category-toolbar {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.cx-skins-grid {
|
||||
width: 100%;
|
||||
max-width: 280px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(230px, 1fr));
|
||||
align-items: start;
|
||||
justify-content: start;
|
||||
gap: 14px;
|
||||
padding-bottom: 28px;
|
||||
}
|
||||
|
||||
/* Preserve compact cards for small categories, then use the available row
|
||||
once four or more themes can fit without making the cards too narrow. */
|
||||
.cx-skins-grid--2 {
|
||||
max-width: 574px;
|
||||
}
|
||||
|
||||
.cx-skins-grid--3 {
|
||||
max-width: 868px;
|
||||
}
|
||||
|
||||
.cx-skins-grid--4 {
|
||||
max-width: 1162px;
|
||||
}
|
||||
|
||||
.cx-skins-card {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--cx-skins-line);
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 5px 18px rgba(27, 35, 48, 0.07);
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
transition: border-color 160ms ease, box-shadow 160ms ease, transform 160ms ease;
|
||||
}
|
||||
|
||||
.cx-skins-card:hover {
|
||||
border-color: #cfd5dd;
|
||||
box-shadow: 0 10px 26px rgba(27, 35, 48, 0.11);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.cx-skins-card--enabled {
|
||||
border-color: color-mix(in srgb, var(--skin-accent) 44%, #d8dee8);
|
||||
box-shadow: 0 8px 24px color-mix(in srgb, var(--skin-accent) 18%, transparent);
|
||||
}
|
||||
|
||||
.cx-skins-card--selected {
|
||||
border-color: var(--cx-skins-blue);
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--cx-skins-blue) 22%, transparent),
|
||||
0 10px 26px rgba(27, 35, 48, 0.11);
|
||||
}
|
||||
|
||||
.cx-skins-card:focus-visible {
|
||||
outline: 2px solid color-mix(in srgb, var(--cx-skins-blue) 76%, transparent);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
.cx-skins-preview {
|
||||
position: relative;
|
||||
aspect-ratio: 16 / 9;
|
||||
overflow: hidden;
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--skin-accent) 18%, #e7e9ed);
|
||||
background: var(--skin-image) var(--skin-focus-x) var(--skin-focus-y) / cover no-repeat, var(--skin-bg);
|
||||
}
|
||||
|
||||
.cx-skins-preview::after {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
content: "";
|
||||
pointer-events: none;
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.05), rgba(20, 23, 28, 0.06));
|
||||
}
|
||||
|
||||
.cx-skins-preview--adaptive .cx-skins-preview-app {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.cx-skins-preview--adaptive::after {
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.03), rgba(20, 23, 28, 0.08));
|
||||
}
|
||||
|
||||
.cx-skins-preview-app {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
top: 9px;
|
||||
bottom: 9px;
|
||||
width: 64%;
|
||||
display: grid;
|
||||
grid-template-columns: 42px minmax(0, 1fr);
|
||||
overflow: hidden;
|
||||
border: 1px solid color-mix(in srgb, var(--skin-line, var(--skin-accent)) 44%, rgba(255, 255, 255, 0.55));
|
||||
border-radius: 7px;
|
||||
box-shadow: 0 10px 28px rgba(25, 22, 30, 0.18);
|
||||
backdrop-filter: blur(7px) saturate(1.08);
|
||||
}
|
||||
|
||||
.cx-skins-preview-app--left {
|
||||
left: 9px;
|
||||
}
|
||||
|
||||
.cx-skins-preview-app--right {
|
||||
right: 9px;
|
||||
}
|
||||
|
||||
.cx-skins-preview-app--center {
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.cx-skins-preview-sidebar {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 6px 5px;
|
||||
border-right: 1px solid color-mix(in srgb, var(--skin-accent) 18%, transparent);
|
||||
background: color-mix(in srgb, var(--skin-panel) 82%, transparent);
|
||||
}
|
||||
|
||||
.cx-skins-preview-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 0 2px 4px;
|
||||
}
|
||||
|
||||
.cx-skins-preview-brand i {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
flex: 0 0 auto;
|
||||
border-radius: 3px;
|
||||
background: var(--skin-accent);
|
||||
box-shadow: 0 2px 7px color-mix(in srgb, var(--skin-accent) 38%, transparent);
|
||||
}
|
||||
|
||||
.cx-skins-preview-brand span {
|
||||
width: 19px;
|
||||
height: 3px;
|
||||
border-radius: 3px;
|
||||
background: color-mix(in srgb, var(--skin-text) 72%, transparent);
|
||||
}
|
||||
|
||||
.cx-skins-preview-nav {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.cx-skins-preview-nav span {
|
||||
height: 10px;
|
||||
border-radius: 3px;
|
||||
background: color-mix(in srgb, var(--skin-text) 9%, transparent);
|
||||
}
|
||||
|
||||
.cx-skins-preview-nav span.is-active {
|
||||
background: color-mix(in srgb, var(--skin-accent) 30%, transparent);
|
||||
box-shadow: inset 2px 0 var(--skin-accent);
|
||||
}
|
||||
|
||||
.cx-skins-preview-profile {
|
||||
width: 100%;
|
||||
height: 10px;
|
||||
margin-top: auto;
|
||||
border-radius: 3px;
|
||||
background: color-mix(in srgb, var(--skin-secondary) 28%, transparent);
|
||||
}
|
||||
|
||||
.cx-skins-preview-main {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-rows: 24px minmax(0, 1fr) 34px;
|
||||
color: var(--skin-text);
|
||||
background: color-mix(in srgb, var(--skin-bg) 68%, transparent);
|
||||
}
|
||||
|
||||
.cx-skins-preview-topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 0 8px;
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--skin-accent) 15%, transparent);
|
||||
background: color-mix(in srgb, var(--skin-panel) 67%, transparent);
|
||||
}
|
||||
|
||||
.cx-skins-preview-topbar span {
|
||||
width: 42px;
|
||||
height: 4px;
|
||||
border-radius: 3px;
|
||||
background: color-mix(in srgb, var(--skin-text) 55%, transparent);
|
||||
}
|
||||
|
||||
.cx-skins-preview-topbar i {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
border-radius: 4px;
|
||||
background: var(--skin-accent);
|
||||
}
|
||||
|
||||
.cx-skins-preview-chat {
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 7px 7px 5px;
|
||||
}
|
||||
|
||||
.cx-skins-preview-message {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.cx-skins-preview-message i {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
flex: 0 0 auto;
|
||||
border-radius: 3px;
|
||||
background: var(--skin-secondary);
|
||||
}
|
||||
|
||||
.cx-skins-preview-message span {
|
||||
width: 68%;
|
||||
height: 17px;
|
||||
border-radius: 4px;
|
||||
background: color-mix(in srgb, var(--skin-panel) 78%, transparent);
|
||||
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--skin-accent) 12%, transparent);
|
||||
}
|
||||
|
||||
.cx-skins-preview-message--user {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.cx-skins-preview-message--user span {
|
||||
width: 54%;
|
||||
background: color-mix(in srgb, var(--skin-accent) 28%, var(--skin-panel));
|
||||
}
|
||||
|
||||
.cx-skins-preview-composer {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin: 0 8px 8px;
|
||||
padding: 0 5px 0 8px;
|
||||
border: 1px solid color-mix(in srgb, var(--skin-accent) 32%, transparent);
|
||||
border-radius: 5px;
|
||||
background: color-mix(in srgb, var(--skin-panel) 84%, transparent);
|
||||
box-shadow: 0 4px 12px rgba(31, 25, 36, 0.1);
|
||||
}
|
||||
|
||||
.cx-skins-preview-composer span {
|
||||
width: 54%;
|
||||
height: 4px;
|
||||
border-radius: 3px;
|
||||
background: color-mix(in srgb, var(--skin-muted) 56%, transparent);
|
||||
}
|
||||
|
||||
.cx-skins-preview-composer i {
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
margin-left: auto;
|
||||
border-radius: 4px;
|
||||
background: var(--skin-accent);
|
||||
}
|
||||
|
||||
.cx-skins-card-body {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
padding: 10px 11px 11px;
|
||||
}
|
||||
|
||||
.cx-skins-card-title {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.cx-skins-card-title > div {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.cx-skins-card-title strong {
|
||||
overflow: hidden;
|
||||
color: #1f232a;
|
||||
font-size: 14px;
|
||||
font-weight: 740;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cx-skins-card-title span {
|
||||
overflow: hidden;
|
||||
color: var(--cx-skins-muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cx-skins-card-body p {
|
||||
min-height: 18px;
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
margin: 0;
|
||||
color: #4d5562;
|
||||
font-size: 12px;
|
||||
font-weight: 620;
|
||||
line-height: 1.5;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 1;
|
||||
}
|
||||
|
||||
.cx-skin-editor-title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.cx-skin-editor-body {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.cx-skin-editor-field {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.cx-skin-editor-field > span {
|
||||
color: var(--ui-text-muted);
|
||||
font-size: 12px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.cx-skin-editor-field input:not([type="range"]),
|
||||
.cx-skin-editor-field textarea {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
border: 1px solid var(--ui-border);
|
||||
border-radius: 7px;
|
||||
outline: none;
|
||||
color: var(--ui-text);
|
||||
background: var(--ui-surface-elevated);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
transition: border-color 140ms ease, box-shadow 140ms ease, background-color 140ms ease;
|
||||
}
|
||||
|
||||
.cx-skin-editor-field input:not([type="range"]) {
|
||||
height: 40px;
|
||||
padding: 0 11px;
|
||||
}
|
||||
|
||||
.cx-skin-editor-field textarea {
|
||||
min-height: 104px;
|
||||
padding: 10px 11px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.cx-skin-editor-field input:not([type="range"]):focus,
|
||||
.cx-skin-editor-field textarea:focus {
|
||||
border-color: color-mix(in srgb, var(--ui-accent) 58%, var(--ui-border));
|
||||
background: var(--ui-surface);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--ui-accent) 12%, transparent);
|
||||
}
|
||||
|
||||
.cx-skin-editor-field input:not([type="range"])::placeholder,
|
||||
.cx-skin-editor-field textarea::placeholder {
|
||||
color: var(--ui-text-subtle);
|
||||
}
|
||||
|
||||
.cx-skin-editor-range {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 54px;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
.cx-skin-editor-range input {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
accent-color: var(--ui-accent);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cx-skin-editor-range input:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.58;
|
||||
}
|
||||
|
||||
.cx-skin-editor-range output {
|
||||
min-width: 54px;
|
||||
padding: 5px 8px;
|
||||
border: 1px solid var(--ui-border);
|
||||
border-radius: 7px;
|
||||
color: var(--ui-text);
|
||||
background: var(--ui-surface-muted);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.cx-skins-empty {
|
||||
min-height: 220px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
gap: 10px;
|
||||
border: 1px dashed #d7dce3;
|
||||
border-radius: 14px;
|
||||
color: var(--cx-skins-muted);
|
||||
background: #fbfcfd;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
@keyframes cx-skins-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 880px) {
|
||||
.cx-skins-header {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.cx-skins-actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.cx-skins-status {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
padding: 8px 14px;
|
||||
}
|
||||
|
||||
.cx-skins-status > div,
|
||||
.cx-skins-status > div:first-child {
|
||||
padding: 6px 0;
|
||||
}
|
||||
|
||||
.cx-skins-status > div + div {
|
||||
border-top: 1px solid var(--cx-skins-line);
|
||||
border-left: 0;
|
||||
}
|
||||
|
||||
.cx-skins-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(230px, 280px));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 440px) {
|
||||
.cx-skins-grid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.cx-skins-preview-app {
|
||||
width: 68%;
|
||||
}
|
||||
}
|
||||
@@ -106,6 +106,7 @@ export type ActionResult = {
|
||||
|
||||
export type OfficialConfigDraft = {
|
||||
authJson: string;
|
||||
configText: string;
|
||||
model?: string;
|
||||
source: string;
|
||||
};
|
||||
@@ -239,65 +240,3 @@ 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;
|
||||
};
|
||||
|
||||
+31
-2
@@ -8,6 +8,34 @@
|
||||
|
||||
日志不得包含 Token、认证文件内容、用户数据或仅适用于某台电脑的隐私路径。
|
||||
|
||||
## 2026-08-11:供应商认证往返、模板完整性与启动读取
|
||||
|
||||
### 决策
|
||||
|
||||
- 新建和 CC Switch 导入的供应商保存完整、无密钥的 TOML 模板;API Key 单独保存在供应商
|
||||
数据库字段中。完整模板是该供应商的权威配置,切换时恢复项目、插件、MCP、桌面、功能和
|
||||
环境设置;旧版稀疏模板仍以当前 live 配置为底稿兼容合并。
|
||||
- 有可信 ChatGPT OAuth 时,第三方切换保留 live `auth.json`,并把目标 Key 写入活动
|
||||
provider 的 `experimental_bearer_token`;没有 OAuth 时继续写 `OPENAI_API_KEY` 以维持
|
||||
Codex 登录状态,同时也写 provider 作用域 Key,避免两文件切换窗口使用错凭据。
|
||||
- 官方配置独立保存完整 `config.toml`、模型和 OAuth/API Key `auth.json`。官方与第三方往返
|
||||
先发布目标路由,再按需更新 auth,失败时按条件原子回滚;未登录的官方 Reset 快照也必须
|
||||
保留完整 TOML。自动捕获不得让官方 live 中残留的中转 API Key 覆盖可信 OAuth 快照。
|
||||
- 兼容无 `auth_mode` 但包含有效 token、且不含 API Key 的旧版官方 OAuth;损坏的 live
|
||||
`auth.json` 与损坏的应用内官方快照都不阻塞状态读取或供应商切换,且绝不能被晋升为官方
|
||||
认证快照。完整官方 TOML 是模型字段的权威源,独立模型输入只补齐缺失值。
|
||||
- 启动状态读取不得迁移提示词、修改文件权限、捕获认证或扫描历史备份;概览在结果返回前显示
|
||||
“正在读取”,失败后显示“读取失败”。
|
||||
- Codex-X 内部数据库使用持久 `user_version`。已升级数据库的普通打开只读版本号;迁移、历史
|
||||
清理和版本更新在同一个 `BEGIN IMMEDIATE` 事务完成,失败可重试,同路径替换后可重新初始化。
|
||||
- Codex 版本探测在 blocking worker 中执行,固定候选先探测,目录候选流式探测,所有阶段共享
|
||||
同一个总 deadline,避免 Windows 慢盘或重定向用户目录拖住启动。
|
||||
- 对照 CC Switch `95f2dd41262f01209100128ea647dbd054b5624a` 的完整 provider config、
|
||||
官方登录保留和 provider-scoped bearer token 路径,并用 Codex CLI `0.147.0-alpha.6.5` 的
|
||||
隔离回环请求验证作用域 Key 优先级;
|
||||
Codex-X 额外保留官方独立快照、live 配置并发检查与条件原子回滚。本节决策替代 2026-07-29
|
||||
中关于第三方 bearer token 和模板激活的旧约束。
|
||||
|
||||
## 2026-07-30:live 配置并发与失败回滚
|
||||
|
||||
### 已确认根因
|
||||
@@ -84,8 +112,9 @@ Tauri 事件循环随最后一个窗口关闭而结束,后台管理能力也
|
||||
|
||||
### 约束
|
||||
|
||||
- 供应商记录只拥有供应商相关字段,不拥有整份用户配置。
|
||||
- 激活供应商必须基于最新 live 文档合并目标 provider 表,不回放历史整文件快照。
|
||||
- 供应商记录可以保存完整 TOML,作为导入与编辑模板;其中的密钥必须剥离并由独立字段保存。
|
||||
- 激活供应商必须基于最新 live 文档,仅合并目标 provider 与 model 字段,不回放模板中的
|
||||
项目、插件、MCP 或其他通用设置。
|
||||
- 官方认证快照与中转认证严格隔离,供应商切换不能覆盖可信官方认证。
|
||||
- 自动捕获官方认证只接受明确的 ChatGPT 登录模式及 access/refresh/id token;代理路由下的
|
||||
API key 属于不可信来源,不能晋升为官方快照。
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "codex-x-workspace",
|
||||
"private": true,
|
||||
"version": "0.3.6",
|
||||
"version": "0.3.11",
|
||||
"scripts": {
|
||||
"dev": "pnpm --dir apps/desktop tauri dev",
|
||||
"build": "pnpm --dir apps/desktop tauri build",
|
||||
|
||||
Generated
-10
@@ -13,9 +13,6 @@ 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
|
||||
@@ -528,9 +525,6 @@ 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==}
|
||||
|
||||
@@ -1099,10 +1093,6 @@ snapshots:
|
||||
'@tauri-apps/cli-win32-ia32-msvc': 2.11.4
|
||||
'@tauri-apps/cli-win32-x64-msvc': 2.11.4
|
||||
|
||||
'@tauri-apps/plugin-dialog@2.7.2':
|
||||
dependencies:
|
||||
'@tauri-apps/api': 2.11.1
|
||||
|
||||
'@tauri-apps/plugin-process@2.3.1':
|
||||
dependencies:
|
||||
'@tauri-apps/api': 2.11.1
|
||||
|
||||
Reference in New Issue
Block a user