Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7e3551babe | ||
|
|
a309f402c1 | ||
|
|
d7610f9b12 | ||
|
|
e4822237fb | ||
|
|
5d18b2555e | ||
|
|
2ace729801 | ||
|
|
488f27e095 | ||
|
|
826a142fc0 | ||
|
|
d1b40285dd | ||
|
|
3ac28c3193 | ||
|
|
39dc8fc535 | ||
|
|
762ff36131 | ||
|
|
5894dceb98 | ||
|
|
92c371e5e0 |
@@ -221,34 +221,9 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Stage pinned Windows skin runtime
|
||||
if: matrix.platform == 'windows-latest'
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$version = '22.23.1'
|
||||
$expectedSha256 = '7df0bc9375723f4a86b3aa1b7cc73342423d9677a8df4538aca31a049e309c29'
|
||||
$archive = Join-Path $env:RUNNER_TEMP "node-v$version-win-x64.zip"
|
||||
$extractRoot = Join-Path $env:RUNNER_TEMP "codex-x-node-$version"
|
||||
$targetRoot = 'apps/desktop/src-tauri/runtime/windows/node'
|
||||
Invoke-WebRequest -Uri "https://nodejs.org/dist/v$version/node-v$version-win-x64.zip" -OutFile $archive
|
||||
$actualSha256 = (Get-FileHash -LiteralPath $archive -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
if ($actualSha256 -cne $expectedSha256) {
|
||||
throw "Node.js archive checksum mismatch: $actualSha256"
|
||||
}
|
||||
Remove-Item -LiteralPath $extractRoot -Recurse -Force -ErrorAction SilentlyContinue
|
||||
Expand-Archive -LiteralPath $archive -DestinationPath $extractRoot
|
||||
$sourceRoot = Join-Path $extractRoot "node-v$version-win-x64"
|
||||
New-Item -ItemType Directory -Path $targetRoot -Force | Out-Null
|
||||
Copy-Item -LiteralPath (Join-Path $sourceRoot 'node.exe') -Destination $targetRoot -Force
|
||||
Copy-Item -LiteralPath (Join-Path $sourceRoot 'LICENSE') -Destination $targetRoot -Force
|
||||
if (-not (Test-Path -LiteralPath (Join-Path $targetRoot 'node.exe') -PathType Leaf)) {
|
||||
throw 'Pinned Windows Node.js runtime was not staged.'
|
||||
}
|
||||
|
||||
- name: Test Windows filesystem compatibility
|
||||
if: matrix.platform == 'windows-latest'
|
||||
run: cargo test --manifest-path apps/desktop/src-tauri/Cargo.toml --lib --locked
|
||||
run: cargo test --manifest-path apps/desktop/src-tauri/Cargo.toml --lib --locked -- --test-threads=1
|
||||
|
||||
- name: Import Apple signing certificate and notarization key
|
||||
if: matrix.macos
|
||||
@@ -380,9 +355,6 @@ jobs:
|
||||
rm -rf "$portable_root"
|
||||
mkdir -p "$portable_dir"
|
||||
cp apps/desktop/src-tauri/target/release/codex-x.exe "$portable_dir/Codex-X.exe"
|
||||
mkdir -p "$portable_dir/skin-runtime/node"
|
||||
cp apps/desktop/src-tauri/runtime/windows/node/node.exe "$portable_dir/skin-runtime/node/node.exe"
|
||||
cp apps/desktop/src-tauri/runtime/windows/node/LICENSE "$portable_dir/skin-runtime/node/LICENSE"
|
||||
touch "$portable_dir/Codex-X.portable"
|
||||
cat > "$portable_dir/README-portable.txt" <<'EOF'
|
||||
Codex-X Windows Portable
|
||||
@@ -390,7 +362,6 @@ jobs:
|
||||
Double click Codex-X.exe to run. No MSI installation is required.
|
||||
The portable edition uses manual downloads instead of in-app installation.
|
||||
Keep Codex-X.portable next to Codex-X.exe so the app can identify this edition.
|
||||
Keep the skin-runtime directory beside Codex-X.exe; it contains the verified Windows skin runtime.
|
||||
Microsoft Edge WebView2 Runtime is required; Windows 10/11 usually already includes it.
|
||||
EOF
|
||||
powershell -NoProfile -Command "Compress-Archive -Path '${portable_dir}/*' -DestinationPath '${archive}' -Force"
|
||||
|
||||
@@ -1,441 +1,414 @@
|
||||
# Changelog
|
||||
# 更新日志
|
||||
|
||||
All notable changes to Codex-X will be documented here.
|
||||
记录 Codex-X 的新功能、体验改进和问题修复。
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [v0.3.17] - 2026-09-09
|
||||
|
||||
### 新增与优化
|
||||
|
||||
- 官方账号支持查看剩余额度、重置时间和可用重置次数,列表中显示邮箱、套餐和登录状态。
|
||||
- 新增 DeepSeek、MiniMax、小米 MiMo 等主流厂商预设和模型映射,方便在 Codex 中使用第三方模型,并保留已有配置。
|
||||
|
||||
### 问题修复
|
||||
|
||||
- 修复编辑正在使用的供应商时保存报错、切换后出现重复供应商,以及部分第三方连接认证失败的问题。
|
||||
- 修复用量统计切换日期范围时页面闪白的问题。
|
||||
|
||||
## [v0.3.16] - 2026-09-09
|
||||
|
||||
### 新功能
|
||||
|
||||
- 支持保存多个官方 Codex 账号,并为每个账号起不同的名字,方便切换使用。
|
||||
- 官方账号和第三方供应商都支持一键复制,点击后直接生成副本。
|
||||
- 新增“开启 1M 上下文窗口”选项,可按需勾选使用(需要模型支持)。
|
||||
- 设置页新增“用量统计”,可以查看 Token 用量、每日趋势、模型分布和最近 10 个会话,并按日期、模型筛选。
|
||||
- AI 后台子任务的用量合并到主对话,聊天列表只显示主会话。最近会话显示聊天标题或项目名,更容易找到。
|
||||
|
||||
### 体验改进
|
||||
|
||||
- 提示词页面的标题统一为蓝色,设置页的标签切换加入过渡动画。
|
||||
|
||||
### 问题修复
|
||||
|
||||
- 修复同步 GitHub 提示词模板时,供应商按钮暂时无法使用的问题。
|
||||
- 修复切换供应商后,原有的 CC Switch 供应商从列表消失的问题。
|
||||
|
||||
## [v0.3.15] - 2026-08-20
|
||||
|
||||
### 问题修复
|
||||
|
||||
- 修复 Windows 点击“重启 Codex”后只隐藏窗口、未真正退出,以及再次重启时报错的问题。
|
||||
|
||||
## [v0.3.14] - 2026-08-20
|
||||
|
||||
### 更新
|
||||
|
||||
- MCP 和 Skills 支持添加自定义备注,方便区分用途。
|
||||
- 供应商支持复制,同一 API 平台可以保存多个模型或多份配置,副本放在列表末尾。
|
||||
- 简化供应商列表,不再重复显示模型信息。
|
||||
- 设置页新增“重启 Codex”按钮,支持 macOS 和 Windows。
|
||||
|
||||
### 问题修复
|
||||
|
||||
- 修复相同供应商配置被覆盖,以及复制当前供应商后丢失启用状态的问题。
|
||||
- 修复 Codex 自动创建的子代理会话被错误标记为“待同步”的问题。
|
||||
|
||||
## [v0.3.13] - 2026-08-18
|
||||
|
||||
### 问题修复
|
||||
|
||||
- 已归档的会话不再被标记为“待同步”,切换供应商时会继续保持归档状态。
|
||||
|
||||
## [v0.3.12] - 2026-08-11
|
||||
|
||||
### 更新
|
||||
|
||||
- 提升使用流畅度,修复部分问题。
|
||||
|
||||
## [v0.3.11] - 2026-08-11
|
||||
|
||||
### 更新
|
||||
|
||||
- 提升使用流畅度,修复部分问题。
|
||||
|
||||
## [v0.3.10] - 2026-08-11
|
||||
|
||||
### 更新
|
||||
|
||||
- 提升使用流畅度,修复部分问题。
|
||||
|
||||
## [v0.3.9] - 2026-08-11
|
||||
|
||||
### 更新
|
||||
|
||||
- 提升使用流畅度,修复部分问题。
|
||||
|
||||
## [v0.3.8] - 2026-08-03
|
||||
|
||||
### 问题修复
|
||||
|
||||
- 修复部分使用问题。
|
||||
|
||||
## [v0.3.7] - 2026-08-02
|
||||
|
||||
### 问题修复
|
||||
|
||||
- 修复部分使用问题。
|
||||
|
||||
## [v0.3.6] - 2026-07-31
|
||||
|
||||
### 更新
|
||||
|
||||
- 供应商与 OpenAI Official 切换改为热更新;切换后新建会话即可生效,不再要求重启 Codex 客户端。
|
||||
- macOS 关闭主窗口后进入顶部栏驻留模式,Windows 隐藏任务栏窗口;托盘菜单仍可恢复窗口或明确退出应用。
|
||||
- 新增独立的项目维护日志,记录配置写入、供应商切换、会话同步和桌面生命周期的长期约束与验证依据。
|
||||
- 官方登录与第三方供应商切换后,新建会话即可使用,无需重启 Codex。
|
||||
- 关闭主窗口后,软件会留在 macOS 菜单栏或 Windows 托盘中,可从菜单恢复窗口或退出。
|
||||
|
||||
### 修复 Bug
|
||||
### 问题修复
|
||||
|
||||
- 修复官方认证被第三方供应商覆盖或清除的问题;第三方密钥仅写入当前 provider,官方 ChatGPT 登录与官方 API Key 均使用独立快照保存并可往返恢复。
|
||||
- 修复编辑活动供应商会新建副本、重复导入 cc-switch 供应商产生多条相同记录,以及旧版 cc-switch 数据库缺少 `category` 字段时无法读取官方认证的问题。
|
||||
- 修复供应商、提示词及其他配置操作并发写入时可能覆盖或破坏 `config.toml` 的问题;写入现在使用跨进程锁、原始快照、条件原子替换和失败回滚。
|
||||
- 修复会话同步显示成功但 Codex 未实际使用同步结果的问题;只处理活动 SQLite,严格校验 SQLite 指定的 rollout 路径、文件身份和线程 ID,并兼容 `codex-dev.db` 与显式 `sqlite_home` 下的自定义数据库名。
|
||||
- 修复会话列表将旧数据库或孤立 JSONL 计入活动会话、把 JSONL 记录行数误当会话数,以及同步过程中修改非活动数据库或工作目录字段的问题。
|
||||
- 修复切换第三方供应商时丢失官方登录或官方 API Key 的问题,现在切回官方时可以恢复。
|
||||
- 修复编辑当前供应商时意外新增副本、重复导入 cc-switch 产生重复项的问题,并兼容旧版 cc-switch 的官方登录导入。
|
||||
- 修复同时操作供应商、提示词或其他设置时,配置被覆盖或损坏的问题。
|
||||
- 修复会话同步提示成功却未生效的问题,并改善对自定义会话存储位置的支持。
|
||||
- 修复旧记录被重复计入会话数量,以及同步时误改旧会话或项目位置的问题。
|
||||
|
||||
## [v0.3.5] - 2026-07-28
|
||||
|
||||
### 更新
|
||||
|
||||
- GitHub 指令提示词现在可以直接查看和编辑本地内容;模板名称和文件名继续跟随在线目录,Markdown 内容由用户在现有提示词编辑页中维护。
|
||||
- 模板内容实际修改后会标记为“本地已修改”,后续同步永久跳过该模板的远端内容,同时继续发现和下载 GitHub 新增模板;启用时优先使用用户的本地版本。
|
||||
- 仅查看或保存未变化的内容不会退出 GitHub 同步;本地修改在下次启用该模板时生效。
|
||||
- GitHub 提示词模板可以直接查看和编辑内容,名称和文件名仍跟随在线版本。
|
||||
- 编辑后的模板会标记为“本地已修改”,后续同步不会覆盖你的修改,新模板仍会正常下载;修改在下次启用模板时生效。
|
||||
- 仅查看模板或保存未改动的内容,不会影响后续在线更新。
|
||||
|
||||
### 修复 Bug
|
||||
### 问题修复
|
||||
|
||||
- 修复 jsDelivr `@main` 目录缓存旧版本时,“同步 GitHub 模板”只能看到 5 个模板、无法发现仓库中 11 个模板的问题;目录成员现在始终由 GitHub 源站确认,源站不可用时仍保留 CDN 和本地缓存回退。
|
||||
- 修复当前使用中转供应商且 live `auth.json` 不存在时,概览页错误显示“认证文件未找到”的问题;已保存的官方认证快照现在会显示为“官方认证已保存”。
|
||||
- 修复同步 GitHub 模板时只能看到 5 个、无法获取完整 11 个模板的问题;网络异常时仍可使用已有缓存。
|
||||
- 修复使用第三方供应商时,概览页误报未找到官方登录信息的问题。
|
||||
|
||||
## [v0.3.4] - 2026-07-28
|
||||
|
||||
### 更新
|
||||
|
||||
- 在线提示词库新增 3 套软件开发模板和 3 套写作辅助模板,并按照模板文件名前缀自动归入对应分类;用户手动分类仍然优先。
|
||||
- 软件开发分类新增“长期维护工程师”“系统化调试与根因修复”“严格代码审查”,其中“长期维护工程师”来自项目当前实际使用的维护规范。
|
||||
- 写作辅助分类新增“清晰表达与润色”“技术文档写作”“结构化长文起草”。6 套新增模板通过 GitHub 在线同步,不增加安装包体积。
|
||||
- 在线模板库新增 6 套提示词,点击同步 GitHub 模板即可获取,不增加安装包体积。
|
||||
- 软件开发新增“长期维护工程师”“系统化调试与根因修复”“严格代码审查”。
|
||||
- 写作辅助新增“清晰表达与润色”“技术文档写作”“结构化长文起草”。新模板会自动分类,仍保留用户手动设置的分类。
|
||||
|
||||
### 修复 Bug
|
||||
### 问题修复
|
||||
|
||||
- 彻底隔离 OpenAI Official 与中转供应商认证:切换到中转前保存可信的官方配置快照,切回官方时恢复该快照,避免 `auth.json` 被中转或 cc-switch 覆盖后导致官方登录失效。
|
||||
- “还原官方配置”现在只恢复独立快照,不再自动切换当前供应商;新增“新建官方配置”,可清除受污染的 live `auth.json` 并引导用户重新完成官方登录。
|
||||
- 优化官方 `auth.json` 编辑器的高度、换行和长 Token 选取体验,避免内容看起来被截断或只能依赖底部横向滚动条。
|
||||
- 修复切换第三方供应商或从 cc-switch 导入后,官方登录信息被覆盖、需要重新登录的问题。
|
||||
- “还原官方配置”不再自动切换当前供应商;新增“新建官方配置”,方便清除异常认证后重新登录。
|
||||
- 调整官方认证编辑框,长内容可以换行显示,更方便查看和选择。
|
||||
|
||||
## [v0.3.3] - 2026-07-26
|
||||
|
||||
### 调整
|
||||
|
||||
- 暂时下线皮肤中心并隐藏侧边栏入口,皮肤页面、运行时实现和用户本地主题文件继续保留,后续可以通过统一功能开关重新启用。
|
||||
- 升级后会自动暂停 Codex-X 此前启用的皮肤并恢复官方显示;若自动停用失败,应用会显示明确错误,不会让用户在入口隐藏后失去恢复提示。
|
||||
- 内置“椎名真白·樱花画室”不再注册为随应用编译的主题,其约 1.7 MB 壁纸不再进入正式安装包;主题源文件仍保留在仓库中。
|
||||
- 暂时下线皮肤中心,隐藏侧边栏入口,保留用户已保存的本地主题文件。
|
||||
- 升级后会自动关闭此前启用的皮肤,恢复 Codex 官方外观;若恢复失败,会显示原因。
|
||||
- 安装包不再附带“椎名真白·樱花画室”主题壁纸,减小下载体积。
|
||||
|
||||
## [v0.3.2] - 2026-07-25
|
||||
|
||||
### 修复 Bug
|
||||
### 问题修复
|
||||
|
||||
- 修复 Windows Codex 冷启动或进入新版首页、外观设置页时,皮肤运行时因只识别旧版主壳层和侧栏而错误提示“未找到经过验证的 Codex 渲染器”的问题;首次连接现在会等待页面渐进加载,并兼容渲染文档切换。
|
||||
- “关闭皮肤”保持为随时可用的异常恢复入口,不再因运行状态刷新延迟、皮肤部分失效或其他操作卡住而无法点击;恢复期间仍会阻止重复提交和其他皮肤写操作。
|
||||
- 修复 Windows 启动 Codex 或进入新版首页、外观设置时,皮肤功能错误提示无法识别 Codex 的问题。
|
||||
- 皮肤出现异常时仍可点击“关闭皮肤”,恢复过程中会避免重复操作。
|
||||
|
||||
### 安全
|
||||
### 安全改进
|
||||
|
||||
- 新版 Windows 页面检测继续要求官方进程、回环 CDP、`app://` 协议以及 Codex 壳层、首页或外观设置页的明确锚点,不会向普通网页或无标记辅助页面注入皮肤。
|
||||
- 皮肤功能仅用于已识别的官方 Codex 窗口,不会应用到普通网页。
|
||||
|
||||
## [v0.3.1] - 2026-07-24
|
||||
|
||||
### 更新
|
||||
|
||||
- 皮肤中心重新设计主题画廊:直接展示真实壁纸和侧边栏、会话区、输入框、按钮等整套界面效果,单个主题不再拉伸占满整行,并移除端点、目录、`theme.json` 路径和主题包结构等开发者信息。
|
||||
- 皮肤中心新增可管理的分类筛选并进一步压缩主题卡片密度;导出主题时改用系统“另存为”窗口选择目录和文件名。
|
||||
- 支持直接从图片创建自适应主题,并可编辑主题名称、简介和界面透明度;内置主题与图片主题共用相同的编辑与预览规格。
|
||||
- 主题画廊根据当前分类数量自适应 1 至 4 列,少量主题保持紧凑,多个主题充分利用可用宽度。
|
||||
- 实机换肤扩展到 Windows:动态校验官方 `OpenAI.Codex` Store 包,安全启动仅绑定回环地址的 CDP 会话,并在 Windows 发布包中附带固定校验的 Node.js 22 运行时;不修改 `WindowsApps`、`app.asar` 或权限。
|
||||
- 皮肤中心只保留“关闭皮肤”入口,移除与其重复的“恢复官方外观”按钮;底层完整恢复能力继续保留用于异常恢复。
|
||||
- 重新设计皮肤中心,可直接预览壁纸、侧边栏、会话区和输入框的整体效果,主题卡片更紧凑。
|
||||
- 支持主题分类筛选,卡片会根据数量自动调整为 1 至 4 列;导出主题可使用系统“另存为”窗口。
|
||||
- 支持从图片创建主题,并修改名称、简介和界面透明度。
|
||||
- 皮肤功能支持 Windows 官方商店版 Codex,无需修改 Codex 安装文件。
|
||||
- 合并重复的恢复按钮,统一使用“关闭皮肤”。
|
||||
|
||||
### 修复 Bug
|
||||
### 问题修复
|
||||
|
||||
- 修复 macOS 皮肤已经注入后 Codex-X 仍长期显示“应用中”、重开后无法识别当前主题的问题;单次注入现在会明确退出,外部命令带有硬超时,并可从经过验证的 Codex 主进程恢复遗失的本机调试端口。
|
||||
- 修复皮肤尚未运行时错误显示“重新应用”、刷新缺少明确反馈,以及提示词或皮肤卡片过多时页面无法继续向下滚动的问题。
|
||||
- 优化普通横图在新任务首页超宽横幅中的自动焦点,避免人物头部被居中 `cover` 裁掉;主题包仍可通过 `art.focusY` 显式覆盖自动位置。
|
||||
- 修复切换到中转供应商时覆盖官方 `auth.json` 的问题,官方登录凭据与供应商凭据现在分别保存和恢复。
|
||||
- 改进 Codex Desktop 检测,兼容更多安装路径、包标识和版本输出格式,减少部分用户错误显示“未检测到 Codex”的情况。
|
||||
- 修复启用皮肤后新会话输入框消失、输入框出现会话内容穿透,以及图片主题预览错误使用默认背景的问题。
|
||||
- 修复浅色主题的固定白色遮罩洗淡壁纸颜色的问题;壁纸保持原图色彩,卡片和项目选择区域跟随透明度,同时为输入框保留可读性下限。
|
||||
- 修复 macOS 应用皮肤后一直显示“应用中”,以及重开软件后无法识别当前主题的问题。
|
||||
- 修复皮肤按钮状态错误、刷新缺少反馈,以及提示词或主题较多时无法继续向下滚动的问题。
|
||||
- 优化横图壁纸的裁剪,减少人物头部被切掉的情况。
|
||||
- 修复切换第三方供应商时覆盖官方登录信息的问题。
|
||||
- 改善 Codex 安装检测,减少已安装却显示“未检测到 Codex”的情况。
|
||||
- 修复启用皮肤后输入框消失、聊天内容透过输入框,以及图片主题预览显示错误背景的问题。
|
||||
- 修复浅色主题下壁纸颜色发白的问题,并改善透明度调整和输入框文字的清晰度。
|
||||
|
||||
### 安全
|
||||
### 安全与来源
|
||||
|
||||
- 换肤运行时只连接经过签名、进程归属和页面标记验证的官方 Codex,并将 CDP 限制在 `127.0.0.1`;停止注入器前会核对 PID、启动时间、Node 路径、脚本路径和端口。
|
||||
- 主题导入增加 ZIP 越界、符号链接、重复文件、文件数量、图片大小和 Manifest 字段校验;运行时使用稳定快照,避免主题切换时配置与图片不一致。
|
||||
|
||||
### 开发
|
||||
|
||||
- 固定复用 Codex Dream Skin Studio `5fd8af532efbaa87d2d0092297fd2d45cd56574e` 的 MIT 注入实现,并加入 Rust 与 Node 专项回归测试;未包含上游声明为非 MIT 授权的真人主题素材。
|
||||
- 加强官方 Codex 识别、主题导入和切换检查。
|
||||
- 换肤功能使用 Codex Dream Skin Studio 的 MIT 授权代码,未包含其非 MIT 授权的真人主题素材。
|
||||
|
||||
## [v0.3.0] - 2026-07-16
|
||||
|
||||
### 更新
|
||||
|
||||
- 全面升级桌面界面,统一各页面布局、按钮、弹窗和 Toast,并加入深浅色模式与页面过渡动画。
|
||||
- 新增应用内更新:macOS、Windows MSI 和 Linux AppImage 可直接下载、安装并重新启动;发现更新时会在侧边栏显示入口和进度。
|
||||
- 完善供应商管理:删除增加二次确认,连接测试改用 `/v1/models`,添加或编辑时可获取并选择模型,同时简化 OpenAI Official 页面。
|
||||
- 完善指令提示词管理:新增分类功能,内置两套新模板,精简模板卡片,添加或编辑后只保存、不自动启用。
|
||||
- 在线提示词会在启动后后台同步并保存到本机;支持 CDN、GitHub 备用源和系统代理,离线时也能使用上次同步内容。
|
||||
- 简化会话管理:统一同步到当前供应商,取消启动时自动修改,并支持查看、选择和删除内部会话。
|
||||
- 优化 Skills/MCP 导入,自动排除已经导入的内容,没有新内容时不再弹出空窗口。
|
||||
- 全面更新界面,统一页面、按钮、弹窗和通知样式,加入深浅色模式与页面切换动画。
|
||||
- 新增应用内更新,macOS、Windows MSI 和 Linux AppImage 用户可直接下载、安装并重启;侧边栏会显示更新入口和进度。
|
||||
- 改进供应商连接测试,删除前增加确认,添加或编辑时可以获取并选择模型,官方登录页面更简洁。
|
||||
- 提示词新增分类和两套内置模板,卡片更简洁;添加或编辑后只保存,需要时再手动启用。
|
||||
- 启动后会在后台同步在线模板,网络异常时尝试备用下载来源;模板会保存到本机,离线时仍能使用,支持系统代理。
|
||||
- 会话统一同步到当前供应商,不再在启动时自动修改;支持查看、选择和删除内部会话。
|
||||
- 导入 Skills / MCP 时会跳过已有内容,没有新内容时不再弹出空窗口。
|
||||
|
||||
### 修复 Bug
|
||||
### 问题修复
|
||||
|
||||
- 修复旧提示词配置可能导致 Codex 报 `expected u32`,以及 Windows `.codex` 使用链接目录后无法正常读写的问题。
|
||||
- 修复会话同步可能改写聊天内容、出现 `invalid_id_prefix`、重复显示会话或覆盖正在写入记录的问题。
|
||||
- 修复深浅色模式下的文字、滚动条、复选框和弹窗显示异常,以及弹窗打开后背景仍能滚动的问题。
|
||||
- 修复提示词缓存延迟造成的重复同步、错误更新或误删除,并修复应用内更新失败后的错误状态提示。
|
||||
- 修复部分旧提示词配置导致 Codex 报错,以及 Windows 将配置文件夹链接到其他位置后无法读写的问题。
|
||||
- 修复会话同步报错、会话重复显示,以及聊天记录被误改或覆盖的问题。
|
||||
- 修复深浅色模式下部分文字和控件显示异常,以及打开弹窗后背景仍能滚动的问题。
|
||||
- 修复提示词重复同步、错误更新或误删除,以及应用更新失败后提示不准确的问题。
|
||||
- 完善安装包和更新的安全检查。
|
||||
|
||||
### 兼容说明
|
||||
|
||||
- `v0.3.0` 是首个支持应用内更新的版本,旧版用户需要手动安装本版本一次。
|
||||
- Windows 便携版和 Linux deb/rpm 继续手动更新;Windows MSI 首次安装仍可能出现 SmartScreen 提示。
|
||||
|
||||
### 开发
|
||||
|
||||
- 增加更新包签名、macOS 签名与公证,并完成后端模块拆分和发布流程安全加固。
|
||||
- Windows 便携版和 Linux deb/rpm 仍需手动更新;Windows MSI 首次安装可能出现系统安全提示(SmartScreen)。
|
||||
|
||||
## [v0.2.35] - 2026-07-13
|
||||
|
||||
### 更新
|
||||
|
||||
- 新增 `gpt-5.6-sol-unrestricted.md` 指令提示词模板,同步 GitHub 模板后即可在【指令提示词】页面启用。
|
||||
- 优化第三方供应商切换:切换后新建或重新打开 Codex 会话即可使用新的中转,无需重启整个 Codex 客户端。
|
||||
- 优化从 cc-switch 导入供应商的结果提示,现在会分别显示新增、更新、合并和跳过数量。
|
||||
|
||||
### 修复 Bug
|
||||
|
||||
- 修复相同 API 地址和 API Key 的供应商因名称、模型或其他配置不同而重复显示的问题;相同地址但不同 Key 的供应商仍会分别保留,升级后也会自动整理已有重复项。
|
||||
- 修复切换到其他供应商或切回 OpenAI Official 后,原供应商可能从列表中消失的问题;切换前会自动保存当前供应商。
|
||||
- 修复从 cc-switch 重复导入时可能覆盖 Codex-X 中已有供应商名称、模型或自定义配置的问题,并兼容旧版 cc-switch 数据。
|
||||
- 优化会话列表:Codex 自动创建的后台子会话不再重复显示为普通会话,项目数量和会话总数更加准确。
|
||||
- 优化会话编号显示,保留编号开头和结尾,减少多个会话看起来像同一个会话的情况。
|
||||
- 修复检测到的供应商与本地供应商同名时,编辑保存可能因 ID 冲突覆盖另一条记录的问题。
|
||||
- 新增 `gpt-5.6-sol-unrestricted.md` 提示词模板,同步 GitHub 模板后即可启用。
|
||||
- 切换第三方供应商后,新建或重新打开 Codex 会话即可使用新供应商,无需重启整个 Codex 客户端。
|
||||
- 自动整理 API 地址和密钥均相同的重复供应商;同一地址使用不同密钥的供应商仍分别保留。
|
||||
- 修复切换供应商或切回官方登录后,原供应商可能消失的问题。
|
||||
- 从 cc-switch 导入时,会显示新增、更新、合并和跳过数量;重复导入不再覆盖已有名称、模型和自定义配置,并兼容旧版 cc-switch 数据。
|
||||
- 会话列表不再重复展示 Codex 自动创建的后台子会话,项目和会话数量更准确;会话编号保留首尾,方便区分。
|
||||
- 修复编辑同名供应商时可能覆盖另一条记录的问题。
|
||||
|
||||
## [v0.2.34] - 2026-07-13
|
||||
|
||||
### 更新
|
||||
|
||||
- 会话管理新增指定会话永久删除:支持单选、多选,并在二次确认后从当前 Codex 存储中硬删除;操作不进入回收站、不创建新的删除备份,Codex-X 内无法撤销。
|
||||
- “按项目路径分组”新增项目级三态勾选,可一次选中某个项目在当前列表中加载的全部会话,也支持跨多个项目批量选择;搜索状态下会同时显示当前命中数与项目会话总数。
|
||||
- 重做会话列表为更克制的表格式布局,统一标题、更新时间、Provider、模型和 ID 列,降低高亮边框、彩色标签与重复装饰造成的视觉干扰。
|
||||
- 移除“已选 / 需修复、选择需同步、清空选择、修复选中”等按条修复控件,保留顶部“检查会话”和“同步 / 修复”用于全量处理。
|
||||
- 永久删除确认窗口会预览待删除会话,并要求确认已关闭正在使用相关会话的其他 Codex 窗口或 CLI;由所选会话派生的子会话也会一并处理。
|
||||
|
||||
### 修复 Bug
|
||||
|
||||
- 修复默认窗口大小下会话列表滚动条在下拉过程中消失、必须放大窗口才能继续查看的问题;列表现在使用稳定的内部滚动区域并阻止滚动穿透。
|
||||
- 修复同时存在当前 `state_*.sqlite` 与旧版 `sqlite/state_*.sqlite` 时,会话总数重复统计并混入旧会话的问题;现在优先使用当前活动会话数据库。
|
||||
- 修复批量删除部分失败时结果反馈不完整的问题,界面会保留未删除项并显示失败数量与原因,方便关闭占用进程后重试。
|
||||
|
||||
### 开发
|
||||
|
||||
- 永久删除优先调用 Codex App Server 官方 `thread/delete`,不可用时使用本地兼容清理,并复用跨平台 Codex 可执行文件探测与启动逻辑。
|
||||
- 本地兼容清理覆盖父子会话、当前及旧版会话数据库关联记录、活动/归档 Rollout、会话索引、历史记录、Shell 快照以及日志、记忆、目标和本地目录等关联数据。
|
||||
- 会话同步与删除共用维护锁,并增加 Rollout 规范路径校验与符号链接边界保护,避免并发维护或越界删除。
|
||||
- 增加活动数据库优先级、父子会话与重复库清理、关联记录清理、Rollout 越界拒绝及符号链接防护测试。
|
||||
- 会话管理支持单选或多选后永久删除,所选会话派生的子会话也会一并删除。删除不进入回收站、不创建新的备份,在 Codex-X 中无法撤销。
|
||||
- 删除前可预览待删除会话,并需确认已关闭其他正在使用这些会话的 Codex 窗口或命令行进程。
|
||||
- 支持按项目勾选当前已加载的会话,也可跨项目批量选择;搜索时同时显示匹配数量和项目会话总数。
|
||||
- 会话列表改为紧凑表格,标题、更新时间、供应商、模型和编号更清晰。检查与修复统一使用顶部的“检查会话”和“同步 / 修复”。
|
||||
- 修复默认窗口大小下会话列表无法继续向下滚动的问题。
|
||||
- 修复升级 Codex 后可能重复统计会话、混入旧会话的问题。
|
||||
- 批量删除部分失败时,会保留未删除项并显示失败数量和原因,方便处理后重试。
|
||||
|
||||
## [v0.2.33] - 2026-07-11
|
||||
|
||||
### 更新
|
||||
|
||||
- 重做【指令提示词】页面布局:当前状态、启用方式和模板列表使用统一的紧凑框架,模板来源与更新时间更容易查看。
|
||||
- GitHub 提示词目录改为权威同步:自动发现并缓存新增 `.md`,清理远端已删除模板和历史别名缓存;离线时仍可使用上次成功同步的内容。
|
||||
- GitHub `examples/` 新增 `海鸥3.0破甲.md`,并清理不再使用的旧示例模板。
|
||||
|
||||
### 修复 Bug
|
||||
|
||||
- 修复首次进入提示词页或切换导航返回时,会先显示历史缓存、随后再跳到 GitHub 最新模板的问题;同一次启动中会保留已同步状态,不再重复回退旧列表。
|
||||
- 修复提示词模板较多时列表溢出最外层边框的问题,外层面板现在会随完整列表正确增高。
|
||||
- 修复 GitHub 同步并发、部分模板下载失败和异常目录响应可能造成缓存状态错乱或误删的问题;失败后允许重新同步。
|
||||
- 修复远端模板已删除但仍在启用时,重启后可能无法识别和关闭的问题;当前生效模板会保留明确的关闭入口。
|
||||
- 删除 `gpt5.5-jeli.md` 介绍中多余的“测试生效”文案。
|
||||
- 修复 macOS Apple Silicon / Intel、Windows 和 Linux 应用图标外围出现大块白边的问题,重新生成全平台透明图标资源。
|
||||
|
||||
### 开发
|
||||
|
||||
- 增加 GitHub 模板动态发现、缓存去重、离线回退、过期缓存删除和异常目录保护测试,并为缓存刷新增加互斥与事务保护。
|
||||
- 修正发布脚本遗漏 `Cargo.lock` 的问题,确保 tag 内 Rust 包版本与应用版本一致。
|
||||
- 重新整理【指令提示词】页面,当前状态、启用方式、模板来源和更新时间更清晰,并简化模板说明。
|
||||
- 同步 GitHub 时会自动添加新模板、移除已删除模板;离线时仍可使用上次成功同步的内容。
|
||||
- 新增 `海鸥3.0破甲.md` 示例模板,清理不再使用的旧示例。
|
||||
- 修复返回提示词页面时列表先显示旧内容再跳到新内容,以及模板较多时列表超出边框的问题。
|
||||
- 修复同步或下载失败时模板列表可能出错、误删的问题,失败后可重新同步;正在使用的模板即使已从 GitHub 删除,重启后仍可正常关闭。
|
||||
- 修复 macOS、Windows 和 Linux 应用图标外围出现大块白边的问题。
|
||||
|
||||
## [v0.2.32] - 2026-07-11
|
||||
|
||||
### 更新
|
||||
|
||||
- 提示词页的“启用方式”新增问号说明,用户可以直接看到“保留原提示词 / 替换原提示词”对现有系统提示词和 `model_instructions_file` 的实际影响。
|
||||
- 提示词注入页面继续沿用统一的当前状态展示,方便判断当前模板与启用方式是否一致。
|
||||
|
||||
### 修复 Bug
|
||||
|
||||
- 修复提示词启用方式说明不够直观的问题,减少误操作风险。
|
||||
|
||||
### 修复 Bug
|
||||
|
||||
- 修复相同 Base URL、API Key、模型但 TOML 内容不同的供应商可能同时显示“当前”的问题;现在优先按完整 live TOML 匹配唯一配置,并避免重复展示 detected custom 卡片。
|
||||
- 修复追加提示词后禁用会误影响用户原有规则的风险;禁用只删除 Codex-X 管理的 AGENTS 区块或指令文件,用户其他内容保持不变。
|
||||
- 修复并发操作在同一毫秒创建备份时可能发生临时文件名冲突的问题。
|
||||
- 修复切换 Skill / MCP 开关后条目因启用状态排序而跳到列表其他位置的问题。
|
||||
- 修复 macOS Overlay 标题栏无法拖动的问题:补齐 Tauri 2 的 `core:window:allow-start-dragging` capability,并统一顶部拖动区域样式。
|
||||
- 修复 Windows 桌面应用环境中因 PATH 不完整或 `codex.cmd` 无法直接执行而检测不到 Codex CLI 版本的问题;新增 npm、ChatGPT、Cursor、VS Code 和常见安装目录探测。
|
||||
|
||||
### 开发
|
||||
|
||||
- 将 `AGENTS.md` 纳入 Codex-X 备份与恢复流程。
|
||||
- 增加受管区块合并/卸载、GitHub 动态模板发现、完整 TOML 供应商匹配、追加/替换模式和 AGENTS 恢复测试。
|
||||
- 提示词“启用方式”增加说明,方便了解“保留原提示词”和“替换原提示词”的区别,并核对当前启用状态。
|
||||
- 修复配置相近的多个供应商可能同时显示“当前启用”、重复显示供应商卡片的问题。
|
||||
- 关闭追加的提示词时会保留用户原有规则,自定义规则也支持备份和恢复。
|
||||
- 修复多项操作同时备份时可能失败的问题。
|
||||
- 修复切换 Skill 或 MCP 开关后,条目跳到列表其他位置的问题。
|
||||
- 修复 macOS 窗口顶部无法拖动的问题,并改善 Windows 上 Codex 版本的识别。
|
||||
|
||||
## [v0.2.31] - 2026-07-09
|
||||
|
||||
### 更新
|
||||
|
||||
- 调整 macOS Toast 展示位置:Toast 现在直接挂在窗口根层,不再被内容区域下移影响,提示位置更靠上、更自然。
|
||||
- 优化 Skills ZIP 安装体验:从 ZIP 安装 Skill 时会读取 `SKILL.md` 里的真实 `name` / `description`,不再显示 `skill-zip-时间戳` 这类临时目录名。
|
||||
- Skills 页面刷新时会自动识别旧版 `skill-zip-*` 目录,并尝试重命名为 Skill 自身名称。
|
||||
|
||||
### 修复 Bug
|
||||
|
||||
- 修复启用第三方供应商后,关闭并重新打开 Codex-X 不再显示“当前启用”的问题。
|
||||
- 修复 Windows / Linux 上供应商编辑页无法正常下拉滚动的问题,编辑 `config.toml` 时不再需要用 PageDown 绕过。
|
||||
- 修复 Toast 受内容容器 `contain` 影响导致 macOS 位置调整不生效的问题。
|
||||
|
||||
### 开发
|
||||
|
||||
- 增加 Skill 元数据读取与旧 ZIP Skill 目录自动修正的单元测试。
|
||||
- 调整 macOS 顶部通知的位置,避免提示随页面内容下移。
|
||||
- 从 ZIP 安装 Skill 后显示其实际名称和说明;刷新时会尝试修正旧版安装留下的临时名称。
|
||||
- 修复启用第三方供应商后,重新打开 Codex-X 不再显示“当前启用”的问题。
|
||||
- 修复 Windows 和 Linux 供应商编辑页无法正常向下滚动的问题。
|
||||
|
||||
## [v0.2.30] - 2026-07-09
|
||||
|
||||
### 更新
|
||||
|
||||
- 优化会话管理页面布局:同步状态、目标 Provider、聊天总数、已展示数量、需修复数量合并为紧凑信息栏,把更多空间留给会话列表。
|
||||
- 会话管理新增“启动自动修复”开关:开启后,Codex-X 启动时会在后台检查本地会话完整性,发现未同步会自动修复,不阻塞界面。
|
||||
- 优化按钮点击体验:移除容易造成 WebView 闪烁/掉帧的水波纹、位移和 filter 效果,点击反馈更轻、更稳。
|
||||
|
||||
### 修复 Bug
|
||||
|
||||
- 修复同名供应商可能同时显示为“当前启用”的问题;Codex-X 现在会记录最后启用的供应商 ID,避免第三方 live Provider 都是 `custom` 时出现双亮。
|
||||
- 修复新增同名供应商时 ID 冲突的问题;新建供应商会自动生成唯一 ID。
|
||||
- 修复提示词管理中同名/同文件名 md 可能同时显示为启用的问题;新增或导入提示词会自动生成唯一文件名,当前状态按实际启用文件精确匹配。
|
||||
- 修复会话管理中会话选择反馈不明显的问题;已选数量和可修复数量会实时展示。
|
||||
|
||||
### 体验
|
||||
|
||||
- 进一步降低供应商、提示词、TOML、会话管理等页面滚动时的闪烁和低帧率感。
|
||||
|
||||
- 精简会话管理顶部信息栏,为会话列表留出更多空间;选择会话时实时显示已选数量和可修复数量。
|
||||
- 新增“启动自动修复”开关,开启后会在启动时后台检查会话,并自动修复未同步的会话,不影响界面操作。
|
||||
- 减少按钮点击和页面滚动时的闪烁、卡顿。
|
||||
- 修复同名供应商可能同时显示“当前启用”,以及新增同名供应商发生冲突的问题。
|
||||
- 修复同名提示词可能同时显示为启用的问题。
|
||||
|
||||
## [v0.2.29] - 2026-07-07
|
||||
|
||||
### 更新
|
||||
|
||||
- 优化 Toast 提示样式:改为窗口顶部居中的产品化通知卡片,减少遮挡右上角操作按钮。
|
||||
- Provider 连接测试文案改为简洁结果:成功显示“连接正常(耗时)”,失败显示 HTTP 状态与耗时。
|
||||
- 第三方供应商编辑页支持保存 TOML 模板;点击“保存”只保存配置,点击“启用”时才写入 Codex live config。
|
||||
- 技能和 MCP 页面“导入已有”改为先展示预览确认弹窗,列出将导入的 Skills / MCP,用户确认后才执行导入。
|
||||
|
||||
### 修复 Bug
|
||||
|
||||
- 修复 Codex 第三方 API auth 写入错误:`auth.json` 现在写入 Codex 实际识别的 `auth_mode = "apikey"`,避免桌面端回到登录页。
|
||||
- 修复官方认证编辑页打开时会静默加载 cc-switch 旧 token,导致新登录账号 token 显示不更新的问题;现在默认读取 live `~/.codex/auth.json`,并提供“刷新当前 auth.json”。
|
||||
- 修复切换到 OpenAI Official 时可能覆盖当前 live auth.json 的问题,避免把旧账号登录态写回。
|
||||
- 修复 Provider 测试把 HTTP 403 误判为“连接正常”的问题;现在只有 2xx 状态会显示成功。
|
||||
- 修复从 cc-switch 导入 Provider 时同名同 URL 供应商可能产生重复卡片的问题。
|
||||
- 修复 SQLite 轻量迁移重复执行时 `duplicate column name: toml_config` 导致启动报错的问题。
|
||||
|
||||
### 开发
|
||||
|
||||
- 增加 Provider/auth 相关单元测试,覆盖第三方切换、官方 auth 保留、403 判定和 cc-switch 导入串台场景。
|
||||
|
||||
- 通知改为顶部居中显示,减少对右上角按钮的遮挡;供应商连接测试结果更简洁,显示连接状态和耗时。
|
||||
- 第三方供应商支持保存配置模板:“保存”只保存配置,点击“启用”后才应用到 Codex。
|
||||
- Skills 和 MCP 的“导入已有”增加预览,确认列表后才执行导入。
|
||||
- 修复切换第三方供应商后 Codex 可能回到登录页的问题。
|
||||
- 官方认证编辑页默认读取当前登录信息,并提供“刷新当前 auth.json”;修复切回官方登录时可能恢复成旧账号的问题。
|
||||
- 修复连接测试将“访问被拒绝”误报为连接正常,以及从 cc-switch 导入时出现重复供应商卡片的问题。
|
||||
- 修复部分用户升级后无法启动的问题。
|
||||
|
||||
## [v0.2.28] - 2026-07-07
|
||||
|
||||
- 修复从 cc-switch 导入 Codex Provider 时可能把供应商名称、base_url 与 API Key 串台的问题。
|
||||
- 导入 cc-switch Provider 时改为先扫描所有 `[model_providers.<id>]` section,再按 provider row id 精确匹配,避免使用过期的 `model_provider` active 值。
|
||||
- 兼容 cc-switch legacy `custom` provider 模板,同时保留 `experimental_bearer_token` 兜底。
|
||||
- 增加复现 Sky2api / MagicAI 交叉配置的单元测试,防止 Provider 切换再次出现“看起来切了但实际没切对”的问题。
|
||||
- 修复从 cc-switch 导入供应商时,名称、API 地址和密钥可能相互混淆的问题,避免启用后实际使用了其他供应商。
|
||||
- 改善旧版 cc-switch 供应商配置的导入兼容性。
|
||||
|
||||
## [v0.2.27] - 2026-07-07
|
||||
|
||||
- 优化【指令提示词】页面为 Skills/MCP 风格的简洁列表:左侧显示模板名称与说明,右侧使用开启/关闭切换;自定义/导入的 md 提示词增加编辑与删除图标。
|
||||
- 优化【供应商】页面操作区:移除“官方配置 / 本地保存 / gpt-5.5 / 不支持路由”等冗余信息,将切换改为“启用”,编辑/删除/测试连接改为图标按钮。
|
||||
- 新增供应商 base_url 连通性测试按钮,方便切换前检查第三方 API 地址是否可达。
|
||||
- 修复第三方 Provider 切换后可能未真正生效的问题:切换和保存 TOML 时会写入 Codex 实际读取的 `experimental_bearer_token`。
|
||||
- 对齐 cc-switch 的 Codex Provider live config 思路:从 cc-switch 导入时会识别 `experimental_bearer_token`,并避免使用 Codex 内置保留 provider id。
|
||||
- 优化启动加载链路:启动诊断、备份列表、会话同步状态改为后台刷新,减少首屏等待。
|
||||
- 提示词页面改为简洁列表,名称和说明更清晰,右侧可直接开关;自定义和导入的提示词支持编辑、删除。
|
||||
- 精简供应商操作区,将“切换”改为“启用”,编辑、删除和连接测试改为图标按钮。
|
||||
- 新增供应商连接测试,方便启用前检查第三方 API 地址是否可用。
|
||||
- 修复第三方供应商切换后可能未真正生效的问题,并改善从 cc-switch 导入配置的兼容性。
|
||||
- 减少启动等待,检查、备份列表和会话状态在后台加载。
|
||||
|
||||
## [v0.2.26] - 2026-07-07
|
||||
|
||||
- 修复第三方 Provider 切换后可能“看起来已切换但实际未按新供应商生效”的问题:不再把所有第三方都写成 `model_provider = "custom"`,而是写入供应商自己的稳定 ID。
|
||||
- 修复从官方 ChatGPT 登录态切到第三方 API Key 时 `auth.json` 仍保留 `auth_mode = "chatgpt"` 的问题;写入 API Key 时会同步设置 `auth_mode = "apikey"`。
|
||||
- 第三方供应商默认不再要求 OpenAI 登录态,避免新建/导入 Provider 时错误继承官方 auth 语义。
|
||||
- 增加 Provider 切换单元测试,覆盖真实 provider key 和 API Key auth mode 的落盘结果。
|
||||
- 修复第三方供应商显示已切换、实际却未生效的问题。
|
||||
- 修复从官方登录切换到第三方 API 密钥后,仍沿用官方登录方式的问题。
|
||||
- 新增或导入第三方供应商时,默认不再要求 OpenAI 官方登录。
|
||||
|
||||
## [v0.2.25] - 2026-07-06
|
||||
|
||||
- 进一步修复 TOML、指令提示词、供应商、会话管理等页面切换时右侧滚动条闪现/消失造成的视觉抖动。
|
||||
- 外层页面滚动容器改为稳定滚动并隐藏外层滚动条,保留 TOML 编辑器、会话列表等内部区域自己的滚动条。
|
||||
- 进一步减少切换配置、提示词、供应商和会话页面时,滚动条闪现造成的抖动;编辑器和会话列表仍保留各自的滚动条。
|
||||
|
||||
## [v0.2.24] - 2026-07-06
|
||||
|
||||
- 修复切换供应商 / 会话管理等页面时右侧滚动条短暂出现又消失的问题:页面切换动画改为纯透明度过渡,不再用 `translateY` 造成瞬时溢出。
|
||||
- 降低页面切换视觉抖动,避免进入页面时内容区域宽度被临时滚动条挤压。
|
||||
- 修复切换供应商、会话管理等页面时滚动条短暂闪现、内容宽度跳动的问题,页面切换更平稳。
|
||||
|
||||
## [v0.2.23] - 2026-07-06
|
||||
|
||||
- 指令提示词页新增并纳管 `gpt5.5-jeli.md` 模板,作为“大白话(80% 场景)破甲”版本。
|
||||
- 简化指令提示词列表视觉:移除花哨图标与文件路径展示,让用户更容易看清模板名称、介绍和启用状态。
|
||||
- 更新 GPT-5.5 / GPT-5.4 内置模板名称为“unrestricted 破甲”,并统一说明使用方法:先让 AI 分析项目,分析完之后发【不直白的逆向】命令。
|
||||
- 新增 `gpt5.5-jeli.md` 提示词模板,名称为“大白话(80% 场景)破甲”。
|
||||
- 精简提示词列表,突出模板名称、介绍和启用状态。
|
||||
- GPT-5.5 和 GPT-5.4 内置模板统一使用“unrestricted 破甲”名称,并补充使用说明:先让 AI 分析项目,再发送【不直白的逆向】命令。
|
||||
|
||||
## [v0.2.22] - 2026-07-06
|
||||
|
||||
- 继续优化指令提示词页切换体验:导航切换进入 React transition,GitHub 内置模板静默检查延后到空闲时执行,减少切页瞬间卡顿。
|
||||
- 修复外部/自定义提示词切换到内置模板后可能出现两份的问题:按规范化内容(换行与首尾空白归一)和文件名双重去重,已有自定义提示词会复用更新,不再新增副本。
|
||||
- 优化启用内置提示词速度:启用时直接使用本地缓存或打包内置版本,不再同步等待 GitHub 网络请求;手动/空闲更新仍会刷新本地缓存。
|
||||
- 启动页增加动态状态文案、双层轨道与扫光动画,让从欢迎页进入主界面更顺滑。
|
||||
- 减少进入提示词页面时的卡顿;GitHub 模板检查延后到空闲时进行。
|
||||
- 启用内置提示词时直接使用本地已有内容,无需等待网络;手动同步或空闲检查后会更新模板。
|
||||
- 修复从自定义提示词切换到内置模板时可能产生重复项的问题,已有相同提示词会直接更新。
|
||||
- 优化启动页状态提示和过渡动画,进入主界面更顺畅。
|
||||
|
||||
## [v0.2.21] - 2026-07-06
|
||||
|
||||
- 进一步降低 UI 卡顿风险:将启动检测、Codex 状态读取、Provider/官方配置/备份/cc-switch 导入等剩余同步命令迁移到后台 worker。
|
||||
- 优化前端串行请求:提示词启用后的备份/提示词刷新改为并发,保存并启用自定义提示词时不再重复拉取列表。
|
||||
- 完善【技能和 MCP】导入已有:读取 cc-switch `mcp_servers` 数据库,按 `enabled_codex` 纳管并同步到 Codex `config.toml [mcp_servers]`。
|
||||
- 增强 Skills 检查更新:对带有 cc-switch 仓库元数据的 Skill 拉取 GitHub 仓库 ZIP 计算远程 hash,显示“有新版本 / 已是最新 / 远程检查失败”等状态。
|
||||
- 减少启动、供应商操作、备份和导入时的卡顿,并加快提示词保存、启用后的刷新。
|
||||
- “导入已有”支持从 cc-switch 导入已为 Codex 启用的 MCP,并应用到 Codex。
|
||||
- 来自 cc-switch 且保留仓库信息的 Skill 可检查 GitHub 更新,显示“有新版本”“已是最新”或“远程检查失败”。
|
||||
|
||||
## [v0.2.20] - 2026-07-06
|
||||
|
||||
- 继续优化指令提示词页性能:提示词列表/状态读取、保存、导入、启用、禁用等后端操作改为后台 worker,避免切页或启用模板时阻塞 UI。
|
||||
- 修复外部自定义提示词重复保存问题:切换到内置模板前会按文件名与内容双重去重,并自动清理历史 `external-*` 重复项。
|
||||
- 优化启动加载体验:启动页增加最短展示、退出淡出与动态过渡,避免从欢迎页突然跳到主页。
|
||||
- 改善提示词页面响应速度,减少切换页面、保存、导入和开关模板时的卡顿。
|
||||
- 修复从自定义提示词切换到内置模板后重复保存的问题,并自动整理已有的此类重复项。
|
||||
- 优化启动页展示和退出动画,避免突然跳到主页。
|
||||
|
||||
## [v0.2.19] - 2026-07-06
|
||||
|
||||
- 新增【技能和 MCP】页面:展示 Codex 当前已安装 Skills 与 MCP,支持导入已有、从 ZIP 安装 Skill、启用/禁用 Skill、启用/禁用 MCP。
|
||||
- MCP 管理会读写 `~/.codex/config.toml` 的 `[mcp_servers]`,禁用后保留到 Codex-X SQLite,后续可一键重新启用。
|
||||
- Skills 管理会扫描 `~/.codex/skills`,并可从 `~/.agents/skills`、`~/.cc-switch/skills` 导入到 Codex;禁用后移动到 Codex-X 禁用目录,避免直接删除。
|
||||
- 优化多个潜在卡顿点:指令提示词 GitHub 检查改为延迟后台执行,远程拉取、会话扫描/同步、Skills/MCP 扫描均放入后台 blocking worker。
|
||||
- 修复外部自定义提示词切换到内置提示词后重复出现的问题,并自动清理同名 `external-*` 重复项。
|
||||
- 增加页面切换过渡、启动页动态光效和首次启动向导退出动画,降低页面突然跳转感。
|
||||
- 新增【技能和 MCP】页面,可查看已安装内容、导入已有配置、从 ZIP 安装 Skill,以及启用或禁用 Skill 和 MCP。
|
||||
- 禁用 Skill 或 MCP 后仍保留原有内容,可随时重新启用;支持导入本机已有的 Skill 和 cc-switch 中的技能。
|
||||
- 减少 GitHub 模板检查、会话检查与同步、Skills 和 MCP 加载时的卡顿。
|
||||
- 修复从自定义提示词切换到内置模板后出现重复项的问题,并自动整理已有的同名重复项。
|
||||
- 优化页面切换、启动页和首次启动向导的过渡动画。
|
||||
|
||||
## [v0.2.18] - 2026-07-05
|
||||
|
||||
- 指令提示词内置模板支持从 GitHub `examples/` 实时检查更新,并缓存到本地;启用内置模板时优先使用 GitHub 最新版本,离线时自动回退本地缓存或打包内置版本。
|
||||
- 指令提示词页面新增“更新内置模板”状态与来源展示,可看到模板来自 GitHub 最新、本地缓存或打包内置。
|
||||
- 继续保留导入 `.md` 提示词、外部提示词自动记忆、会话管理交互优化和 API Key 可见切换等体验改进。
|
||||
- 内置提示词支持从 GitHub 检查更新,启用时优先使用最新模板;离线时可使用本地缓存或随应用提供的模板。
|
||||
- 提示词页面新增更新状态和模板来源提示。
|
||||
|
||||
## [v0.2.17] - 2026-07-04
|
||||
|
||||
- 修复 macOS Intel Release 构建 runner:从已不可用/长时间排队的 `macos-13` 切换为 `macos-15-intel`。
|
||||
- 继续保留 macOS Apple Silicon / macOS Intel / Windows MSI / Windows portable ZIP / Linux deb/rpm 多平台产物。
|
||||
- 修复 Intel Mac 安装包发布失败或长时间等待的问题。
|
||||
|
||||
## [v0.2.16] - 2026-07-04
|
||||
|
||||
- Release 新增 macOS Intel 构建,Intel Mac 用户可下载 x64 DMG。
|
||||
- macOS Release 现在同时提供 Apple Silicon 与 Intel 两种 DMG。
|
||||
- Windows Release 新增 portable ZIP,包含可直接运行的 `Codex-X.exe`,无需 MSI 安装。
|
||||
- 发布流程支持上传 `.zip` portable 产物,并更新 README 下载说明。
|
||||
- 新增 Intel Mac 版 DMG,macOS 用户可按芯片类型选择 Intel 或 Apple Silicon 安装包。
|
||||
- 新增 Windows 免安装 ZIP,解压后即可运行,无需安装 MSI。
|
||||
|
||||
## [v0.2.15] - 2026-07-04
|
||||
|
||||
- 会话管理页移除 CODEX_HOME 与 Provider Sync 备份位置展示,页面信息更简洁。
|
||||
- 优化 Windows 打开下载页体验:后端改为后台线程 spawn 浏览器,不再等待 `cmd /C start`,避免 WebView 卡顿 2-3 秒。
|
||||
- 统一所有外部链接按钮走异步打开逻辑,打开项目主页、反馈页、下载页都不会阻塞界面。
|
||||
- 修复 macOS 顶部 toast 被 Overlay 标题栏遮挡的问题,toast 自动下移到标题栏安全区下方。
|
||||
- 精简会话管理页面,移除不常用的配置和备份位置说明。
|
||||
- 修复 Windows 打开下载页、项目主页等外部链接时界面短暂卡顿的问题。
|
||||
- 修复 macOS 顶部提示消息被标题栏遮挡的问题。
|
||||
|
||||
## [v0.2.14] - 2026-07-04
|
||||
|
||||
- 修复 macOS Overlay 标题栏遮挡内容的问题,为红黄绿窗口按钮预留完整安全区。
|
||||
- 调整侧边栏、内容区、Provider/TOML/指令提示词/会话管理页高度计算,避免顶部内容被标题栏压住。
|
||||
- 加高 macOS 顶部拖拽区域并保持深色渐变,窗口顶部继续和应用色系统一。
|
||||
- 修复 macOS 标题栏和窗口按钮遮挡页面内容的问题。
|
||||
- 增大窗口顶部拖拽区域,并统一深色外观。
|
||||
|
||||
## [v0.2.13] - 2026-07-04
|
||||
|
||||
- macOS 窗口标题栏改为深色融合样式,避免顶部出现系统白色标题条。
|
||||
- 会话管理页面移除多余外层玻璃边框,统一为完整内容容器。
|
||||
- 优化会话列表边界与滚动区域,避免列表像卡在外层框外面。
|
||||
- macOS 标题栏改为与应用一致的深色样式。
|
||||
- 简化会话管理页面边框,改善列表布局和滚动体验。
|
||||
|
||||
## [v0.2.12] - 2026-07-04
|
||||
|
||||
- Windows MSI 安装器回退为默认简洁样式,移除上一版过重的自定义安装界面图。
|
||||
- 【会话管理】页面新增会话列表,展示标题、Provider、模型、工作目录、更新时间、归档/需同步状态。
|
||||
- 会话扫描会从 Codex 本地 SQLite threads 表读取最近会话,方便用户直接判断哪些历史 thread 需要同步修复。
|
||||
- Windows MSI 恢复简洁的安装界面。
|
||||
- 会话管理新增历史会话列表,可查看标题、供应商、模型、工作目录、更新时间,以及归档和待同步状态。
|
||||
|
||||
## [v0.2.11] - 2026-07-04
|
||||
|
||||
- 调整首次启动自动检查更新体验:不再弹出居中的强提醒窗口。
|
||||
- 自动检测到新版本时仅显示轻量 toast,并保留概览页顶部“发现新版本”提示条。
|
||||
- 只有用户在【关于】页面主动点击“检查更新”时,检测到新版本才弹出“现在下载 / 稍后”窗口。
|
||||
- 启动时发现新版本改为轻量提示,概览页仍保留更新提示条。
|
||||
- 仅在“关于”页面主动检查更新并发现新版本时,弹出“现在下载 / 稍后”窗口。
|
||||
|
||||
## [v0.2.10] - 2026-07-04
|
||||
|
||||
- 修复 Release 发布任务上传 Linux 产物时误把 `deb/`、`rpm/` 目录当作资产上传的问题。
|
||||
- 发布任务现在只收集 `.dmg` / `.msi` / `.deb` / `.rpm` 文件并统一上传。
|
||||
- 补齐稳定的三平台 Release 自动发布流程。
|
||||
- 修复 Linux 安装包上传失败的问题,完善 macOS、Windows 和 Linux 安装包发布。
|
||||
|
||||
## [v0.2.9] - 2026-07-04
|
||||
|
||||
- 修复 GitHub Actions Release 发布流程:不再由三平台矩阵并发创建 Release,改为先上传构建产物,再由单独发布任务统一创建/更新 Release。
|
||||
- 修复 `Resource not accessible by integration` 导致 Release 创建失败的问题。
|
||||
- Release 仍会从 `CHANGELOG.md` 自动读取当前 tag 的更新日志,并上传 macOS / Windows / Linux 安装包。
|
||||
- 修复 GitHub 版本发布失败的问题。
|
||||
|
||||
## [v0.2.8] - 2026-07-04
|
||||
|
||||
- 更新页进一步产品化:去掉资源/仓库调试信息,将“有更新”改为更明显的绿色标签。
|
||||
- 概览页顶部新增轻量“发现新版本”提示条,可直接打开 Releases 页面。
|
||||
- 新增【会话管理】页面:检查 Codex 本地 sessions / archived_sessions 与 SQLite threads 是否和当前 Provider 同步。
|
||||
- 新增一键 Provider Sync / 修复历史会话:写入前备份到 `~/.codex/backups_state/provider-sync/`,并保留最近 5 份备份。
|
||||
- 简化更新页面,使用醒目的绿色标签标示新版本;概览页新增可直接打开下载页的更新提示条。
|
||||
- 新增“会话管理”页面,可检查历史会话是否与当前供应商匹配,并一键同步修复。
|
||||
- 修复会话前自动备份,保留最近 5 份备份。
|
||||
|
||||
## [v0.2.7] - 2026-07-04
|
||||
|
||||
- 简化更新检查页展示。
|
||||
- “打开下载页”改为打开 GitHub Releases 页面。
|
||||
- 更新弹窗保持简洁,仅提示版本差异。
|
||||
- 首次启动自动检查更新并弹窗提醒。
|
||||
- 简化更新页面和弹窗,“打开下载页”直接前往 GitHub 版本下载页面。
|
||||
- 首次启动自动检查更新,发现新版本时弹窗提醒。
|
||||
|
||||
## [v0.2.5] - 2026-07-04
|
||||
|
||||
- Windows 版双击启动不再额外弹出终端窗口。
|
||||
- 改进 Windows MSI 安装器品牌展示与图标。
|
||||
- About 页面外部链接改为系统默认浏览器打开。
|
||||
- Release 流程加入 Rust cache,后续构建更快。
|
||||
- 修复 Windows 双击启动时额外弹出终端窗口的问题。
|
||||
- 改善 Windows MSI 安装界面的应用名称和图标展示。
|
||||
- “关于”页面的外部链接使用系统默认浏览器打开。
|
||||
|
||||
## [v0.2.4] - 2026-07-04
|
||||
|
||||
- 美化 Windows MSI 安装器横幅与对话图。
|
||||
- Windows 安装包加入应用图标。
|
||||
- 美化 Windows MSI 安装界面,并为安装包加入应用图标。
|
||||
|
||||
## [v0.2.3] - 2026-07-04
|
||||
|
||||
- 修复 macOS DMG 中应用图标缺失问题。
|
||||
- 补充 macOS 安装说明。
|
||||
- Release 流程加入基础缓存优化。
|
||||
- 修复 macOS DMG 中应用图标缺失的问题,并补充安装说明。
|
||||
|
||||
## [v0.2.2] - 2026-07-04
|
||||
|
||||
- macOS / Linux / Windows 首次三平台 Release。
|
||||
- Linux 产物改为 `deb` / `rpm`,避免 AppImage 图标问题。
|
||||
- 首次提供 macOS、Linux 和 Windows 三个平台的安装包。
|
||||
- Linux 改为提供 DEB 和 RPM 安装包。
|
||||
|
||||
## [v0.2.1] - 2026-07-04
|
||||
|
||||
- 首次加入应用图标与 GitHub Release 自动发布。
|
||||
- 首次加入应用图标,并通过 GitHub 提供版本下载。
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
**Codex Prompts · API / Providers · Sessions · Skills / MCP in One Place**
|
||||
|
||||
A cross-platform desktop tool for **OpenAI Codex Desktop / Codex CLI**. Manage prompt templates, switch third-party APIs, organize / repair / permanently delete local sessions, manage Skills / MCP, apply macOS / Windows skins, and inspect TOML and login credentials without repeatedly editing configuration files by hand.
|
||||
A cross-platform desktop tool for **OpenAI Codex Desktop / Codex CLI**. Manage prompt templates, switch third-party APIs, organize / repair / permanently delete local sessions, manage Skills / MCP, and inspect TOML and login credentials without repeatedly editing configuration files by hand.
|
||||
|
||||
<p>
|
||||
<img src="https://img.shields.io/github/v/release/yynxxxxx/Codex-X?label=version&color=blue" alt="version" />
|
||||
@@ -37,9 +37,10 @@ When you use Codex Desktop, the CLI, third-party APIs, and multiple prompts toge
|
||||
You can use it to:
|
||||
|
||||
- Choose from the prompt library or import your own `.md`, then keep the existing prompt or replace it
|
||||
- Save, test, and switch between OpenAI Official and third-party APIs, including Providers imported from cc-switch
|
||||
- Manage multiple named official Codex login profiles and third-party APIs, duplicate or switch them with a click, and import Providers from cc-switch
|
||||
- Search and organize local sessions by project, check / automatically repair problems, and precisely delete sessions you no longer need
|
||||
- Manage Skills and MCP in one place, and inspect the current `config.toml`, `auth.json`, and operation backups
|
||||
- Explore token usage by date and model in Settings, with subagent usage attributed to its main conversation
|
||||
|
||||
## Preview
|
||||
|
||||
@@ -117,7 +118,7 @@ You can use it to:
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">⚡ <b>Switch APIs / relays</b></td>
|
||||
<td align="left">Save, test, and enable multiple third-party Providers, or import them from cc-switch; entries with the same endpoint and Key are merged automatically. Create or reopen a session after switching to apply the change without restarting the entire Codex client.</td>
|
||||
<td align="left">Save multiple named official Codex login profiles alongside third-party Providers, with one-click duplication and switching. Third-party Providers support connection checks, model discovery / testing, and imports from cc-switch.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">💬 <b>Organize local sessions</b></td>
|
||||
@@ -129,11 +130,11 @@ You can use it to:
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">📝 <b>Manage config and login</b></td>
|
||||
<td align="left">Inspect the active Codex <code>config.toml</code> and <code>auth.json</code> in one place, with a clear distinction between official login credentials and third-party API Keys. Important writes are backed up automatically and can be restored when needed.</td>
|
||||
<td align="left">Inspect <code>config.toml</code> and <code>auth.json</code> in one place. Both official and third-party profile editors offer an “Enable 1M context window” checkbox beside <code>config.toml>; it applies on save and requires model support. Important writes are backed up automatically.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">🎨 <b>Apply macOS / Windows skins</b></td>
|
||||
<td align="left">Use the bundled Shiina Mashiro art-studio theme with real-wallpaper, full-interface previews. Import or export packs, hot-switch themes, and turn the skin off directly. The runtime is built into Codex-X; the separate Codex Dream Skin engine is not required.</td>
|
||||
<td align="center">📊 <b>Track token usage</b></td>
|
||||
<td align="left">Switch between General and Usage statistics in Settings. Filter local usage by date and model, explore daily trends, cache hit rate, and model distribution, and view the 10 most recent main conversations. Subagent usage is attributed to its main conversation and does not count as a separate session.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">📦 <b>Use it across platforms</b></td>
|
||||
@@ -242,7 +243,8 @@ A backup is created automatically before every enable or disable action. In addi
|
||||
> [!NOTE]
|
||||
> After enabling a new third-party Provider, create or reopen a Codex session to use the new relay. You do not need to restart the entire Codex client.
|
||||
|
||||
- Save multiple third-party Providers and always see which one is currently active
|
||||
- Save multiple named official Codex login profiles and third-party Providers, and always see which one is currently active
|
||||
- Duplicate a profile immediately with one click, without a confirmation dialog or opening the editor; rename it whenever needed
|
||||
- Test an API endpoint before switching, and save or enable a configuration separately
|
||||
- Edit the Base URL, API Key, Model, Wire API, and complete TOML on the same page
|
||||
- cc-switch imports report added, updated, merged, and skipped entries; the same URL + Key is no longer shown more than once
|
||||
@@ -405,15 +407,6 @@ pnpm --dir apps/desktop tauri build
|
||||
|
||||
## Desktop Installation Notes
|
||||
|
||||
### Skin Center security boundary
|
||||
|
||||
- Live skin application supports macOS and Windows. Linux can still manage, import, and export packs but is never reported as applied.
|
||||
- macOS uses only the signed Node.js bundled with the official Codex app and validates its bundle and process identity. Windows dynamically validates the current user's registered `OpenAI.Codex` Store package and uses the pinned Node.js 22 runtime shipped with Codex-X releases.
|
||||
- CDP binds only to `127.0.0.1`; Codex-X does not modify the official `.app`, `WindowsApps`, `app.asar`, code signature, directory permissions, or `config.toml`.
|
||||
- First application usually asks to restart Codex. Turn off skin removes the live interface skin immediately, and any theme can be applied again later.
|
||||
- Themes adapt cropping from image dimensions and content. Subject-focused artwork can use `art.focusX`, `art.focusY` (`0` to `1`), and `art.safeArea` in `theme.json` for precise framing without generating duplicate images for the same theme.
|
||||
- Injection is based on the MIT-licensed [Fei-Away/Codex-Dream-Skin](https://github.com/Fei-Away/Codex-Dream-Skin) source. The pinned source and license notice are in `apps/desktop/src-tauri/resources/skin-runtime/NOTICE.md`.
|
||||
|
||||
If you see “app is damaged” when opening an unsigned / unnotarized DMG, this is normal macOS Gatekeeper behavior.
|
||||
|
||||
- Best option: sign and notarize with an Apple Developer ID
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
**Codex 可视化提示词注入 · Provider · 会话 · Skills / MCP 管理工具**
|
||||
|
||||
一款面向 **OpenAI Codex 桌面端 / Codex CLI** 的跨平台桌面工具。把提示词模板、自定义 Prompt、第三方 API 供应商、会话同步、Skills / MCP、macOS / Windows 皮肤和 TOML 配置都放进可视化界面里,不用反复手改文件。
|
||||
一款面向 **OpenAI Codex 桌面端 / Codex CLI** 的跨平台桌面工具。把提示词模板、自定义 Prompt、第三方 API 供应商、会话同步、Skills / MCP 和 TOML 配置都放进可视化界面里,不用反复手改文件。
|
||||
|
||||
<p>
|
||||
<img src="https://img.shields.io/github/v/release/yynxxxxx/Codex-X?label=version&color=blue" alt="version" />
|
||||
@@ -38,9 +38,10 @@
|
||||
|
||||
- 像管理插件一样管理提示词:分类、导入 Markdown、自定义编辑、一键启用 / 禁用
|
||||
- 内置 5 套提示词模板,同时支持用户把自己的提示词变成可视化模板库
|
||||
- 保存、测试并切换 OpenAI Official 与第三方 API,还能从 cc-switch 导入现有供应商
|
||||
- 管理多个可命名的官方 Codex 登录与第三方 API,一键复制、切换,并从 cc-switch 导入现有供应商
|
||||
- 同步、检查、搜索和删除本地会话,按项目路径整理 Codex 历史记录
|
||||
- 集中管理 Skills 与 MCP,查看当前 `config.toml`、`auth.json` 和操作备份
|
||||
- 在设置中按日期、模型查看 Token 用量趋势,子代理用量归入所属主会话
|
||||
|
||||
## 软件预览
|
||||
|
||||
@@ -97,7 +98,7 @@
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><b>Provider / API</b></td>
|
||||
<td align="left">添加、编辑、启用、删除第三方供应商;支持连接检测、模型获取 / 测试、从 cc-switch 导入,并可在 OpenAI Official 与中转 API 之间切换。</td>
|
||||
<td align="left">保存多个可命名的官方 Codex 登录配置,与第三方供应商统一管理、一键复制和切换;第三方供应商支持连接检测、模型获取 / 测试及从 cc-switch 导入。</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><b>会话管理</b></td>
|
||||
@@ -109,11 +110,11 @@
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><b>配置与登录</b></td>
|
||||
<td align="left">集中查看 Codex 当前使用的 <code>config.toml</code> 与 <code>auth.json</code>,区分官方登录态和第三方 API Key;重要写入前自动备份。</td>
|
||||
<td align="left">集中查看 <code>config.toml</code> 与 <code>auth.json</code>;官方登录和第三方供应商编辑页均可在 <code>config.toml</code> 旁勾选“开启 1M 上下文窗口”,保存后生效,需模型支持;重要写入前自动备份。</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><b>macOS / Windows 皮肤中心</b></td>
|
||||
<td align="left">内置“椎名真白·樱花画室”主题,以真实壁纸和完整界面缩略图展示主题效果,支持导入 / 导出、热切换和关闭皮肤;换肤运行时已经内置在 Codex-X 中,无需另装 Codex Dream Skin 引擎。</td>
|
||||
<td align="center"><b>用量统计</b></td>
|
||||
<td align="left">设置页提供“通用设置 / 用量统计”标签,按日期与模型筛选本地 Token 用量,查看每日趋势、缓存命中率、模型分布及最近 10 个主会话;子代理用量归入所属主会话,不单独计为会话。</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><b>跨平台使用</b></td>
|
||||
@@ -234,7 +235,8 @@ Codex-X 现在不只是“几套内置 Prompt”的启动器,而是一个可
|
||||
> [!NOTE]
|
||||
> 启用新的第三方供应商后,新建或重新打开 Codex 会话即可使用新的中转,不需要重启整个 Codex 客户端。
|
||||
|
||||
- 保存多个第三方供应商,随时查看当前正在使用哪一个
|
||||
- 保存多个可命名的官方 Codex 登录配置与第三方供应商,随时查看当前正在使用哪一个
|
||||
- 点击“复制”直接新增独立副本,无需确认或进入编辑页;名称可稍后修改
|
||||
- 切换前可检测连接,并可获取模型进行测试
|
||||
- 在同一页面编辑 Base URL、API Key、Model、Wire API 和完整 TOML
|
||||
- 从 cc-switch 导入时自动区分新增、更新、合并与跳过;相同 URL + Key 不再重复显示
|
||||
@@ -394,15 +396,6 @@ pnpm --dir apps/desktop tauri build
|
||||
|
||||
## 桌面端安装说明
|
||||
|
||||
### 皮肤中心安全边界
|
||||
|
||||
- 实机换肤支持 macOS 和 Windows;Linux 仍可管理、导入和导出主题包,但不会显示为已应用。
|
||||
- macOS 只使用官方 Codex 内置且签名一致的 Node.js,并校验 Bundle ID、代码签名和进程归属;Windows 动态校验当前用户注册的 `OpenAI.Codex` Store 包,并使用发布包内固定校验的 Node.js 22 运行时。
|
||||
- CDP 仅绑定 `127.0.0.1`,不修改官方 `.app`、`WindowsApps`、`app.asar`、代码签名、目录权限或 `config.toml`。
|
||||
- 首次应用通常需要确认重启 Codex;“关闭皮肤”会立即移除当前界面皮肤,之后可随时重新应用主题。
|
||||
- 主题默认会根据图片尺寸和内容自适应裁切;人物或主体构图可在 `theme.json` 使用 `art.focusX`、`art.focusY`(`0` 到 `1`)和 `art.safeArea` 精确控制,无需为同一主题重复生成图片。
|
||||
- 注入实现基于 [Fei-Away/Codex-Dream-Skin](https://github.com/Fei-Away/Codex-Dream-Skin) 的 MIT 源码,固定来源和许可见 `apps/desktop/src-tauri/resources/skin-runtime/NOTICE.md`。
|
||||
|
||||
如果你在未签名 / 未公证的 DMG 中看到“软件已损坏”提示,这是 macOS Gatekeeper 的正常行为。
|
||||
|
||||
- 最佳方式:使用 Apple Developer ID 签名并 notarize
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "codex-x",
|
||||
"version": "0.3.6",
|
||||
"version": "0.3.17",
|
||||
"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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Codex Dream Skin Studio contributors
|
||||
Copyright (c) 2023 LobeHub
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
@@ -0,0 +1,16 @@
|
||||
# Provider brand marks
|
||||
|
||||
These six unmodified SVG assets are from [Lobe Icons](https://github.com/lobehub/lobe-icons), licensed under the MIT License. The upstream copyright and permission notice is included in [LICENSE](./LICENSE).
|
||||
|
||||
Pinned upstream commit: `a94750e3f5f8fc33757b839d85030e742284e43a`.
|
||||
|
||||
| Local file | Upstream source |
|
||||
| --- | --- |
|
||||
| `deepseek.svg` | [packages/static-svg/icons/deepseek.svg](https://github.com/lobehub/lobe-icons/blob/a94750e3f5f8fc33757b839d85030e742284e43a/packages/static-svg/icons/deepseek.svg) |
|
||||
| `minimax.svg` | [packages/static-svg/icons/minimax.svg](https://github.com/lobehub/lobe-icons/blob/a94750e3f5f8fc33757b839d85030e742284e43a/packages/static-svg/icons/minimax.svg) |
|
||||
| `mimo.svg` | [packages/static-svg/icons/xiaomimimo.svg](https://github.com/lobehub/lobe-icons/blob/a94750e3f5f8fc33757b839d85030e742284e43a/packages/static-svg/icons/xiaomimimo.svg) |
|
||||
| `kimi.svg` | [packages/static-svg/icons/kimi.svg](https://github.com/lobehub/lobe-icons/blob/a94750e3f5f8fc33757b839d85030e742284e43a/packages/static-svg/icons/kimi.svg) |
|
||||
| `glm.svg` | [packages/static-svg/icons/zhipu.svg](https://github.com/lobehub/lobe-icons/blob/a94750e3f5f8fc33757b839d85030e742284e43a/packages/static-svg/icons/zhipu.svg) |
|
||||
| `qwen.svg` | [packages/static-svg/icons/qwen.svg](https://github.com/lobehub/lobe-icons/blob/a94750e3f5f8fc33757b839d85030e742284e43a/packages/static-svg/icons/qwen.svg) |
|
||||
|
||||
The monochrome SVGs are rendered as CSS masks so their color follows the preset card state. Brand names and marks identify their respective providers; they do not imply endorsement.
|
||||
@@ -0,0 +1 @@
|
||||
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>DeepSeek</title><path d="M23.748 4.482c-.254-.124-.364.113-.512.234-.051.039-.094.09-.137.136-.372.397-.806.657-1.373.626-.829-.046-1.537.214-2.163.848-.133-.782-.575-1.248-1.247-1.548-.352-.156-.708-.311-.955-.65-.172-.241-.219-.51-.305-.774-.055-.16-.11-.323-.293-.35-.2-.031-.278.136-.356.276-.313.572-.434 1.202-.422 1.84.027 1.436.633 2.58 1.838 3.393.137.093.172.187.129.323-.082.28-.18.552-.266.833-.055.179-.137.217-.329.14a5.526 5.526 0 01-1.736-1.18c-.857-.828-1.631-1.742-2.597-2.458a11.365 11.365 0 00-.689-.471c-.985-.957.13-1.743.388-1.836.27-.098.093-.432-.779-.428-.872.004-1.67.295-2.687.684a3.055 3.055 0 01-.465.137 9.597 9.597 0 00-2.883-.102c-1.885.21-3.39 1.102-4.497 2.623C.082 8.606-.231 10.684.152 12.85c.403 2.284 1.569 4.175 3.36 5.653 1.858 1.533 3.997 2.284 6.438 2.14 1.482-.085 3.133-.284 4.994-1.86.47.234.962.327 1.78.397.63.059 1.236-.03 1.705-.128.735-.156.684-.837.419-.961-2.155-1.004-1.682-.595-2.113-.926 1.096-1.296 2.746-2.642 3.392-7.003.05-.347.007-.565 0-.845-.004-.17.035-.237.23-.256a4.173 4.173 0 001.545-.475c1.396-.763 1.96-2.015 2.093-3.517.02-.23-.004-.467-.247-.588zM11.581 18c-2.089-1.642-3.102-2.183-3.52-2.16-.392.024-.321.471-.235.763.09.288.207.486.371.739.114.167.192.416-.113.603-.673.416-1.842-.14-1.897-.167-1.361-.802-2.5-1.86-3.301-3.307-.774-1.393-1.224-2.887-1.298-4.482-.02-.386.093-.522.477-.592a4.696 4.696 0 011.529-.039c2.132.312 3.946 1.265 5.468 2.774.868.86 1.525 1.887 2.202 2.891.72 1.066 1.494 2.082 2.48 2.914.348.292.625.514.891.677-.802.09-2.14.11-3.054-.614zm1-6.44a.306.306 0 01.415-.287.302.302 0 01.2.288.306.306 0 01-.31.307.303.303 0 01-.304-.308zm3.11 1.596c-.2.081-.399.151-.59.16a1.245 1.245 0 01-.798-.254c-.274-.23-.47-.358-.552-.758a1.73 1.73 0 01.016-.588c.07-.327-.008-.537-.239-.727-.187-.156-.426-.199-.688-.199a.559.559 0 01-.254-.078c-.11-.054-.2-.19-.114-.358.028-.054.16-.186.192-.21.356-.202.767-.136 1.146.016.352.144.618.408 1.001.782.391.451.462.576.685.914.176.265.336.537.445.848.067.195-.019.354-.25.452z"></path></svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
@@ -0,0 +1 @@
|
||||
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Zhipu</title><path d="M11.991 23.503a.24.24 0 00-.244.248.24.24 0 00.244.249.24.24 0 00.245-.249.24.24 0 00-.22-.247l-.025-.001zM9.671 5.365a1.697 1.697 0 011.099 2.132l-.071.172-.016.04-.018.054c-.07.16-.104.32-.104.498-.035.71.47 1.279 1.186 1.314h.366c1.309.053 2.338 1.173 2.286 2.523-.052 1.332-1.152 2.38-2.478 2.327h-.174c-.715.018-1.274.64-1.239 1.368 0 .124.018.23.053.337.209.373.54.658.96.8.75.23 1.517-.125 1.9-.782l.018-.035c.402-.64 1.17-.96 1.92-.711.854.284 1.378 1.226 1.099 2.167a1.661 1.661 0 01-2.077 1.102 1.711 1.711 0 01-.907-.711l-.017-.035c-.2-.323-.463-.58-.851-.711l-.056-.018a1.646 1.646 0 00-1.954.746 1.66 1.66 0 01-1.065.764 1.677 1.677 0 01-1.989-1.279c-.209-.906.332-1.83 1.257-2.043a1.51 1.51 0 01.296-.035h.018c.68-.071 1.151-.622 1.116-1.333a1.307 1.307 0 00-.227-.693 2.515 2.515 0 01-.366-1.403 2.39 2.39 0 01.366-1.208c.14-.195.21-.444.227-.693.018-.71-.506-1.261-1.186-1.332l-.07-.018a1.43 1.43 0 01-.299-.07l-.05-.019a1.7 1.7 0 01-1.047-2.114 1.68 1.68 0 012.094-1.101zm-5.575 10.11c.26-.264.639-.367.994-.27.355.096.633.379.728.74.095.362-.007.748-.267 1.013-.402.41-1.053.41-1.455 0a1.062 1.062 0 010-1.482zm14.845-.294c.359-.09.738.024.992.297.254.274.344.665.237 1.025-.107.36-.396.634-.756.718-.551.128-1.1-.22-1.23-.781a1.05 1.05 0 01.757-1.26zm-.064-4.39c.314.32.49.753.49 1.206 0 .452-.176.886-.49 1.206-.315.32-.74.5-1.185.5-.444 0-.87-.18-1.184-.5a1.727 1.727 0 010-2.412 1.654 1.654 0 012.369 0zm-11.243.163c.364.484.447 1.128.218 1.691a1.665 1.665 0 01-2.188.923c-.855-.36-1.26-1.358-.907-2.228a1.68 1.68 0 011.33-1.038c.593-.08 1.183.169 1.547.652zm11.545-4.221c.368 0 .708.2.892.524.184.324.184.724 0 1.048a1.026 1.026 0 01-.892.524c-.568 0-1.03-.47-1.03-1.048 0-.579.462-1.048 1.03-1.048zm-14.358 0c.368 0 .707.2.891.524.184.324.184.724 0 1.048a1.026 1.026 0 01-.891.524c-.569 0-1.03-.47-1.03-1.048 0-.579.461-1.048 1.03-1.048zm10.031-1.475c.925 0 1.675.764 1.675 1.706s-.75 1.705-1.675 1.705-1.674-.763-1.674-1.705c0-.942.75-1.706 1.674-1.706zm-2.626-.684c.362-.082.653-.356.761-.718a1.062 1.062 0 00-.238-1.028 1.017 1.017 0 00-.996-.294c-.547.14-.881.7-.752 1.257.13.558.675.907 1.225.783zm0 16.876c.359-.087.644-.36.75-.72a1.062 1.062 0 00-.237-1.019 1.018 1.018 0 00-.985-.301 1.037 1.037 0 00-.762.717c-.108.361-.017.754.239 1.028.245.263.606.377.953.305l.043-.01zM17.19 3.5a.631.631 0 00.628-.64c0-.355-.279-.64-.628-.64a.631.631 0 00-.628.64c0 .355.28.64.628.64zm-10.38 0a.631.631 0 00.628-.64c0-.355-.28-.64-.628-.64a.631.631 0 00-.628.64c0 .355.279.64.628.64zm-5.182 7.852a.631.631 0 00-.628.64c0 .354.28.639.628.639a.63.63 0 00.627-.606l.001-.034a.62.62 0 00-.628-.64zm5.182 9.13a.631.631 0 00-.628.64c0 .355.279.64.628.64a.631.631 0 00.628-.64c0-.355-.28-.64-.628-.64zm10.38.018a.631.631 0 00-.628.64c0 .355.28.64.628.64a.631.631 0 00.628-.64c0-.355-.279-.64-.628-.64zm5.182-9.148a.631.631 0 00-.628.64c0 .354.279.639.628.639a.631.631 0 00.628-.64c0-.355-.28-.64-.628-.64zm-.384-4.992a.24.24 0 00.244-.249.24.24 0 00-.244-.249.24.24 0 00-.244.249c0 .142.122.249.244.249zM11.991.497a.24.24 0 00.245-.248A.24.24 0 0011.99 0a.24.24 0 00-.244.249c0 .133.108.236.223.247l.021.001zM2.011 6.36a.24.24 0 00.245-.249.24.24 0 00-.244-.249.24.24 0 00-.244.249.24.24 0 00.244.249zm0 11.263a.24.24 0 00-.243.248.24.24 0 00.244.249.24.24 0 00.244-.249.252.252 0 00-.244-.248zm19.995-.018a.24.24 0 00-.245.248.24.24 0 00.245.25.24.24 0 00.244-.25.252.252 0 00-.244-.248z"></path></svg>
|
||||
|
After Width: | Height: | Size: 3.5 KiB |
@@ -0,0 +1 @@
|
||||
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Kimi</title><path d="M21.846 0a1.923 1.923 0 110 3.846H20.15a.226.226 0 01-.227-.226V1.923C19.923.861 20.784 0 21.846 0z"></path><path d="M11.065 11.199l7.257-7.2c.137-.136.06-.41-.116-.41H14.3a.164.164 0 00-.117.051l-7.82 7.756c-.122.12-.302.013-.302-.179V3.82c0-.127-.083-.23-.185-.23H3.186c-.103 0-.186.103-.186.23V19.77c0 .128.083.23.186.23h2.69c.103 0 .186-.102.186-.23v-3.25c0-.069.025-.135.069-.178l2.424-2.406a.158.158 0 01.205-.023l6.484 4.772a7.677 7.677 0 003.453 1.283c.108.012.2-.095.2-.23v-3.06c0-.117-.07-.212-.164-.227a5.028 5.028 0 01-2.027-.807l-5.613-4.064c-.117-.078-.132-.279-.028-.381z"></path></svg>
|
||||
|
After Width: | Height: | Size: 786 B |
@@ -0,0 +1 @@
|
||||
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>XiaomiMiMo</title><path d="M.958 15.936a.459.459 0 01.459.44v2.729a.46.46 0 01-.918 0v-2.729a.459.459 0 01.459-.44zm4.814-2.035a.46.46 0 01.553.45v4.754a.458.458 0 11-.918 0V15.48L3.74 17.202a.462.462 0 01-.655.016.462.462 0 01-.065-.082L.628 14.67a.459.459 0 01.658-.637l2.124 2.187 2.127-2.188a.46.46 0 01.235-.13zm2.068.004a.46.46 0 01.458.445v4.755a.46.46 0 01-.458.458.459.459 0 01-.458-.458V14.35a.459.459 0 01.458-.445zm1.973 2.014a.46.46 0 01.46.457v2.729a.46.46 0 01-.784.324.46.46 0 01-.134-.324v-2.729a.46.46 0 01.458-.458zm.002-2.045a.458.458 0 01.328.157l2.127 2.19 2.125-2.19a.459.459 0 01.784.318v4.756a.46.46 0 01-.455.458.46.46 0 01-.458-.458V15.48l-1.667 1.723a.46.46 0 01-.65.008l-.005-.005c0-.002-.002-.002-.004-.003l-2.455-2.534a.46.46 0 01-.008-.667.461.461 0 01.338-.128zm6.797 1.206a.46.46 0 01.53.651A1.966 1.966 0 0019.81 18.4a.462.462 0 01.623.18.46.46 0 01-.181.624 2.863 2.863 0 01-1.38.353l-.142-.004a2.88 2.88 0 01-2.393-4.263.461.461 0 01.274-.21zm.864-.931a2.884 2.884 0 013.915 3.914.46.46 0 01-.402.24l-.057-.004a.458.458 0 01-.164-.055.46.46 0 01-.182-.622 1.967 1.967 0 00-2.669-2.67.459.459 0 11-.441-.803zM9.59 6.368c1.481 0 1.696 1.202 1.696 1.654v2.648h-.917v-.432c-.26.346-.792.535-1.36.535-.133 0-1.289-.03-1.384-1.136-.082-.932.675-1.61 2.053-1.61h.691c0-.563-.367-.886-.983-.886-.44.013-.864.174-1.2.458l-.36-.664c.484-.379 1.012-.567 1.764-.567zm4.427.1c1.263 0 2.082.97 2.083 2.15 0 1.181-.824 2.154-2.083 2.154-1.26 0-2.084-.972-2.084-2.152 0-1.18.82-2.153 2.084-2.153zm6.801.015c.68 0 1.202.465 1.197 1.548v2.642H21.1V8.29c0-.312-.002-.98-.63-.98s-.628.667-.628.838v2.524h-.89V8.148c0-.17-.001-.838-.63-.838-.628 0-.628.668-.628.98v2.383h-.917v-4.03h.917V7a1.22 1.22 0 01.947-.516c.398 0 .76.193.982.686a1.321 1.321 0 011.195-.686zm-18.093.872l1.457-1.772H5.32L3.311 8.07l2.14 2.602H4.24L2.725 8.796 1.21 10.672H0L2.138 8.07.13 5.583h1.138l1.458 1.772zm4.149 3.317h-.916V6.644h.916v4.028zm16.99 0h-.916V6.644h.916v4.028zM9.925 8.71c-1.055 0-1.359.412-1.326.742.032.329.324.537.757.537a1.013 1.013 0 001.014-.968l.002-.31h-.447zM14.018 7.3c-.663 0-1.184.487-1.184 1.32 0 .832.52 1.32 1.184 1.32.662 0 1.182-.49 1.182-1.32 0-.832-.52-1.32-1.182-1.32zM6.417 5.001a.568.568 0 01.587.582.588.588 0 01-1.175 0A.57.57 0 016.417 5zm16.991 0a.57.57 0 01.592.582.588.588 0 01-1.174 0 .57.57 0 01.357-.542.572.572 0 01.225-.04z"></path></svg>
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
@@ -0,0 +1 @@
|
||||
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Minimax</title><path d="M16.278 2c1.156 0 2.093.927 2.093 2.07v12.501a.74.74 0 00.744.709.74.74 0 00.743-.709V9.099a2.06 2.06 0 012.071-2.049A2.06 2.06 0 0124 9.1v6.561a.649.649 0 01-.652.645.649.649 0 01-.653-.645V9.1a.762.762 0 00-.766-.758.762.762 0 00-.766.758v7.472a2.037 2.037 0 01-2.048 2.026 2.037 2.037 0 01-2.048-2.026v-12.5a.785.785 0 00-.788-.753.785.785 0 00-.789.752l-.001 15.904A2.037 2.037 0 0113.441 22a2.037 2.037 0 01-2.048-2.026V18.04c0-.356.292-.645.652-.645.36 0 .652.289.652.645v1.934c0 .263.142.506.372.638.23.131.514.131.744 0a.734.734 0 00.372-.638V4.07c0-1.143.937-2.07 2.093-2.07zm-5.674 0c1.156 0 2.093.927 2.093 2.07v11.523a.648.648 0 01-.652.645.648.648 0 01-.652-.645V4.07a.785.785 0 00-.789-.78.785.785 0 00-.789.78v14.013a2.06 2.06 0 01-2.07 2.048 2.06 2.06 0 01-2.071-2.048V9.1a.762.762 0 00-.766-.758.762.762 0 00-.766.758v3.8a2.06 2.06 0 01-2.071 2.049A2.06 2.06 0 010 12.9v-1.378c0-.357.292-.646.652-.646.36 0 .653.29.653.646V12.9c0 .418.343.757.766.757s.766-.339.766-.757V9.099a2.06 2.06 0 012.07-2.048 2.06 2.06 0 012.071 2.048v8.984c0 .419.343.758.767.758.423 0 .766-.339.766-.758V4.07c0-1.143.937-2.07 2.093-2.07z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1 @@
|
||||
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Qwen</title><path d="M12.604 1.34c.393.69.784 1.382 1.174 2.075a.18.18 0 00.157.091h5.552c.174 0 .322.11.446.327l1.454 2.57c.19.337.24.478.024.837-.26.43-.513.864-.76 1.3l-.367.658c-.106.196-.223.28-.04.512l2.652 4.637c.172.301.111.494-.043.77-.437.785-.882 1.564-1.335 2.34-.159.272-.352.375-.68.37-.777-.016-1.552-.01-2.327.016a.099.099 0 00-.081.05 575.097 575.097 0 01-2.705 4.74c-.169.293-.38.363-.725.364-.997.003-2.002.004-3.017.002a.537.537 0 01-.465-.271l-1.335-2.323a.09.09 0 00-.083-.049H4.982c-.285.03-.553-.001-.805-.092l-1.603-2.77a.543.543 0 01-.002-.54l1.207-2.12a.198.198 0 000-.197 550.951 550.951 0 01-1.875-3.272l-.79-1.395c-.16-.31-.173-.496.095-.965.465-.813.927-1.625 1.387-2.436.132-.234.304-.334.584-.335a338.3 338.3 0 012.589-.001.124.124 0 00.107-.063l2.806-4.895a.488.488 0 01.422-.246c.524-.001 1.053 0 1.583-.006L11.704 1c.341-.003.724.032.9.34zm-3.432.403a.06.06 0 00-.052.03L6.254 6.788a.157.157 0 01-.135.078H3.253c-.056 0-.07.025-.041.074l5.81 10.156c.025.042.013.062-.034.063l-2.795.015a.218.218 0 00-.2.116l-1.32 2.31c-.044.078-.021.118.068.118l5.716.008c.046 0 .08.02.104.061l1.403 2.454c.046.081.092.082.139 0l5.006-8.76.783-1.382a.055.055 0 01.096 0l1.424 2.53a.122.122 0 00.107.062l2.763-.02a.04.04 0 00.035-.02.041.041 0 000-.04l-2.9-5.086a.108.108 0 010-.113l.293-.507 1.12-1.977c.024-.041.012-.062-.035-.062H9.2c-.059 0-.073-.026-.043-.077l1.434-2.505a.107.107 0 000-.114L9.225 1.774a.06.06 0 00-.053-.031zm6.29 8.02c.046 0 .058.02.034.06l-.832 1.465-2.613 4.585a.056.056 0 01-.05.029.058.058 0 01-.05-.029L8.498 9.841c-.02-.034-.01-.052.028-.054l.216-.012 6.722-.012z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -490,8 +490,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "codex-x"
|
||||
version = "0.3.6"
|
||||
version = "0.3.17"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
"dirs 5.0.1",
|
||||
"percent-encoding",
|
||||
@@ -504,7 +505,6 @@ dependencies = [
|
||||
"sha2",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-dialog",
|
||||
"tauri-plugin-process",
|
||||
"tauri-plugin-single-instance",
|
||||
"tauri-plugin-updater",
|
||||
@@ -1603,12 +1603,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 +2917,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 +3676,6 @@ dependencies = [
|
||||
"gtk",
|
||||
"heck 0.5.0",
|
||||
"http",
|
||||
"http-range",
|
||||
"jni 0.21.1",
|
||||
"libc",
|
||||
"log",
|
||||
@@ -3819,48 +3788,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.17"
|
||||
description = "Codex Switch & Instruct desktop manager"
|
||||
authors = ["yynxxxxx"]
|
||||
license = "MIT"
|
||||
@@ -11,18 +11,16 @@ 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"] }
|
||||
serde_json = "1.0"
|
||||
base64 = "0.22"
|
||||
toml_edit = "0.22"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
dirs = "5.0"
|
||||
@@ -40,5 +38,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",
|
||||
|
||||
|
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,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.
|
||||
@@ -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();
|
||||
|
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
|
||||
}
|
||||
@@ -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 = 5;
|
||||
|
||||
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,
|
||||
@@ -63,6 +148,7 @@ pub(crate) fn open() -> Result<Connection> {
|
||||
toml_config TEXT,
|
||||
wire_api TEXT NOT NULL DEFAULT 'responses',
|
||||
requires_openai_auth INTEGER NOT NULL DEFAULT 1,
|
||||
model_mappings_json TEXT NOT NULL DEFAULT '[]',
|
||||
source TEXT NOT NULL DEFAULT 'manual',
|
||||
source_id TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
@@ -108,27 +194,59 @@ pub(crate) fn open() -> Result<Connection> {
|
||||
content_hash TEXT,
|
||||
enabled INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS skills_mcp_notes (
|
||||
codex_dir TEXT NOT NULL,
|
||||
item_kind TEXT NOT NULL CHECK(item_kind IN ('skill', 'mcp')),
|
||||
item_id TEXT NOT NULL,
|
||||
note TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY(codex_dir, item_kind, item_id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS active_provider_selections (
|
||||
codex_dir TEXT PRIMARY KEY,
|
||||
provider_id TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS official_profiles (
|
||||
codex_dir TEXT NOT NULL,
|
||||
id TEXT NOT NULL,
|
||||
provider_name TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY(codex_dir, id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS official_profile_selections (
|
||||
codex_dir TEXT PRIMARY KEY,
|
||||
profile_id TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);",
|
||||
)
|
||||
.map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
ensure_sqlite_column(
|
||||
&conn,
|
||||
conn,
|
||||
"providers",
|
||||
"toml_config",
|
||||
"ALTER TABLE providers ADD COLUMN toml_config TEXT",
|
||||
)?;
|
||||
ensure_sqlite_column(
|
||||
&conn,
|
||||
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",
|
||||
)?;
|
||||
ensure_sqlite_column(
|
||||
conn,
|
||||
"providers",
|
||||
"model_mappings_json",
|
||||
"ALTER TABLE providers ADD COLUMN model_mappings_json TEXT NOT NULL DEFAULT '[]'",
|
||||
)?;
|
||||
conn.execute_batch(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS idx_providers_source_identity
|
||||
ON providers(source, source_id)
|
||||
@@ -170,5 +288,294 @@ pub(crate) fn open() -> Result<Connection> {
|
||||
[],
|
||||
)
|
||||
.map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
Ok(conn)
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn open() -> Result<Connection> {
|
||||
database_initializer().open_at(&db_path()?)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, Barrier};
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
fn test_db_path(name: &str) -> PathBuf {
|
||||
static COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
let suffix = COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||
std::env::temp_dir()
|
||||
.join(format!(
|
||||
"codex-x-app-db-{name}-{}-{suffix}",
|
||||
std::process::id()
|
||||
))
|
||||
.join("codexx.db")
|
||||
}
|
||||
|
||||
fn remove_test_db(path: &Path) {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::remove_dir_all(parent).expect("remove app database test directory");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_opens_do_not_rerun_legacy_cleanup() {
|
||||
let path = test_db_path("cleanup-once");
|
||||
let initializer = DatabaseInitializer::new();
|
||||
let conn = initializer.open_at(&path).expect("initialize database");
|
||||
conn.execute_batch(
|
||||
"INSERT INTO prompts (id, title, filename, content, created_at, updated_at)
|
||||
VALUES
|
||||
('kept', 'Kept', 'same.md', 'same', '1', '1'),
|
||||
('external-duplicate', 'Duplicate', 'same.md', 'same', '2', '2');",
|
||||
)
|
||||
.expect("seed a post-migration duplicate");
|
||||
drop(conn);
|
||||
|
||||
let reopened_initializer = DatabaseInitializer::new();
|
||||
let conn = reopened_initializer
|
||||
.open_at(&path)
|
||||
.expect("reopen database in a new process lifecycle");
|
||||
let count: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM prompts", [], |row| row.get(0))
|
||||
.expect("count prompts after reopen");
|
||||
assert_eq!(count, 2, "reopen must not rerun migration cleanup");
|
||||
drop(conn);
|
||||
remove_test_db(&path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn database_replaced_at_the_same_path_is_initialized_again() {
|
||||
let path = test_db_path("replace-database");
|
||||
let initializer = DatabaseInitializer::new();
|
||||
let conn = initializer.open_at(&path).expect("initialize database");
|
||||
assert_eq!(
|
||||
schema_version(&conn).expect("read schema version"),
|
||||
APP_DB_SCHEMA_VERSION
|
||||
);
|
||||
drop(conn);
|
||||
|
||||
fs::remove_file(&path).expect("replace initialized database");
|
||||
let conn = initializer
|
||||
.open_at(&path)
|
||||
.expect("initialize replacement database");
|
||||
let provider_count: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM providers", [], |row| row.get(0))
|
||||
.expect("query replacement database schema");
|
||||
assert_eq!(provider_count, 0);
|
||||
assert_eq!(
|
||||
schema_version(&conn).expect("read replacement version"),
|
||||
APP_DB_SCHEMA_VERSION
|
||||
);
|
||||
drop(conn);
|
||||
remove_test_db(&path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_four_provider_rows_migrate_with_empty_model_mappings() {
|
||||
let path = test_db_path("provider-model-mappings-v5");
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent).unwrap();
|
||||
}
|
||||
let legacy = Connection::open(&path).unwrap();
|
||||
legacy.execute_batch("CREATE TABLE providers (
|
||||
id TEXT PRIMARY KEY, provider_name TEXT NOT NULL, base_url TEXT NOT NULL,
|
||||
model TEXT NOT NULL, api_key TEXT, toml_config TEXT,
|
||||
wire_api TEXT NOT NULL DEFAULT 'responses', requires_openai_auth INTEGER NOT NULL DEFAULT 1,
|
||||
source TEXT NOT NULL DEFAULT 'manual', source_id TEXT,
|
||||
created_at TEXT NOT NULL, updated_at TEXT NOT NULL);
|
||||
INSERT INTO providers (id, provider_name, base_url, model, api_key, source, source_id, created_at, updated_at)
|
||||
VALUES ('legacy-models', 'Existing provider', 'https://migration.example.test/v1', 'existing-model', 'fixture-key', 'cc-switch', 'source-row', 'original-created', 'original-updated');
|
||||
PRAGMA user_version = 4;").unwrap();
|
||||
drop(legacy);
|
||||
let migrated = DatabaseInitializer::new().open_at(&path).unwrap();
|
||||
assert_eq!(schema_version(&migrated).unwrap(), 5);
|
||||
let stored = crate::providers::list_saved_providers_on_connection(&migrated).unwrap();
|
||||
assert_eq!(stored.len(), 1);
|
||||
assert!(stored[0].model_mappings.is_empty());
|
||||
assert_eq!(stored[0].model, "existing-model");
|
||||
assert_eq!(stored[0].api_key.as_deref(), Some("fixture-key"));
|
||||
let metadata: (String, String, String, String, String) = migrated.query_row(
|
||||
"SELECT source, source_id, created_at, updated_at, model_mappings_json FROM providers WHERE id = 'legacy-models'", [],
|
||||
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?, row.get(4)?)),
|
||||
).unwrap();
|
||||
assert_eq!(
|
||||
metadata,
|
||||
(
|
||||
"cc-switch".into(),
|
||||
"source-row".into(),
|
||||
"original-created".into(),
|
||||
"original-updated".into(),
|
||||
"[]".into()
|
||||
)
|
||||
);
|
||||
drop(migrated);
|
||||
remove_test_db(&path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_one_database_migrates_new_metadata_tables_without_data_loss() {
|
||||
let path = test_db_path("skills-mcp-notes-migration");
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent).expect("create legacy database directory");
|
||||
}
|
||||
let legacy = Connection::open(&path).expect("create legacy database");
|
||||
legacy
|
||||
.execute_batch(
|
||||
"CREATE TABLE managed_skills (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
directory TEXT NOT NULL,
|
||||
source_path TEXT,
|
||||
content_hash TEXT,
|
||||
enabled INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
INSERT INTO managed_skills
|
||||
(id, name, directory, enabled, updated_at)
|
||||
VALUES ('legacy-skill', 'Legacy skill', 'legacy-skill', 1, '2026-01-01T00:00:00Z');
|
||||
PRAGMA user_version = 1;",
|
||||
)
|
||||
.expect("seed version one database");
|
||||
drop(legacy);
|
||||
|
||||
let initializer = DatabaseInitializer::new();
|
||||
let migrated = initializer.open_at(&path).expect("migrate database");
|
||||
assert_eq!(
|
||||
schema_version(&migrated).expect("read migrated schema version"),
|
||||
APP_DB_SCHEMA_VERSION
|
||||
);
|
||||
let skill_count: i64 = migrated
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM managed_skills WHERE id = 'legacy-skill'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.expect("count preserved skill");
|
||||
assert_eq!(skill_count, 1);
|
||||
migrated
|
||||
.execute(
|
||||
"INSERT INTO skills_mcp_notes
|
||||
(codex_dir, item_kind, item_id, note, updated_at)
|
||||
VALUES
|
||||
('/tmp/legacy-codex', 'skill', 'legacy-skill', 'my note', '2026-01-02T00:00:00Z')",
|
||||
[],
|
||||
)
|
||||
.expect("write note after migration");
|
||||
migrated
|
||||
.execute(
|
||||
"INSERT INTO active_provider_selections
|
||||
(codex_dir, provider_id, updated_at)
|
||||
VALUES
|
||||
('/tmp/legacy-codex', 'legacy-provider', '2026-01-02T00:00:00Z')",
|
||||
[],
|
||||
)
|
||||
.expect("write active provider selection after migration");
|
||||
drop(migrated);
|
||||
remove_test_db(&path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn concurrent_first_opens_initialize_once() {
|
||||
let path = test_db_path("concurrent-init");
|
||||
let attempts = Arc::new(AtomicUsize::new(0));
|
||||
let barrier = Arc::new(Barrier::new(3));
|
||||
let mut workers = Vec::new();
|
||||
|
||||
for _ in 0..2 {
|
||||
let path = path.clone();
|
||||
// Separate initializers model independent Codex-X processes; the
|
||||
// SQLite transaction and persistent version still permit one run.
|
||||
let initializer = DatabaseInitializer::new();
|
||||
let attempts = Arc::clone(&attempts);
|
||||
let barrier = Arc::clone(&barrier);
|
||||
workers.push(thread::spawn(move || {
|
||||
barrier.wait();
|
||||
initializer.open_at_with(&path, |conn| {
|
||||
attempts.fetch_add(1, Ordering::SeqCst);
|
||||
thread::sleep(Duration::from_millis(25));
|
||||
conn.execute_batch("CREATE TABLE initialized_once (id INTEGER PRIMARY KEY);")
|
||||
.map_err(|error| CodexxError::Database(error.to_string()))
|
||||
})
|
||||
}));
|
||||
}
|
||||
|
||||
barrier.wait();
|
||||
for worker in workers {
|
||||
drop(
|
||||
worker
|
||||
.join()
|
||||
.expect("join concurrent database opener")
|
||||
.expect("open database concurrently"),
|
||||
);
|
||||
}
|
||||
assert_eq!(attempts.load(Ordering::SeqCst), 1);
|
||||
remove_test_db(&path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_initialization_can_retry() {
|
||||
let path = test_db_path("retry-init");
|
||||
let initializer = DatabaseInitializer::new();
|
||||
let attempts = AtomicUsize::new(0);
|
||||
|
||||
let error = initializer
|
||||
.open_at_with(&path, |_| {
|
||||
attempts.fetch_add(1, Ordering::SeqCst);
|
||||
Err(CodexxError::Database(
|
||||
"injected transient initialization failure".to_string(),
|
||||
))
|
||||
})
|
||||
.expect_err("first initialization must fail");
|
||||
assert!(error
|
||||
.to_string()
|
||||
.contains("transient initialization failure"));
|
||||
|
||||
let conn = initializer
|
||||
.open_at_with(&path, |conn| {
|
||||
attempts.fetch_add(1, Ordering::SeqCst);
|
||||
conn.execute_batch("CREATE TABLE retry_succeeded (id INTEGER PRIMARY KEY);")
|
||||
.map_err(|error| CodexxError::Database(error.to_string()))
|
||||
})
|
||||
.expect("retry initialization");
|
||||
drop(conn);
|
||||
let conn = initializer
|
||||
.open_at_with(&path, |_| {
|
||||
attempts.fetch_add(1, Ordering::SeqCst);
|
||||
Ok(())
|
||||
})
|
||||
.expect("open initialized database");
|
||||
assert_eq!(attempts.load(Ordering::SeqCst), 2);
|
||||
drop(conn);
|
||||
remove_test_db(&path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn initialized_read_connection_does_not_need_a_write_lock() {
|
||||
let path = test_db_path("read-with-writer");
|
||||
let initializer = DatabaseInitializer::new();
|
||||
let writer = initializer.open_at(&path).expect("initialize database");
|
||||
writer
|
||||
.execute_batch("BEGIN IMMEDIATE")
|
||||
.expect("hold database write reservation");
|
||||
|
||||
let reader = initializer
|
||||
.open_at(&path)
|
||||
.expect("open reader while write reservation is held");
|
||||
let count: i64 = reader
|
||||
.query_row("SELECT COUNT(*) FROM providers", [], |row| row.get(0))
|
||||
.expect("read while another connection holds write reservation");
|
||||
assert_eq!(count, 0);
|
||||
|
||||
drop(reader);
|
||||
writer
|
||||
.execute_batch("ROLLBACK")
|
||||
.expect("release write lock");
|
||||
drop(writer);
|
||||
remove_test_db(&path);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
use crate::error::{CodexxError, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use toml_edit::{value, DocumentMut};
|
||||
|
||||
const CONTEXT_WINDOW: &str = "model_context_window";
|
||||
const COMPACT_TOKEN_LIMIT: &str = "model_auto_compact_token_limit";
|
||||
const ONE_MILLION: i64 = 1_000_000;
|
||||
const DEFAULT_COMPACT_LIMIT: i64 = 900_000;
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct ContextWindowValues {
|
||||
pub(crate) context_window: Option<i64>,
|
||||
pub(crate) compact_token_limit: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct ContextWindowConfig {
|
||||
pub(crate) config_text: String,
|
||||
pub(crate) enabled: bool,
|
||||
#[serde(flatten)]
|
||||
pub(crate) values: ContextWindowValues,
|
||||
}
|
||||
|
||||
fn read_integer(doc: &DocumentMut, key: &str) -> Result<Option<i64>> {
|
||||
doc.get(key)
|
||||
.map(|item| {
|
||||
item.as_integer()
|
||||
.ok_or_else(|| CodexxError::Config(format!("{key} 必须为整数")))
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
fn write_integer(doc: &mut DocumentMut, key: &str, number: i64) {
|
||||
// Keep existing inline comments and spacing when replacing a setting.
|
||||
let decor = doc
|
||||
.get(key)
|
||||
.and_then(|item| item.as_value())
|
||||
.map(|value| value.decor().clone());
|
||||
doc[key] = value(number);
|
||||
if let Some(decor) = decor {
|
||||
*doc[key].as_value_mut().expect("integer value").decor_mut() = decor;
|
||||
}
|
||||
}
|
||||
|
||||
fn restore_integer(doc: &mut DocumentMut, key: &str, number: Option<i64>) {
|
||||
if let Some(number) = number {
|
||||
write_integer(doc, key, number);
|
||||
} else {
|
||||
doc.as_table_mut().remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
/// Edits a supplied draft only; this never reads or writes the live configuration.
|
||||
/// `enabled = None` parses the checkbox state after a manual TOML edit.
|
||||
pub(crate) fn update_codex_context_window_inner(
|
||||
config_text: String,
|
||||
enabled: Option<bool>,
|
||||
previous_values: Option<ContextWindowValues>,
|
||||
) -> Result<ContextWindowConfig> {
|
||||
let mut doc = config_text
|
||||
.parse::<DocumentMut>()
|
||||
.map_err(|error| CodexxError::Toml {
|
||||
path: "config.toml".to_string(),
|
||||
message: error.to_string(),
|
||||
})?;
|
||||
let context_window = read_integer(&doc, CONTEXT_WINDOW)?;
|
||||
let compact_token_limit = read_integer(&doc, COMPACT_TOKEN_LIMIT)?;
|
||||
|
||||
match enabled {
|
||||
Some(true) => {
|
||||
write_integer(&mut doc, CONTEXT_WINDOW, ONE_MILLION);
|
||||
if compact_token_limit.is_none() {
|
||||
write_integer(&mut doc, COMPACT_TOKEN_LIMIT, DEFAULT_COMPACT_LIMIT);
|
||||
}
|
||||
}
|
||||
Some(false) => {
|
||||
// Only undo the 1M preset. A manual edit to either value wins.
|
||||
if context_window == Some(ONE_MILLION) {
|
||||
restore_integer(
|
||||
&mut doc,
|
||||
CONTEXT_WINDOW,
|
||||
previous_values
|
||||
.as_ref()
|
||||
.and_then(|values| values.context_window),
|
||||
);
|
||||
}
|
||||
let expected_compact_limit = previous_values
|
||||
.as_ref()
|
||||
.and_then(|values| values.compact_token_limit)
|
||||
.unwrap_or(DEFAULT_COMPACT_LIMIT);
|
||||
if compact_token_limit == Some(expected_compact_limit) {
|
||||
restore_integer(
|
||||
&mut doc,
|
||||
COMPACT_TOKEN_LIMIT,
|
||||
previous_values
|
||||
.as_ref()
|
||||
.and_then(|values| values.compact_token_limit),
|
||||
);
|
||||
}
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
|
||||
let context_window = read_integer(&doc, CONTEXT_WINDOW)?;
|
||||
let compact_token_limit = read_integer(&doc, COMPACT_TOKEN_LIMIT)?;
|
||||
Ok(ContextWindowConfig {
|
||||
config_text: if enabled.is_none() {
|
||||
config_text
|
||||
} else {
|
||||
doc.to_string()
|
||||
},
|
||||
enabled: context_window == Some(ONE_MILLION),
|
||||
values: ContextWindowValues {
|
||||
context_window,
|
||||
compact_token_limit,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn enables_at_top_level_and_preserves_unrelated_tables_and_comments() {
|
||||
let original = "# My Codex settings\nmodel = \"gpt-5.5\" # keep model\n\n[mcp_servers.notes]\ncommand = \"notes\"\nmodel_context_window = 120000\n\n[model_providers.custom]\nbase_url = \"https://api.example.test/v1\"\n";
|
||||
let result = update_codex_context_window_inner(original.into(), Some(true), None).unwrap();
|
||||
let doc = result.config_text.parse::<DocumentMut>().unwrap();
|
||||
assert_eq!(doc[CONTEXT_WINDOW].as_integer(), Some(ONE_MILLION));
|
||||
assert_eq!(
|
||||
doc[COMPACT_TOKEN_LIMIT].as_integer(),
|
||||
Some(DEFAULT_COMPACT_LIMIT)
|
||||
);
|
||||
assert_eq!(
|
||||
doc["mcp_servers"]["notes"][CONTEXT_WINDOW].as_integer(),
|
||||
Some(120000)
|
||||
);
|
||||
assert!(result.config_text.contains("# My Codex settings"));
|
||||
assert!(result
|
||||
.config_text
|
||||
.contains("model = \"gpt-5.5\" # keep model"));
|
||||
assert!(result
|
||||
.config_text
|
||||
.contains("base_url = \"https://api.example.test/v1\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_underscores_and_quoted_keys_without_reformatting_the_draft() {
|
||||
let original = "\"model_context_window\" = 1_000_000 # large\nmodel_auto_compact_token_limit = 850_000\n";
|
||||
let result = update_codex_context_window_inner(original.into(), None, None).unwrap();
|
||||
assert!(result.enabled);
|
||||
assert_eq!(result.values.compact_token_limit, Some(850000));
|
||||
assert_eq!(result.config_text, original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_context_values_in_tables_or_multiline_strings() {
|
||||
let original = "instructions = '''\nmodel_context_window = 1000000\n'''\n[profiles.large]\nmodel_context_window = 1000000\n";
|
||||
let result = update_codex_context_window_inner(original.into(), None, None).unwrap();
|
||||
assert!(!result.enabled);
|
||||
assert_eq!(result.values.context_window, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_custom_compaction_when_enabling_and_disabling() {
|
||||
let original = "model_auto_compact_token_limit = 850000 # custom\n";
|
||||
let enabled = update_codex_context_window_inner(original.into(), Some(true), None).unwrap();
|
||||
assert_eq!(enabled.values.compact_token_limit, Some(850000));
|
||||
let disabled =
|
||||
update_codex_context_window_inner(enabled.config_text, Some(false), None).unwrap();
|
||||
assert_eq!(disabled.values.context_window, None);
|
||||
assert_eq!(disabled.config_text, original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restores_custom_context_and_existing_default_compaction_in_same_edit() {
|
||||
let original = "model_context_window = 256000 # original\nmodel_auto_compact_token_limit = 900000 # intentional\n";
|
||||
let parsed = update_codex_context_window_inner(original.into(), None, None).unwrap();
|
||||
let enabled = update_codex_context_window_inner(original.into(), Some(true), None).unwrap();
|
||||
let disabled = update_codex_context_window_inner(
|
||||
enabled.config_text,
|
||||
Some(false),
|
||||
Some(parsed.values),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(disabled.config_text, original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabling_a_saved_preset_removes_both_default_values() {
|
||||
let original = "model = \"gpt-5.5\"\n";
|
||||
let enabled = update_codex_context_window_inner(original.into(), Some(true), None).unwrap();
|
||||
let disabled =
|
||||
update_codex_context_window_inner(enabled.config_text, Some(false), None).unwrap();
|
||||
assert_eq!(disabled.config_text, original);
|
||||
assert!(!disabled.enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabling_does_not_undo_new_manual_values() {
|
||||
let manual = "model_context_window = 500000\nmodel_auto_compact_token_limit = 420000\n";
|
||||
let result = update_codex_context_window_inner(
|
||||
manual.into(),
|
||||
Some(false),
|
||||
Some(ContextWindowValues {
|
||||
context_window: Some(256000),
|
||||
compact_token_limit: Some(200000),
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(result.config_text, manual);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_toml_and_invalid_top_level_field_types() {
|
||||
for original in [
|
||||
"[unfinished",
|
||||
"model_context_window = '1000000'",
|
||||
"model_auto_compact_token_limit = 'automatic'",
|
||||
] {
|
||||
assert!(update_codex_context_window_inner(original.into(), Some(true), None).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_blank_draft_can_be_enabled_and_returned_to_blank() {
|
||||
let enabled = update_codex_context_window_inner(String::new(), Some(true), None).unwrap();
|
||||
assert!(enabled.enabled);
|
||||
let disabled =
|
||||
update_codex_context_window_inner(enabled.config_text, Some(false), None).unwrap();
|
||||
assert!(disabled.config_text.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -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)]
|
||||
{
|
||||
|
||||
@@ -18,6 +18,7 @@ mod backups;
|
||||
mod ccswitch;
|
||||
mod config_migration;
|
||||
mod constants;
|
||||
mod context_config;
|
||||
mod desktop_lifecycle;
|
||||
mod error;
|
||||
mod file_io;
|
||||
@@ -29,13 +30,11 @@ mod providers;
|
||||
mod remote;
|
||||
mod sessions;
|
||||
mod skills_mcp;
|
||||
mod skin_presets;
|
||||
mod skin_runtime;
|
||||
mod skins;
|
||||
mod sqlite_utils;
|
||||
mod state;
|
||||
mod toml_utils;
|
||||
mod updates;
|
||||
mod usage;
|
||||
|
||||
use backups::{
|
||||
action_backup_root, backups, create_backup, validate_backup_codex_dir, BackupEntry, BackupMeta,
|
||||
@@ -73,26 +72,34 @@ use prompts::{
|
||||
prompt_content_source_urls, stable_remote_prompt_id, stale_cached_prompt_ids,
|
||||
uninstall_managed_agents_block, CachedBuiltinPrompt, GithubContentEntry,
|
||||
};
|
||||
use providers::official_profiles::{
|
||||
delete_official_profile_inner, duplicate_official_profile_inner, get_official_profile_inner,
|
||||
list_official_profiles_inner, save_official_profile_inner, switch_official_profile_inner,
|
||||
OfficialProfileActionResult, OfficialProfileDetail, OfficialProfileInput,
|
||||
OfficialProfileSummary,
|
||||
};
|
||||
#[cfg(test)]
|
||||
use providers::{
|
||||
build_ccswitch_codex_provider, canonical_provider_base_url, codex_sections_from_config,
|
||||
consolidate_legacy_provider_duplicates_on_connection, detected_live_custom_provider,
|
||||
is_official_ccswitch_row, list_saved_providers_on_connection, normalize_saved_provider,
|
||||
official_snapshot_path_for_test, provider_by_id_on_connection, provider_identity,
|
||||
provider_status_result, read_ccswitch_codex_rows, save_manual_provider_on_connection,
|
||||
save_provider_toml_config_with_pre_persist, switch_official_provider_with_pre_persist,
|
||||
get_official_config_draft_inner, is_official_ccswitch_row, list_saved_providers_on_connection,
|
||||
normalize_saved_provider, official_snapshot_path_for_test, provider_by_id_on_connection,
|
||||
provider_identity, provider_status_result, read_ccswitch_codex_rows,
|
||||
reset_official_provider_inner, restore_official_provider_inner,
|
||||
save_manual_provider_on_connection, save_provider_toml_config_with_pre_persist,
|
||||
switch_official_provider_inner, switch_official_provider_with_pre_persist,
|
||||
switch_provider_with_pre_persist, upsert_ccswitch_provider_on_connection,
|
||||
upsert_provider_on_connection, CcSwitchCodexRow, ProviderUpsertKind, ProviderUpsertMode,
|
||||
};
|
||||
use providers::{
|
||||
capture_live_chatgpt_config, delete_saved_provider_inner, fetch_provider_models_inner,
|
||||
get_official_config_draft_inner, import_ccswitch_codex_providers_inner,
|
||||
list_saved_providers_inner, read_ccswitch_official_auth_inner, reset_official_provider_inner,
|
||||
restore_official_provider_inner, save_active_provider_inner, save_official_config_inner,
|
||||
save_provider_inner, save_provider_toml_config_inner, switch_official_provider_inner,
|
||||
switch_provider_inner, test_provider_connection_inner, ImportResult, OfficialAuthCandidate,
|
||||
OfficialConfigDraft, OfficialConfigInput, ProviderConnectionResult, ProviderInput,
|
||||
ProviderModelsResult, ProviderTomlInput, SavedProvider,
|
||||
build_provider_toml_draft_inner, clear_active_provider_on_connection,
|
||||
delete_saved_provider_inner, fetch_provider_models_inner,
|
||||
import_ccswitch_codex_providers_inner, list_saved_providers_inner, open_store,
|
||||
read_ccswitch_official_auth_inner, remember_active_provider_on_connection,
|
||||
save_active_provider_inner, save_official_config_inner, save_provider_inner,
|
||||
save_provider_toml_config_inner, switch_provider_inner, test_provider_connection_inner,
|
||||
ImportResult, OfficialAuthCandidate, OfficialConfigInput, ProviderConnectionResult,
|
||||
ProviderInput, ProviderModelsResult, ProviderTomlInput, SavedProvider,
|
||||
};
|
||||
#[cfg(test)]
|
||||
use sessions::{
|
||||
@@ -108,25 +115,20 @@ use sessions::{
|
||||
};
|
||||
use skills_mcp::{
|
||||
build_skills_mcp_state_inner, check_skill_updates_inner, import_existing_skills_mcp_inner,
|
||||
install_skill_zip_inner, preview_existing_skills_mcp_inner, toggle_codex_mcp_inner,
|
||||
toggle_codex_skill_inner, SkillsMcpActionResult, SkillsMcpImportPreview, SkillsMcpState,
|
||||
install_skill_zip_inner, preview_existing_skills_mcp_inner, save_skills_mcp_note_inner,
|
||||
toggle_codex_mcp_inner, toggle_codex_skill_inner, SkillsMcpActionResult,
|
||||
SkillsMcpImportPreview, SkillsMcpState,
|
||||
};
|
||||
#[cfg(test)]
|
||||
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;
|
||||
@@ -163,6 +165,13 @@ struct AboutInfo {
|
||||
native_updater_supported: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct CodexDesktopRestartResult {
|
||||
app_name: String,
|
||||
was_running: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct DiagnosticItem {
|
||||
@@ -192,7 +201,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() {
|
||||
@@ -482,6 +491,20 @@ async fn toggle_codex_mcp(
|
||||
.map_err(|e| CodexxError::Config(format!("切换 MCP 失败: {e}")))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn save_skills_mcp_note(
|
||||
config_dir: Option<String>,
|
||||
item_kind: String,
|
||||
id: String,
|
||||
note: String,
|
||||
) -> Result<SkillsMcpState> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
save_skills_mcp_note_inner(config_dir, item_kind, id, note)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| CodexxError::Config(format!("保存 Skills/MCP 备注失败: {e}")))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn install_skill_zip(
|
||||
config_dir: Option<String>,
|
||||
@@ -495,74 +518,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))
|
||||
@@ -655,6 +610,18 @@ async fn get_about_info(config_dir: Option<String>) -> Result<AboutInfo> {
|
||||
.map_err(|e| CodexxError::Config(format!("读取关于信息失败: {e}")))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn restart_codex_desktop() -> std::result::Result<CodexDesktopRestartResult, String> {
|
||||
let (app_name, was_running) =
|
||||
tauri::async_runtime::spawn_blocking(platform::restart_codex_desktop)
|
||||
.await
|
||||
.map_err(|error| format!("重启 Codex 桌面客户端失败: {error}"))??;
|
||||
Ok(CodexDesktopRestartResult {
|
||||
app_name,
|
||||
was_running,
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn list_saved_prompts() -> Result<Vec<SavedPrompt>> {
|
||||
tauri::async_runtime::spawn_blocking(list_saved_prompts_inner)
|
||||
@@ -929,6 +896,66 @@ async fn list_saved_providers() -> Result<Vec<SavedProvider>> {
|
||||
.map_err(|e| CodexxError::Config(format!("读取供应商列表失败: {e}")))?
|
||||
}
|
||||
|
||||
enum ActiveProviderSelectionUpdate {
|
||||
Set(String),
|
||||
ClearIfOfficial,
|
||||
}
|
||||
|
||||
fn finish_provider_selection(
|
||||
mut result: ActionResult,
|
||||
update: ActiveProviderSelectionUpdate,
|
||||
) -> ActionResult {
|
||||
if matches!(update, ActiveProviderSelectionUpdate::ClearIfOfficial)
|
||||
&& !result.state.is_official_provider
|
||||
{
|
||||
return result;
|
||||
}
|
||||
let codex_dir = PathBuf::from(&result.state.codex_dir);
|
||||
let refreshed_state = (|| -> Result<CodexState> {
|
||||
let conn = open_store()?;
|
||||
match update {
|
||||
ActiveProviderSelectionUpdate::Set(provider_id) => {
|
||||
let saved = providers::list_saved_providers_on_connection(&conn)?;
|
||||
let matches = providers::detected_live_custom_provider(&codex_dir)?
|
||||
.map(|live| providers::matching_saved_provider_ids_for_live(&live, &saved))
|
||||
.unwrap_or_default();
|
||||
// A detected UI row can carry a provisional ID. Adoption may
|
||||
// allocate a different ID, so only honor an explicit selection
|
||||
// that actually matches the now-active saved configuration.
|
||||
if matches.contains(&provider_id) {
|
||||
remember_active_provider_on_connection(&conn, &codex_dir, &provider_id)?;
|
||||
}
|
||||
}
|
||||
ActiveProviderSelectionUpdate::ClearIfOfficial => {
|
||||
clear_active_provider_on_connection(&conn, &codex_dir)?;
|
||||
}
|
||||
}
|
||||
drop(conn);
|
||||
build_state_after_migration(codex_dir)
|
||||
})();
|
||||
match refreshed_state {
|
||||
Ok(state) => result.state = state,
|
||||
Err(error) => {
|
||||
result
|
||||
.message
|
||||
.push_str(&format!(";当前供应商状态记录失败:{error}"));
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
#[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)
|
||||
}
|
||||
@@ -940,14 +967,73 @@ async fn save_provider(provider: SavedProvider) -> Result<SavedProvider> {
|
||||
.map_err(|e| CodexxError::Config(format!("保存供应商失败: {e}")))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn duplicate_provider(
|
||||
config_dir: Option<String>,
|
||||
provider_id: Option<String>,
|
||||
provider_name: Option<String>,
|
||||
) -> Result<providers::DuplicateProviderResult> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
providers::duplicate_provider_inner(config_dir, provider_id, provider_name)
|
||||
})
|
||||
.await
|
||||
.map_err(|error| CodexxError::Config(format!("复制供应商失败: {error}")))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn update_codex_context_window(
|
||||
config_text: String,
|
||||
enabled: Option<bool>,
|
||||
previous_values: Option<context_config::ContextWindowValues>,
|
||||
) -> Result<context_config::ContextWindowConfig> {
|
||||
context_config::update_codex_context_window_inner(config_text, enabled, previous_values)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn get_usage_statistics(
|
||||
config_dir: Option<String>,
|
||||
range: Option<String>,
|
||||
model: Option<String>,
|
||||
force_refresh: Option<bool>,
|
||||
) -> Result<usage::UsageStatistics> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
usage::get_usage_statistics_inner(config_dir, range, model, force_refresh)
|
||||
})
|
||||
.await
|
||||
.map_err(|error| CodexxError::Config(format!("读取用量统计失败: {error}")))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn activate_saved_provider(
|
||||
config_dir: Option<String>,
|
||||
provider_id: String,
|
||||
) -> Result<ActionResult> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let result = providers::activate_saved_provider_inner(config_dir, provider_id.clone())?;
|
||||
Ok(finish_provider_selection(
|
||||
result,
|
||||
ActiveProviderSelectionUpdate::Set(provider_id),
|
||||
))
|
||||
})
|
||||
.await
|
||||
.map_err(|error| CodexxError::Config(format!("启用供应商失败: {error}")))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn save_active_provider(
|
||||
provider: SavedProvider,
|
||||
config_dir: Option<String>,
|
||||
) -> Result<ActionResult> {
|
||||
tauri::async_runtime::spawn_blocking(move || save_active_provider_inner(provider, config_dir))
|
||||
.await
|
||||
.map_err(|e| CodexxError::Config(format!("保存活动供应商失败: {e}")))?
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let provider_id = provider.id.clone();
|
||||
let result = save_active_provider_inner(provider, config_dir)?;
|
||||
Ok(finish_provider_selection(
|
||||
result,
|
||||
ActiveProviderSelectionUpdate::Set(provider_id),
|
||||
))
|
||||
})
|
||||
.await
|
||||
.map_err(|e| CodexxError::Config(format!("保存活动供应商失败: {e}")))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -966,42 +1052,115 @@ 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)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn switch_official_provider(config_dir: Option<String>) -> Result<ActionResult> {
|
||||
tauri::async_runtime::spawn_blocking(move || switch_official_provider_inner(config_dir))
|
||||
.await
|
||||
.map_err(|e| CodexxError::Config(format!("切换官方配置失败: {e}")))?
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let result = switch_official_profile_inner(
|
||||
config_dir,
|
||||
providers::official_profiles::DEFAULT_OFFICIAL_PROFILE_ID.to_string(),
|
||||
)?;
|
||||
Ok(finish_provider_selection(
|
||||
result,
|
||||
ActiveProviderSelectionUpdate::ClearIfOfficial,
|
||||
))
|
||||
})
|
||||
.await
|
||||
.map_err(|e| CodexxError::Config(format!("切换官方配置失败: {e}")))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn get_official_config_draft(
|
||||
async fn list_official_profiles(config_dir: Option<String>) -> Result<Vec<OfficialProfileSummary>> {
|
||||
tauri::async_runtime::spawn_blocking(move || list_official_profiles_inner(config_dir))
|
||||
.await
|
||||
.map_err(|error| CodexxError::Config(format!("读取官方配置列表失败: {error}")))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn get_official_profile_quota(
|
||||
config_dir: Option<String>,
|
||||
) -> Result<Option<OfficialConfigDraft>> {
|
||||
tauri::async_runtime::spawn_blocking(move || get_official_config_draft_inner(config_dir))
|
||||
.await
|
||||
.map_err(|e| CodexxError::Config(format!("读取官方配置快照失败: {e}")))?
|
||||
profile_id: String,
|
||||
) -> Result<providers::quota::OfficialQuotaSnapshot> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
providers::quota::get_official_profile_quota_inner(config_dir, profile_id)
|
||||
})
|
||||
.await
|
||||
.map_err(|_| CodexxError::Config("读取官方账号额度失败,请重试".to_string()))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn restore_official_provider(config_dir: Option<String>) -> Result<ActionResult> {
|
||||
tauri::async_runtime::spawn_blocking(move || restore_official_provider_inner(config_dir))
|
||||
async fn get_official_profile_reset_credits(
|
||||
config_dir: Option<String>,
|
||||
profile_id: String,
|
||||
) -> Result<providers::quota::OfficialResetCreditsSnapshot> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
providers::quota::get_official_profile_reset_credits_inner(config_dir, profile_id)
|
||||
})
|
||||
.await
|
||||
.map_err(|_| CodexxError::Config("读取官方账号重置次数失败,请重试".to_string()))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn get_official_profile(
|
||||
config_dir: Option<String>,
|
||||
profile_id: String,
|
||||
) -> Result<OfficialProfileDetail> {
|
||||
tauri::async_runtime::spawn_blocking(move || get_official_profile_inner(config_dir, profile_id))
|
||||
.await
|
||||
.map_err(|e| CodexxError::Config(format!("还原官方配置失败: {e}")))?
|
||||
.map_err(|error| CodexxError::Config(format!("读取官方配置失败: {error}")))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn save_official_profile(input: OfficialProfileInput) -> Result<OfficialProfileActionResult> {
|
||||
tauri::async_runtime::spawn_blocking(move || save_official_profile_inner(input))
|
||||
.await
|
||||
.map_err(|error| CodexxError::Config(format!("保存官方配置失败: {error}")))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn duplicate_official_profile(
|
||||
config_dir: Option<String>,
|
||||
profile_id: String,
|
||||
provider_name: Option<String>,
|
||||
) -> Result<OfficialProfileActionResult> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
duplicate_official_profile_inner(config_dir, profile_id, provider_name)
|
||||
})
|
||||
.await
|
||||
.map_err(|error| CodexxError::Config(format!("复制官方配置失败: {error}")))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn switch_official_profile(
|
||||
config_dir: Option<String>,
|
||||
profile_id: String,
|
||||
) -> Result<ActionResult> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
switch_official_profile_inner(config_dir, profile_id)
|
||||
})
|
||||
.await
|
||||
.map_err(|error| CodexxError::Config(format!("切换官方配置失败: {error}")))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn delete_official_profile(config_dir: Option<String>, profile_id: String) -> Result<()> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
delete_official_profile_inner(config_dir, profile_id)
|
||||
})
|
||||
.await
|
||||
.map_err(|error| CodexxError::Config(format!("删除官方配置失败: {error}")))?
|
||||
}
|
||||
|
||||
#[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)
|
||||
let result = providers::official_profiles::reset_default_official_profile_inner(input)?;
|
||||
Ok(finish_provider_selection(
|
||||
result,
|
||||
ActiveProviderSelectionUpdate::ClearIfOfficial,
|
||||
))
|
||||
})
|
||||
.await
|
||||
.map_err(|e| CodexxError::Config(format!("新建官方配置失败: {e}")))?
|
||||
@@ -1010,7 +1169,16 @@ 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)
|
||||
let result = save_official_config_inner(
|
||||
input.config_dir,
|
||||
input.model,
|
||||
input.auth_json,
|
||||
input.config_text,
|
||||
)?;
|
||||
Ok(finish_provider_selection(
|
||||
result,
|
||||
ActiveProviderSelectionUpdate::ClearIfOfficial,
|
||||
))
|
||||
})
|
||||
.await
|
||||
.map_err(|e| CodexxError::Config(format!("保存官方配置失败: {e}")))?
|
||||
@@ -1190,10 +1358,21 @@ async fn disable_external_instruction(config_dir: Option<String>) -> Result<Acti
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn save_provider_toml_config(input: ProviderTomlInput) -> Result<ActionResult> {
|
||||
tauri::async_runtime::spawn_blocking(move || save_provider_toml_config_inner(input))
|
||||
.await
|
||||
.map_err(|e| CodexxError::Config(format!("保存供应商 TOML 失败: {e}")))?
|
||||
async fn save_provider_toml_config(
|
||||
input: ProviderTomlInput,
|
||||
provider_id: Option<String>,
|
||||
) -> Result<ActionResult> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let result = save_provider_toml_config_inner(input)?;
|
||||
Ok(match provider_id {
|
||||
Some(provider_id) => {
|
||||
finish_provider_selection(result, ActiveProviderSelectionUpdate::Set(provider_id))
|
||||
}
|
||||
None => result,
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|e| CodexxError::Config(format!("保存供应商 TOML 失败: {e}")))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -1218,9 +1397,18 @@ async fn fetch_provider_models(
|
||||
|
||||
#[tauri::command]
|
||||
async fn switch_provider(input: ProviderInput) -> Result<ActionResult> {
|
||||
tauri::async_runtime::spawn_blocking(move || switch_provider_inner(input))
|
||||
.await
|
||||
.map_err(|e| CodexxError::Config(format!("切换供应商失败: {e}")))?
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let provider_id = input.provider_id.clone();
|
||||
let result = switch_provider_inner(input)?;
|
||||
Ok(match provider_id {
|
||||
Some(provider_id) => {
|
||||
finish_provider_selection(result, ActiveProviderSelectionUpdate::Set(provider_id))
|
||||
}
|
||||
None => result,
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|e| CodexxError::Config(format!("切换供应商失败: {e}")))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -1316,6 +1504,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 +1586,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| {
|
||||
@@ -1404,21 +1595,15 @@ pub fn run() {
|
||||
.on_window_event(desktop_lifecycle::handle_window_event)
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
get_about_info,
|
||||
restart_codex_desktop,
|
||||
check_app_update,
|
||||
get_skills_mcp_state,
|
||||
preview_existing_skills_mcp,
|
||||
import_existing_skills_mcp,
|
||||
toggle_codex_skill,
|
||||
toggle_codex_mcp,
|
||||
save_skills_mcp_note,
|
||||
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,13 +1621,24 @@ pub fn run() {
|
||||
delete_saved_prompt,
|
||||
enable_saved_prompt,
|
||||
list_saved_providers,
|
||||
build_provider_toml_draft,
|
||||
save_provider,
|
||||
duplicate_provider,
|
||||
update_codex_context_window,
|
||||
get_usage_statistics,
|
||||
save_active_provider,
|
||||
activate_saved_provider,
|
||||
delete_saved_provider,
|
||||
get_codex_state,
|
||||
switch_official_provider,
|
||||
get_official_config_draft,
|
||||
restore_official_provider,
|
||||
list_official_profiles,
|
||||
get_official_profile,
|
||||
get_official_profile_quota,
|
||||
get_official_profile_reset_credits,
|
||||
save_official_profile,
|
||||
duplicate_official_profile,
|
||||
switch_official_profile,
|
||||
delete_official_profile,
|
||||
reset_official_provider,
|
||||
save_official_config,
|
||||
enable_instruction,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use crate::error::{CodexxError, Result};
|
||||
#[cfg(test)]
|
||||
use chrono::Local;
|
||||
use std::path::PathBuf;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub(crate) fn home_dir() -> Result<PathBuf> {
|
||||
dirs::home_dir().ok_or(CodexxError::NoHomeDir)
|
||||
@@ -33,3 +33,13 @@ pub(crate) fn app_home() -> Result<PathBuf> {
|
||||
Ok(home_dir()?.join(".codexx"))
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn normalized_path_scope(path: &Path) -> String {
|
||||
let resolved = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
|
||||
let normalized = resolved.to_string_lossy().replace('\\', "/");
|
||||
if cfg!(target_os = "windows") {
|
||||
normalized.to_ascii_lowercase()
|
||||
} else {
|
||||
normalized
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,8 @@ use serde::Deserialize;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::Mutex;
|
||||
|
||||
static BUILTIN_PROMPT_CACHE_LOCK: Mutex<()> = Mutex::new(());
|
||||
// Only serialize remote syncs. Local reads must stay available during downloads.
|
||||
static BUILTIN_PROMPT_SYNC_LOCK: Mutex<()> = Mutex::new(());
|
||||
const CATALOG_CDN_KEY: &str = "模板 CDN";
|
||||
const CATALOG_GITHUB_KEY: &str = "GitHub 模板目录";
|
||||
const PROMPT_CDN_KEY: &str = "模板 CDN";
|
||||
@@ -305,6 +306,13 @@ pub(crate) fn delete_cached_prompt_ids(
|
||||
|
||||
fn prune_builtin_prompt_cache(active_ids: &HashSet<String>) -> Result<usize> {
|
||||
let mut conn = open_db()?;
|
||||
prune_builtin_prompt_cache_on_connection(&mut conn, active_ids)
|
||||
}
|
||||
|
||||
fn prune_builtin_prompt_cache_on_connection(
|
||||
conn: &mut Connection,
|
||||
active_ids: &HashSet<String>,
|
||||
) -> Result<usize> {
|
||||
let transaction = conn
|
||||
.transaction_with_behavior(TransactionBehavior::Immediate)
|
||||
.map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
@@ -315,7 +323,14 @@ fn prune_builtin_prompt_cache(active_ids: &HashSet<String>) -> Result<usize> {
|
||||
let mut deleted = 0;
|
||||
for id in stale_ids {
|
||||
deleted += transaction
|
||||
.execute("DELETE FROM builtin_prompt_cache WHERE id = ?1", [id])
|
||||
.execute(
|
||||
"DELETE FROM builtin_prompt_cache
|
||||
WHERE id = ?1
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM builtin_prompt_overrides WHERE template_id = ?1
|
||||
)",
|
||||
[id],
|
||||
)
|
||||
.map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
}
|
||||
transaction
|
||||
@@ -678,6 +693,24 @@ fn mark_customized_prompt_statuses(
|
||||
}
|
||||
}
|
||||
|
||||
fn include_customized_prompt_statuses(
|
||||
statuses: &mut Vec<BuiltinPromptStatus>,
|
||||
customized_ids: &HashSet<String>,
|
||||
caches: Vec<CachedBuiltinPrompt>,
|
||||
) {
|
||||
for cache in caches {
|
||||
if customized_ids.contains(&cache.id)
|
||||
&& !statuses.iter().any(|status| status.id == cache.id)
|
||||
{
|
||||
statuses.push(prompt_status_from_cache(
|
||||
cache,
|
||||
"本地已修改,GitHub 同步已跳过",
|
||||
));
|
||||
}
|
||||
}
|
||||
mark_customized_prompt_statuses(statuses, customized_ids);
|
||||
}
|
||||
|
||||
fn should_sync_prompt_content(id: &str, customized_ids: &HashSet<String>) -> bool {
|
||||
!customized_ids.contains(id)
|
||||
}
|
||||
@@ -733,7 +766,7 @@ pub(crate) fn builtin_prompt_status_inner() -> Result<Vec<BuiltinPromptStatus>>
|
||||
pub(crate) fn refresh_builtin_prompts_with_active(
|
||||
active_remote_builtin_prompt_id: impl FnOnce() -> Option<String>,
|
||||
) -> Result<Vec<BuiltinPromptStatus>> {
|
||||
let _cache_guard = BUILTIN_PROMPT_CACHE_LOCK
|
||||
let _sync_guard = BUILTIN_PROMPT_SYNC_LOCK
|
||||
.lock()
|
||||
.map_err(|_| CodexxError::Database("提示词缓存锁已损坏".to_string()))?;
|
||||
let customized_ids = builtin_prompt_override_ids_inner()?;
|
||||
@@ -828,7 +861,10 @@ pub(crate) fn refresh_builtin_prompts_with_active(
|
||||
if catalog_confirmation_failed {
|
||||
mark_catalog_confirmation_failed(&mut statuses);
|
||||
}
|
||||
mark_customized_prompt_statuses(&mut statuses, &customized_ids);
|
||||
// Local edits can finish while remote requests are pending. Use their
|
||||
// latest IDs and retained cache entries when returning the catalog.
|
||||
let customized_ids = builtin_prompt_override_ids_inner()?;
|
||||
include_customized_prompt_statuses(&mut statuses, &customized_ids, cached_builtin_prompts()?);
|
||||
let order = bundled_prompt_metas()
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
@@ -849,9 +885,8 @@ pub(crate) fn refresh_builtin_prompts_with_active(
|
||||
pub(crate) fn builtin_prompt_content(
|
||||
template_id: &str,
|
||||
) -> Result<(String, String, String, String)> {
|
||||
let _cache_guard = BUILTIN_PROMPT_CACHE_LOCK
|
||||
.lock()
|
||||
.map_err(|_| CodexxError::Database("提示词缓存锁已损坏".to_string()))?;
|
||||
// Editing and enabling a template are local operations. Network refreshes
|
||||
// update SQLite separately, whose reads see the last committed cache entry.
|
||||
let id = if template_id.trim().is_empty() {
|
||||
"gpt5.5-unrestricted"
|
||||
} else {
|
||||
@@ -872,19 +907,6 @@ pub(crate) fn builtin_prompt_content(
|
||||
"本地已修改".to_string(),
|
||||
));
|
||||
}
|
||||
let trust = PromptContentTrust {
|
||||
cached: cached.as_ref().map(|item| item.content.as_str()),
|
||||
bundled: bundled.map(|item| item.content),
|
||||
};
|
||||
if let Ok((remote, source_url)) = fetch_remote_prompt(&filename, trust) {
|
||||
save_builtin_prompt_cache(id, &filename, &source_url, &remote)?;
|
||||
return Ok((
|
||||
filename.clone(),
|
||||
format!("./{filename}"),
|
||||
remote,
|
||||
"在线最新".to_string(),
|
||||
));
|
||||
}
|
||||
if let Some(cache) = cached {
|
||||
return Ok((
|
||||
cache.filename.clone(),
|
||||
@@ -894,7 +916,9 @@ pub(crate) fn builtin_prompt_content(
|
||||
));
|
||||
}
|
||||
let bundled = bundled.ok_or_else(|| {
|
||||
CodexxError::Config(format!("无法下载提示词且没有可用缓存: {template_id}"))
|
||||
CodexxError::Config(format!(
|
||||
"提示词尚无本地内容,请先同步 GitHub 模板: {template_id}"
|
||||
))
|
||||
})?;
|
||||
Ok((
|
||||
bundled.filename.to_string(),
|
||||
@@ -925,6 +949,91 @@ pub(crate) fn builtin_prompt_detail_inner(template_id: &str) -> Result<BuiltinPr
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn local_prompt_detail_does_not_wait_for_background_sync() {
|
||||
let _db_guard = crate::app_db::test_db_guard();
|
||||
let id = "github-detail-during-sync";
|
||||
save_builtin_prompt_cache(
|
||||
id,
|
||||
"detail-during-sync.md",
|
||||
"https://example.test/prompt.md",
|
||||
"cached content",
|
||||
)
|
||||
.expect("cache template");
|
||||
super::super::store::save_builtin_prompt_override_inner(id, "local edit")
|
||||
.expect("save local edit");
|
||||
|
||||
let sync_guard = BUILTIN_PROMPT_SYNC_LOCK
|
||||
.lock()
|
||||
.expect("simulate pending sync");
|
||||
let (sender, receiver) = std::sync::mpsc::channel();
|
||||
let reader = std::thread::spawn(move || {
|
||||
sender.send(builtin_prompt_detail_inner(id)).unwrap();
|
||||
});
|
||||
let detail = receiver.recv_timeout(std::time::Duration::from_secs(1));
|
||||
// Release before asserting so a regression cannot leave a blocked worker.
|
||||
drop(sync_guard);
|
||||
reader.join().expect("finish detail reader");
|
||||
let conn = open_db().expect("open test store");
|
||||
conn.execute(
|
||||
"DELETE FROM builtin_prompt_overrides WHERE template_id = ?1",
|
||||
[id],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute("DELETE FROM builtin_prompt_cache WHERE id = ?1", [id])
|
||||
.unwrap();
|
||||
|
||||
let detail = detail
|
||||
.expect("local detail must finish while sync is pending")
|
||||
.unwrap();
|
||||
assert_eq!(detail.content, "local edit");
|
||||
assert!(detail.customized);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cached_and_bundled_prompt_content_stays_available_during_sync() {
|
||||
let _db_guard = crate::app_db::test_db_guard();
|
||||
let id = "github-cached-content-during-sync";
|
||||
save_builtin_prompt_cache(
|
||||
id,
|
||||
"cached-during-sync.md",
|
||||
"https://example.test/prompt.md",
|
||||
"last downloaded content",
|
||||
)
|
||||
.expect("cache online template");
|
||||
let before = cached_builtin_prompt(id).unwrap().unwrap().checked_at;
|
||||
let sync_guard = BUILTIN_PROMPT_SYNC_LOCK
|
||||
.lock()
|
||||
.expect("simulate pending sync");
|
||||
let (sender, receiver) = std::sync::mpsc::channel();
|
||||
let reader = std::thread::spawn(move || {
|
||||
let result = (|| -> Result<_> {
|
||||
Ok((
|
||||
builtin_prompt_content(id)?,
|
||||
builtin_prompt_content("gpt5.5-unrestricted")?,
|
||||
))
|
||||
})();
|
||||
sender.send(result).unwrap();
|
||||
});
|
||||
let result = receiver.recv_timeout(std::time::Duration::from_secs(1));
|
||||
drop(sync_guard);
|
||||
reader.join().expect("finish content reader");
|
||||
let after = cached_builtin_prompt(id).unwrap().unwrap().checked_at;
|
||||
open_db()
|
||||
.unwrap()
|
||||
.execute("DELETE FROM builtin_prompt_cache WHERE id = ?1", [id])
|
||||
.unwrap();
|
||||
|
||||
let (cached, bundled) = result
|
||||
.expect("local content must not wait for sync")
|
||||
.unwrap();
|
||||
assert_eq!(cached.2, "last downloaded content");
|
||||
assert_eq!(cached.3, "本地缓存");
|
||||
assert_eq!(bundled.2, INSTRUCTION_CONTENT);
|
||||
assert_eq!(bundled.3, "打包内置");
|
||||
assert_eq!(before, after, "reading must not refresh the cache");
|
||||
}
|
||||
|
||||
const TEST_SOURCES: [RemoteSource<'static>; 2] = [
|
||||
RemoteSource::new(PROMPT_CDN_KEY, "https://cdn.test/prompt.md", None),
|
||||
RemoteSource::new(PROMPT_GITHUB_KEY, "https://github.test/prompt.md", None),
|
||||
@@ -939,6 +1048,10 @@ mod tests {
|
||||
content TEXT NOT NULL,
|
||||
checked_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE builtin_prompt_overrides (
|
||||
template_id TEXT PRIMARY KEY,
|
||||
content TEXT NOT NULL
|
||||
);",
|
||||
)
|
||||
.expect("create prompt cache table");
|
||||
@@ -950,6 +1063,65 @@ mod tests {
|
||||
conn
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edit_saved_during_sync_survives_stale_catalog_pruning_and_returned_statuses() {
|
||||
let mut conn = prompt_cache_connection();
|
||||
for id in [
|
||||
"removed-but-edited",
|
||||
"removed-unedited",
|
||||
"remote-still-present",
|
||||
] {
|
||||
save_builtin_prompt_cache_on_connection(
|
||||
&conn,
|
||||
id,
|
||||
&format!("{id}.md"),
|
||||
"https://example.test/prompt.md",
|
||||
"downloaded content",
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
// Sync started before this edit, so its original retained IDs omit it.
|
||||
let retained_at_sync_start = HashSet::from(["remote-still-present".to_string()]);
|
||||
conn.execute(
|
||||
"INSERT INTO builtin_prompt_overrides (template_id, content) VALUES (?1, ?2)",
|
||||
["removed-but-edited", "new local content"],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let removed = prune_builtin_prompt_cache_on_connection(&mut conn, &retained_at_sync_start)
|
||||
.expect("prune the old catalog after the local save");
|
||||
assert_eq!(removed, 1);
|
||||
let cache = cached_builtin_prompt_from_connection(&conn, "removed-but-edited")
|
||||
.unwrap()
|
||||
.expect("keep the filename needed to reopen the saved edit");
|
||||
assert_eq!(cache.filename, "removed-but-edited.md");
|
||||
assert!(
|
||||
cached_builtin_prompt_from_connection(&conn, "removed-unedited")
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
|
||||
let mut statuses = vec![prompt_status_from_cache(
|
||||
cached_builtin_prompt_from_connection(&conn, "remote-still-present")
|
||||
.unwrap()
|
||||
.unwrap(),
|
||||
"synced",
|
||||
)];
|
||||
let customized_ids = HashSet::from(["removed-but-edited".to_string()]);
|
||||
include_customized_prompt_statuses(
|
||||
&mut statuses,
|
||||
&customized_ids,
|
||||
cached_builtin_prompts_from_connection(&conn).unwrap(),
|
||||
);
|
||||
assert_eq!(statuses.len(), 2);
|
||||
let edited = statuses
|
||||
.iter()
|
||||
.find(|status| status.id == "removed-but-edited")
|
||||
.expect("include the newly edited template in the sync response");
|
||||
assert!(edited.customized);
|
||||
assert!(edited.message.contains("本地已修改"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn synced_remote_prompt_is_available_from_sqlite_after_restart() {
|
||||
let database_path = std::env::temp_dir().join(format!(
|
||||
|
||||
@@ -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, strip_provider_bearer_tokens,
|
||||
upsert_ccswitch_provider_on_connection, ProviderUpsertKind, SavedProvider,
|
||||
};
|
||||
use crate::ccswitch::{ccswitch_db_candidates, default_ccswitch_db_path};
|
||||
use crate::error::{CodexxError, Result};
|
||||
use crate::sqlite_utils::table_column_set;
|
||||
use crate::string_value;
|
||||
use crate::toml_utils::ensure_table;
|
||||
use rusqlite::{Connection, OpenFlags, TransactionBehavior};
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use toml_edit::{DocumentMut, Table};
|
||||
use toml_edit::{value, DocumentMut, Item, Table};
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -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,130 @@ fn select_ccswitch_section_for_row(
|
||||
global_sections: &HashMap<String, CcSwitchCodexSection>,
|
||||
) -> Option<CcSwitchCodexSection> {
|
||||
let provider_id = custom_provider_id(&row.id);
|
||||
if let Some(section) = global_sections.get(&provider_id) {
|
||||
return Some(section.clone());
|
||||
}
|
||||
if let Some(section) = global_sections.get(row.id.trim()) {
|
||||
return Some(section.clone());
|
||||
}
|
||||
|
||||
let config_text = settings.get("config").and_then(Value::as_str).unwrap_or("");
|
||||
let doc = config_text.parse::<DocumentMut>().ok()?;
|
||||
let model = string_value(&doc, "model");
|
||||
let active_provider = string_value(&doc, "model_provider");
|
||||
let providers = doc.get("model_providers").and_then(|item| item.as_table());
|
||||
let doc = config_text.parse::<DocumentMut>().ok();
|
||||
|
||||
if let Some(providers) = providers {
|
||||
for exact_id in [provider_id.as_str(), row.id.trim()] {
|
||||
if let Some(section) = providers
|
||||
.get(exact_id)
|
||||
.and_then(|item| item.as_table())
|
||||
.and_then(|table| codex_section_from_table(exact_id, table, model.clone()))
|
||||
{
|
||||
return Some(section);
|
||||
}
|
||||
}
|
||||
if let Some(doc) = doc.as_ref() {
|
||||
let model = string_value(doc, "model");
|
||||
let active_provider = string_value(doc, "model_provider");
|
||||
let providers = doc.get("model_providers").and_then(|item| item.as_table());
|
||||
|
||||
if active_provider.as_deref() == Some(row.id.trim())
|
||||
|| active_provider.as_deref() == Some(provider_id.as_str())
|
||||
{
|
||||
if let Some(active) = active_provider.as_deref() {
|
||||
if let Some(providers) = providers {
|
||||
for exact_id in [provider_id.as_str(), row.id.trim()] {
|
||||
if let Some(section) = providers
|
||||
.get(active)
|
||||
.get(exact_id)
|
||||
.and_then(|item| item.as_table())
|
||||
.and_then(|table| codex_section_from_table(active, table, model.clone()))
|
||||
.and_then(|table| codex_section_from_table(exact_id, table, model.clone()))
|
||||
{
|
||||
return Some(section);
|
||||
}
|
||||
}
|
||||
|
||||
if active_provider.as_deref() == Some(row.id.trim())
|
||||
|| active_provider.as_deref() == Some(provider_id.as_str())
|
||||
{
|
||||
if let Some(active) = active_provider.as_deref() {
|
||||
if let Some(section) = providers
|
||||
.get(active)
|
||||
.and_then(|item| item.as_table())
|
||||
.and_then(|table| codex_section_from_table(active, table, model.clone()))
|
||||
{
|
||||
return Some(section);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy cc-switch templates store each third-party provider under
|
||||
// `[model_providers.custom]` in that row's own complete config.
|
||||
if active_provider
|
||||
.as_deref()
|
||||
.is_none_or(|active| active == "custom")
|
||||
{
|
||||
if let Some(section) = providers
|
||||
.get("custom")
|
||||
.and_then(|item| item.as_table())
|
||||
.and_then(|table| codex_section_from_table("custom", table, model.clone()))
|
||||
{
|
||||
return Some(section);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy cc-switch/custom templates often store every third-party provider
|
||||
// under `[model_providers.custom]`. Only use it when the row's own config
|
||||
// explicitly activates custom or contains no other provider identity.
|
||||
if active_provider
|
||||
.as_deref()
|
||||
.is_none_or(|active| active == "custom")
|
||||
{
|
||||
if let Some(section) = providers
|
||||
.get("custom")
|
||||
.and_then(|item| item.as_table())
|
||||
.and_then(|table| codex_section_from_table("custom", table, model.clone()))
|
||||
{
|
||||
return Some(section);
|
||||
}
|
||||
for exact_id in [provider_id.as_str(), row.id.trim()] {
|
||||
if let Some(section) = global_sections.get(exact_id) {
|
||||
return Some(section.clone());
|
||||
}
|
||||
}
|
||||
|
||||
let doc = doc?;
|
||||
let active_provider = string_value(&doc, "model_provider");
|
||||
doc.get("base_url")
|
||||
.and_then(|item| item.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|base_url| CcSwitchCodexSection {
|
||||
id: provider_id,
|
||||
name: None,
|
||||
base_url: base_url.trim_end_matches('/').to_string(),
|
||||
model,
|
||||
wire_api: "responses".to_string(),
|
||||
requires_openai_auth: false,
|
||||
experimental_bearer_token: experimental_bearer_token_from_doc(
|
||||
&doc,
|
||||
active_provider.as_deref(),
|
||||
),
|
||||
.map(|base_url| {
|
||||
let base_url = base_url.trim_end_matches('/').to_string();
|
||||
let token = experimental_bearer_token_from_doc(&doc, active_provider.as_deref());
|
||||
let mut provider_table = Table::new();
|
||||
provider_table["base_url"] = value(base_url.clone());
|
||||
provider_table["wire_api"] = value("responses");
|
||||
provider_table["requires_openai_auth"] = value(false);
|
||||
if let Some(token) = token.as_deref() {
|
||||
provider_table["experimental_bearer_token"] = value(token);
|
||||
}
|
||||
CcSwitchCodexSection {
|
||||
id: provider_id,
|
||||
name: None,
|
||||
base_url,
|
||||
model: string_value(&doc, "model"),
|
||||
wire_api: "responses".to_string(),
|
||||
requires_openai_auth: false,
|
||||
experimental_bearer_token: token,
|
||||
provider_table,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn ccswitch_provider_template(
|
||||
settings: &Value,
|
||||
section: &CcSwitchCodexSection,
|
||||
provider_name: &str,
|
||||
model: &str,
|
||||
) -> Option<String> {
|
||||
let config_text = settings.get("config").and_then(Value::as_str).unwrap_or("");
|
||||
let mut doc = if config_text.trim().is_empty() {
|
||||
DocumentMut::new()
|
||||
} else {
|
||||
config_text.parse::<DocumentMut>().ok()?
|
||||
};
|
||||
let provider_id = section.id.trim();
|
||||
if provider_id.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
if string_value(&doc, "model_provider").as_deref() != Some(provider_id) {
|
||||
doc["model_provider"] = value(provider_id);
|
||||
}
|
||||
if string_value(&doc, "model").as_deref() != Some(model) {
|
||||
doc["model"] = value(model);
|
||||
}
|
||||
let providers = ensure_table(doc.as_table_mut(), "model_providers").ok()?;
|
||||
if providers
|
||||
.get(provider_id)
|
||||
.and_then(|item| item.as_table())
|
||||
.is_none()
|
||||
{
|
||||
let mut table = section.provider_table.clone();
|
||||
if table_string(&table, "name").is_none() && !provider_name.trim().is_empty() {
|
||||
table["name"] = value(provider_name.trim());
|
||||
}
|
||||
providers.insert(provider_id, Item::Table(table));
|
||||
}
|
||||
strip_provider_bearer_tokens(&mut doc);
|
||||
Some(doc.to_string().trim_end().to_string())
|
||||
}
|
||||
|
||||
pub(crate) fn build_ccswitch_codex_provider(
|
||||
row: &CcSwitchCodexRow,
|
||||
global_sections: &HashMap<String, CcSwitchCodexSection>,
|
||||
@@ -241,19 +296,26 @@ pub(crate) fn build_ccswitch_codex_provider(
|
||||
let settings: Value = serde_json::from_str(&row.settings_config).ok()?;
|
||||
let section = select_ccswitch_section_for_row(row, &settings, global_sections)?;
|
||||
let api_key = ccswitch_auth_api_key(&settings).or(section.experimental_bearer_token.clone());
|
||||
let provider_name = section
|
||||
.name
|
||||
.clone()
|
||||
.or_else(|| {
|
||||
let name = row.name.trim();
|
||||
(!name.is_empty()).then(|| name.to_string())
|
||||
})
|
||||
.unwrap_or_else(|| row.id.clone());
|
||||
let model = section.model.clone()?;
|
||||
let toml_config = ccswitch_provider_template(&settings, §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,
|
||||
model_mappings: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -334,7 +396,6 @@ pub(crate) fn import_ccswitch_codex_providers_inner(path: Option<String>) -> Res
|
||||
match result.kind {
|
||||
ProviderUpsertKind::Added => added += 1,
|
||||
ProviderUpsertKind::Updated => updated += 1,
|
||||
ProviderUpsertKind::Merged => merged += 1,
|
||||
}
|
||||
imported += 1;
|
||||
}
|
||||
@@ -435,9 +496,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 +510,195 @@ pub(crate) fn read_ccswitch_official_auth_inner(
|
||||
|
||||
Ok(Some(OfficialAuthCandidate {
|
||||
auth_json,
|
||||
config_text,
|
||||
model,
|
||||
source: format!("cc-switch:{name}:{id}"),
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn provider_import_round_trips_complete_config_without_bearer_tokens() {
|
||||
let settings_config = json!({
|
||||
"auth": {"OPENAI_API_KEY": "sk-from-auth"},
|
||||
"config": r#"# keep-imported-comment
|
||||
model_provider = "custom"
|
||||
model = "gpt-5.6-sol"
|
||||
model_reasoning_effort = "xhigh"
|
||||
service_tier = "priority"
|
||||
experimental_bearer_token = "sk-top-level"
|
||||
notify = ["C:\\Users\\Thy\\codex-computer-use.exe", "turn-ended"]
|
||||
|
||||
[model_providers.custom]
|
||||
name = "Sky2api"
|
||||
base_url = "https://proxy.example.com/v1/"
|
||||
wire_api = "responses"
|
||||
requires_openai_auth = false
|
||||
experimental_bearer_token = "sk-from-config"
|
||||
request_max_retries = 7
|
||||
|
||||
[model_providers.other]
|
||||
name = "Other provider"
|
||||
base_url = "https://other.example.com/v1"
|
||||
experimental_bearer_token = "sk-other"
|
||||
|
||||
[projects."/work/project"]
|
||||
trust_level = "trusted"
|
||||
|
||||
[desktop]
|
||||
followUpQueueMode = "queue"
|
||||
localeOverride = "zh-CN"
|
||||
|
||||
[windows]
|
||||
sandbox = "elevated"
|
||||
shell_path = 'D:\Program Files\PowerShell\7\pwsh.exe'
|
||||
|
||||
[plugins."browser@openai-bundled"]
|
||||
enabled = true
|
||||
|
||||
[features]
|
||||
js_repl = false
|
||||
|
||||
[shell_environment_policy.set]
|
||||
CODEX_HOME = 'C:\Users\Thy\.codex'
|
||||
|
||||
[mcp_servers.docs]
|
||||
command = "docs-server"
|
||||
"#,
|
||||
})
|
||||
.to_string();
|
||||
let row = CcSwitchCodexRow {
|
||||
id: "magicai-123".to_string(),
|
||||
name: " Sky2_free ".to_string(),
|
||||
settings_config,
|
||||
category: None,
|
||||
};
|
||||
|
||||
let provider =
|
||||
build_ccswitch_codex_provider(&row, &HashMap::new()).expect("build cc-switch provider");
|
||||
assert_eq!(provider.id, "magicai-123");
|
||||
assert_eq!(provider.provider_name, "Sky2api");
|
||||
assert_eq!(provider.base_url, "https://proxy.example.com/v1");
|
||||
assert_eq!(provider.model, "gpt-5.6-sol");
|
||||
assert_eq!(provider.api_key.as_deref(), Some("sk-from-auth"));
|
||||
assert_eq!(provider.wire_api, "responses");
|
||||
assert!(!provider.requires_openai_auth);
|
||||
|
||||
let text = provider.toml_config.expect("complete provider TOML");
|
||||
let doc = text.parse::<DocumentMut>().expect("parse provider TOML");
|
||||
assert!(text.contains("# keep-imported-comment"));
|
||||
assert_eq!(doc["model_provider"].as_str(), Some("custom"));
|
||||
assert_eq!(doc["model_reasoning_effort"].as_str(), Some("xhigh"));
|
||||
assert_eq!(doc["service_tier"].as_str(), Some("priority"));
|
||||
assert_eq!(doc["notify"].as_array().map(|values| values.len()), Some(2));
|
||||
assert_eq!(
|
||||
doc["model_providers"]["custom"]["name"].as_str(),
|
||||
Some("Sky2api")
|
||||
);
|
||||
assert_eq!(
|
||||
doc["model_providers"]["custom"]["base_url"].as_str(),
|
||||
Some("https://proxy.example.com/v1/")
|
||||
);
|
||||
assert_eq!(
|
||||
doc["model_providers"]["custom"]["request_max_retries"].as_integer(),
|
||||
Some(7)
|
||||
);
|
||||
assert_eq!(
|
||||
doc["model_providers"]["other"]["base_url"].as_str(),
|
||||
Some("https://other.example.com/v1")
|
||||
);
|
||||
assert_eq!(
|
||||
doc["projects"]["/work/project"]["trust_level"].as_str(),
|
||||
Some("trusted")
|
||||
);
|
||||
assert_eq!(doc["desktop"]["followUpQueueMode"].as_str(), Some("queue"));
|
||||
assert_eq!(doc["desktop"]["localeOverride"].as_str(), Some("zh-CN"));
|
||||
assert_eq!(doc["windows"]["sandbox"].as_str(), Some("elevated"));
|
||||
assert_eq!(
|
||||
doc["windows"]["shell_path"].as_str(),
|
||||
Some(r"D:\Program Files\PowerShell\7\pwsh.exe")
|
||||
);
|
||||
assert_eq!(
|
||||
doc["plugins"]["browser@openai-bundled"]["enabled"].as_bool(),
|
||||
Some(true)
|
||||
);
|
||||
assert_eq!(doc["features"]["js_repl"].as_bool(), Some(false));
|
||||
assert_eq!(
|
||||
doc["shell_environment_policy"]["set"]["CODEX_HOME"].as_str(),
|
||||
Some(r"C:\Users\Thy\.codex")
|
||||
);
|
||||
assert_eq!(
|
||||
doc["mcp_servers"]["docs"]["command"].as_str(),
|
||||
Some("docs-server")
|
||||
);
|
||||
assert!(doc.get("experimental_bearer_token").is_none());
|
||||
assert!(doc["model_providers"]
|
||||
.as_table()
|
||||
.expect("model providers table")
|
||||
.iter()
|
||||
.all(|(_, item)| item
|
||||
.as_table()
|
||||
.is_none_or(|table| table.get("experimental_bearer_token").is_none())));
|
||||
assert!(!text.contains("sk-from-auth"));
|
||||
assert!(!text.contains("experimental_bearer_token"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn complete_row_without_a_model_is_not_silently_downgraded() {
|
||||
let row = CcSwitchCodexRow {
|
||||
id: "missing-model".to_string(),
|
||||
name: "Database label".to_string(),
|
||||
settings_config: json!({
|
||||
"auth": {"OPENAI_API_KEY": "sk-test"},
|
||||
"config": r#"model_provider = "custom"
|
||||
|
||||
[model_providers.custom]
|
||||
name = "TOML label"
|
||||
base_url = "https://proxy.example.com/v1"
|
||||
wire_api = "responses"
|
||||
requires_openai_auth = false
|
||||
"#,
|
||||
})
|
||||
.to_string(),
|
||||
category: None,
|
||||
};
|
||||
|
||||
assert!(build_ccswitch_codex_provider(&row, &HashMap::new()).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_row_config_does_not_create_a_sparse_provider() {
|
||||
let section = codex_sections_from_config(
|
||||
r#"model_provider = "broken-row"
|
||||
model = "gpt-5.5"
|
||||
|
||||
[model_providers.broken-row]
|
||||
name = "Recovered only from another row"
|
||||
base_url = "https://proxy.example.com/v1"
|
||||
wire_api = "responses"
|
||||
requires_openai_auth = false
|
||||
"#,
|
||||
)
|
||||
.into_iter()
|
||||
.next()
|
||||
.expect("global provider section");
|
||||
let mut global_sections = HashMap::new();
|
||||
global_sections.insert(section.id.clone(), section);
|
||||
let row = CcSwitchCodexRow {
|
||||
id: "broken-row".to_string(),
|
||||
name: "Broken row".to_string(),
|
||||
settings_config: json!({
|
||||
"auth": {"OPENAI_API_KEY": "sk-must-not-import"},
|
||||
"config": "model = ["
|
||||
})
|
||||
.to_string(),
|
||||
category: None,
|
||||
};
|
||||
|
||||
assert!(build_ccswitch_codex_provider(&row, &global_sections).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
mod ccswitch;
|
||||
mod connection;
|
||||
mod live;
|
||||
pub(crate) mod model_catalog;
|
||||
mod official_auth;
|
||||
pub(crate) mod official_profiles;
|
||||
pub(crate) mod quota;
|
||||
mod selection;
|
||||
mod store;
|
||||
|
||||
use crate::error::Result;
|
||||
@@ -24,21 +28,24 @@ 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,
|
||||
activate_saved_provider_inner, build_provider_toml_draft_inner, delete_saved_provider_inner,
|
||||
save_active_provider_inner, save_official_config_inner, save_provider_toml_config_inner,
|
||||
switch_official_provider_inner, switch_provider_inner, OfficialConfigInput, ProviderInput,
|
||||
ProviderTomlInput,
|
||||
switch_provider_inner, OfficialConfigInput, ProviderInput, ProviderTomlInput,
|
||||
};
|
||||
#[cfg(test)]
|
||||
pub(crate) use live::{
|
||||
save_provider_toml_config_with_pre_persist, switch_official_provider_with_pre_persist,
|
||||
switch_provider_with_pre_persist,
|
||||
reset_official_provider_inner, restore_official_provider_inner,
|
||||
save_provider_toml_config_with_pre_persist, switch_official_provider_inner,
|
||||
switch_official_provider_with_pre_persist, switch_provider_with_pre_persist,
|
||||
};
|
||||
#[cfg(test)]
|
||||
pub(crate) use official_auth::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,
|
||||
capture_live_chatgpt_config, get_official_config_draft_inner, official_snapshot_path_for_test,
|
||||
};
|
||||
pub(crate) use official_auth::{document_is_official, official_auth_available};
|
||||
pub(crate) use selection::{
|
||||
clear_active_provider_on_connection, clear_provider_selections_on_connection,
|
||||
reconcile_active_provider_on_connection, remember_active_provider_on_connection,
|
||||
};
|
||||
#[cfg(test)]
|
||||
pub(crate) use store::{
|
||||
@@ -47,12 +54,15 @@ 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, duplicate_provider_inner, experimental_bearer_token_from_doc,
|
||||
is_placeholder_provider, list_saved_providers_inner, list_saved_providers_on_connection,
|
||||
matching_saved_provider_ids_for_live, matching_saved_provider_ids_for_live_on_connection,
|
||||
normalize_saved_provider, normalize_saved_provider_for_save, provider_template_from_document,
|
||||
reserved_codex_provider_id, rollback_provider_store_inner,
|
||||
save_detected_provider_with_rollback_inner, save_provider_inner,
|
||||
save_provider_with_rollback_inner, strip_provider_bearer_tokens,
|
||||
upsert_ccswitch_provider_on_connection, DuplicateProviderResult, ProviderStoreRollback,
|
||||
ProviderUpsertKind, SavedProvider,
|
||||
};
|
||||
|
||||
pub(crate) fn open_store() -> Result<Connection> {
|
||||
|
||||
@@ -0,0 +1,712 @@
|
||||
//! Provider-local model menus using Codex's external ModelsResponse catalog.
|
||||
//! A display name never changes the model slug sent to the provider. Catalogs
|
||||
//! are immutable so a failed live-config transaction can keep its old pointer.
|
||||
|
||||
use crate::error::{CodexxError, Result};
|
||||
use crate::file_io::{ensure_directory, io_err, write_private_json};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::HashSet;
|
||||
use std::fs;
|
||||
use std::io::Read;
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
use toml_edit::{value, DocumentMut};
|
||||
|
||||
const MAX_MAPPINGS: usize = 64;
|
||||
const MAX_NAME_CHARS: usize = 200;
|
||||
const MAX_CONTEXT_WINDOW: i64 = 10_000_000;
|
||||
const DEFAULT_CONTEXT_WINDOW: i64 = 128_000;
|
||||
const OWNED_DIRECTORY: &str = ".codex-x";
|
||||
const CATALOG_DIRECTORY: &str = "model-catalogs";
|
||||
const CATALOG_FIELD: &str = "model_catalog_json";
|
||||
const OWNERSHIP_FIELD: &str = "_codex_x_model_catalog";
|
||||
const MAX_CATALOG_BYTES: u64 = 1024 * 1024;
|
||||
const BASE_INSTRUCTIONS: &str = "You are a coding assistant working with the user in a shared workspace. Use the available tools to inspect and edit project files, follow the user's requirements, and verify your changes.";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct ProviderModelMapping {
|
||||
pub(crate) model: String,
|
||||
#[serde(default)]
|
||||
pub(crate) display_name: String,
|
||||
#[serde(default)]
|
||||
pub(crate) context_window: Option<i64>,
|
||||
}
|
||||
|
||||
fn validate_name(name: &str, label: &str) -> Result<String> {
|
||||
let name = name.trim();
|
||||
if name.is_empty() {
|
||||
return Err(CodexxError::Config(format!("{label}不能为空")));
|
||||
}
|
||||
if name.chars().count() > MAX_NAME_CHARS || name.chars().any(char::is_control) {
|
||||
return Err(CodexxError::Config(format!(
|
||||
"{label}最多 {MAX_NAME_CHARS} 个字符,且不能包含控制字符"
|
||||
)));
|
||||
}
|
||||
Ok(name.to_owned())
|
||||
}
|
||||
|
||||
pub(crate) fn normalize_mappings(
|
||||
rows: &[ProviderModelMapping],
|
||||
default_model: &str,
|
||||
) -> Result<Vec<ProviderModelMapping>> {
|
||||
if rows.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
if rows.len() > MAX_MAPPINGS {
|
||||
return Err(CodexxError::Config(format!(
|
||||
"最多添加 {MAX_MAPPINGS} 个模型"
|
||||
)));
|
||||
}
|
||||
let default_model = validate_name(default_model, "默认模型 ID")?;
|
||||
let mut seen = HashSet::new();
|
||||
let mut normalized = Vec::new();
|
||||
for row in rows {
|
||||
let model = validate_name(&row.model, "模型 ID")?;
|
||||
let display_name = if row.display_name.trim().is_empty() {
|
||||
model.clone()
|
||||
} else {
|
||||
validate_name(&row.display_name, "模型显示名称")?
|
||||
};
|
||||
if row
|
||||
.context_window
|
||||
.is_some_and(|window| !(1..=MAX_CONTEXT_WINDOW).contains(&window))
|
||||
{
|
||||
return Err(CodexxError::Config(format!(
|
||||
"模型上下文窗口须为 1 至 {MAX_CONTEXT_WINDOW} 之间的整数"
|
||||
)));
|
||||
}
|
||||
if seen.insert(model.clone()) {
|
||||
normalized.push(ProviderModelMapping {
|
||||
model,
|
||||
display_name,
|
||||
context_window: row.context_window,
|
||||
});
|
||||
}
|
||||
}
|
||||
if !seen.contains(&default_model) {
|
||||
if normalized.len() >= MAX_MAPPINGS {
|
||||
return Err(CodexxError::Config(format!(
|
||||
"模型列表需要包含默认模型,合计最多 {MAX_MAPPINGS} 个模型"
|
||||
)));
|
||||
}
|
||||
normalized.insert(
|
||||
0,
|
||||
ProviderModelMapping {
|
||||
display_name: default_model.clone(),
|
||||
model: default_model,
|
||||
context_window: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
Ok(normalized)
|
||||
}
|
||||
|
||||
fn model_entry(mapping: &ProviderModelMapping, priority: usize, default_context: i64) -> Value {
|
||||
let context_window = mapping.context_window.unwrap_or(default_context);
|
||||
// Schema: openai/codex rust-v0.153.4, protocol/src/openai_models.rs,
|
||||
// ModelInfo + ModelsResponse. Keep legacy required fields for older Codex.
|
||||
// Unlike cloning a GPT cache entry, this does not import proprietary model
|
||||
// instructions, hosted tools, service tiers, vision, or reasoning promises.
|
||||
json!({
|
||||
"slug": mapping.model,
|
||||
"display_name": mapping.display_name,
|
||||
"description": mapping.display_name,
|
||||
"base_instructions": BASE_INSTRUCTIONS,
|
||||
"default_reasoning_level": null,
|
||||
"supported_reasoning_levels": [],
|
||||
"shell_type": "shell_command",
|
||||
"visibility": "list",
|
||||
"supported_in_api": true,
|
||||
"priority": priority,
|
||||
"additional_speed_tiers": [],
|
||||
"service_tiers": [],
|
||||
"availability_nux": null,
|
||||
"upgrade": null,
|
||||
"supports_reasoning_summaries": false,
|
||||
"supports_reasoning_summary_parameter": false,
|
||||
"default_reasoning_summary": "none",
|
||||
"support_verbosity": false,
|
||||
"default_verbosity": null,
|
||||
"apply_patch_tool_type": null,
|
||||
"truncation_policy": { "mode": "bytes", "limit": 10000 },
|
||||
"supports_parallel_tool_calls": false,
|
||||
"supports_image_detail_original": false,
|
||||
"context_window": context_window,
|
||||
"max_context_window": context_window,
|
||||
"effective_context_window_percent": 95,
|
||||
"experimental_supported_tools": [],
|
||||
"input_modalities": ["text"],
|
||||
"supports_search_tool": false,
|
||||
"use_responses_lite": false
|
||||
})
|
||||
}
|
||||
|
||||
fn build_catalog(mappings: &[ProviderModelMapping], doc: &DocumentMut) -> Value {
|
||||
let default_context = doc
|
||||
.get("model_context_window")
|
||||
.and_then(|item| item.as_integer())
|
||||
.filter(|window| (1..=MAX_CONTEXT_WINDOW).contains(window))
|
||||
.unwrap_or(DEFAULT_CONTEXT_WINDOW);
|
||||
json!({"models": mappings.iter().enumerate()
|
||||
.map(|(priority, mapping)| model_entry(mapping, priority, default_context))
|
||||
.collect::<Vec<_>>()})
|
||||
}
|
||||
|
||||
fn is_link(metadata: &fs::Metadata) -> bool {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::fs::MetadataExt;
|
||||
// Include junctions/reparse points, not only ordinary symlinks.
|
||||
metadata.file_attributes() & 0x400 != 0
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
metadata.file_type().is_symlink()
|
||||
}
|
||||
}
|
||||
|
||||
fn owned_catalog_directory(codex_dir: &Path) -> PathBuf {
|
||||
codex_dir.join(OWNED_DIRECTORY).join(CATALOG_DIRECTORY)
|
||||
}
|
||||
|
||||
fn catalog_filename(catalog: &Value) -> Result<String> {
|
||||
let content = serde_json::to_vec(catalog)
|
||||
.map_err(|_| CodexxError::Config("无法生成供应商模型目录".into()))?;
|
||||
let mut digest = Sha256::new();
|
||||
digest.update(b"codex-x-provider-model-catalog-v2\0");
|
||||
digest.update(&content);
|
||||
Ok(format!("{:x}.json", digest.finalize()))
|
||||
}
|
||||
|
||||
fn has_verified_ownership(path: &Path, filename: &str) -> bool {
|
||||
let Ok(file) = fs::File::open(path) else {
|
||||
return false;
|
||||
};
|
||||
let Ok(metadata) = file.metadata() else {
|
||||
return false;
|
||||
};
|
||||
if !metadata.is_file() || metadata.len() > MAX_CATALOG_BYTES {
|
||||
return false;
|
||||
}
|
||||
let mut bytes = Vec::new();
|
||||
if file
|
||||
.take(MAX_CATALOG_BYTES + 1)
|
||||
.read_to_end(&mut bytes)
|
||||
.is_err()
|
||||
|| bytes.len() as u64 > MAX_CATALOG_BYTES
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let Ok(catalog) = serde_json::from_slice::<Value>(&bytes) else {
|
||||
return false;
|
||||
};
|
||||
let Some(owner) = catalog.get(OWNERSHIP_FIELD) else {
|
||||
return false;
|
||||
};
|
||||
let Some(provider_hash) = owner.get("provider_hash").and_then(Value::as_str) else {
|
||||
return false;
|
||||
};
|
||||
owner.get("source").and_then(Value::as_str) == Some("codex-x")
|
||||
&& owner.get("format").and_then(Value::as_u64) == Some(1)
|
||||
&& provider_hash.len() == 64
|
||||
&& provider_hash.bytes().all(|byte| byte.is_ascii_hexdigit())
|
||||
&& catalog
|
||||
.get("models")
|
||||
.and_then(Value::as_array)
|
||||
.is_some_and(|models| !models.is_empty() && models.len() <= MAX_MAPPINGS)
|
||||
&& catalog_filename(&catalog).is_ok_and(|expected| expected == filename)
|
||||
}
|
||||
|
||||
fn prepare_owned_directory(codex_dir: &Path) -> Result<PathBuf> {
|
||||
ensure_directory(codex_dir)?;
|
||||
// The user's CODEX_HOME may intentionally be a symlink/junction. Resolve
|
||||
// that once, then reject links inside our own generated-file directory.
|
||||
let mut directory = codex_dir
|
||||
.canonicalize()
|
||||
.map_err(|error| io_err(codex_dir, error))?;
|
||||
for component in [OWNED_DIRECTORY, CATALOG_DIRECTORY] {
|
||||
directory.push(component);
|
||||
match fs::symlink_metadata(&directory) {
|
||||
Ok(metadata) if is_link(&metadata) || !metadata.is_dir() => {
|
||||
return Err(CodexxError::Config(
|
||||
"模型目录被文件或链接占用,请检查 Codex-X 模型目录".into(),
|
||||
));
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
||||
ensure_directory(&directory)?;
|
||||
}
|
||||
Err(error) => return Err(io_err(&directory, error)),
|
||||
}
|
||||
let metadata =
|
||||
fs::symlink_metadata(&directory).map_err(|error| io_err(&directory, error))?;
|
||||
if is_link(&metadata) || !metadata.is_dir() {
|
||||
return Err(CodexxError::Config(
|
||||
"模型目录在创建时发生变化,请重试".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(directory)
|
||||
}
|
||||
|
||||
fn is_owned_pointer(codex_dir: &Path, pointer: &str) -> bool {
|
||||
let pointer = Path::new(pointer);
|
||||
// Never treat a traversal path as our generated pointer, even if part of
|
||||
// the spelling happens to resemble the owned directory.
|
||||
if pointer
|
||||
.components()
|
||||
.any(|part| matches!(part, Component::ParentDir))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let Some(name) = pointer.file_name().and_then(|name| name.to_str()) else {
|
||||
return false;
|
||||
};
|
||||
let Some(hash) = name.strip_suffix(".json") else {
|
||||
return false;
|
||||
};
|
||||
if hash.len() != 64 || !hash.bytes().all(|byte| byte.is_ascii_hexdigit()) {
|
||||
return false;
|
||||
}
|
||||
let candidate = if pointer.is_absolute() {
|
||||
pointer.to_path_buf()
|
||||
} else {
|
||||
codex_dir.join(pointer)
|
||||
};
|
||||
let root = owned_catalog_directory(codex_dir);
|
||||
let Some(parent) = candidate.parent() else {
|
||||
return false;
|
||||
};
|
||||
let Some(owner_directory) = parent.parent() else {
|
||||
return false;
|
||||
};
|
||||
if parent
|
||||
.file_name()
|
||||
.is_none_or(|name| name != CATALOG_DIRECTORY)
|
||||
|| owner_directory
|
||||
.file_name()
|
||||
.is_none_or(|name| name != OWNED_DIRECTORY)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
// Only the CODEX_HOME ancestor may be redirected. A link placed inside the
|
||||
// app-owned path (or at the catalog itself) must not claim a user's target.
|
||||
for directory in [parent, owner_directory] {
|
||||
if fs::symlink_metadata(directory)
|
||||
.is_ok_and(|metadata| is_link(&metadata) || !metadata.is_dir())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if fs::symlink_metadata(&candidate)
|
||||
.is_ok_and(|metadata| is_link(&metadata) || !metadata.is_file())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let same_parent = match (parent.canonicalize(), root.canonicalize()) {
|
||||
(Ok(parent), Ok(root)) => parent == root,
|
||||
_ => parent == root,
|
||||
};
|
||||
// Retain support for legacy/stale pointers in the current owned directory.
|
||||
// For another CODEX_HOME, require a marker and a digest covering the whole
|
||||
// catalog; a similarly named user catalog must remain untouched.
|
||||
same_parent || has_verified_ownership(&candidate, name)
|
||||
}
|
||||
|
||||
pub(crate) fn prepare_model_catalog(
|
||||
codex_dir: &Path,
|
||||
provider_id: &str,
|
||||
mappings: &[ProviderModelMapping],
|
||||
model: &str,
|
||||
doc: &mut DocumentMut,
|
||||
) -> Result<()> {
|
||||
let mappings = normalize_mappings(mappings, model)?;
|
||||
if mappings.is_empty() {
|
||||
if doc
|
||||
.get(CATALOG_FIELD)
|
||||
.and_then(|item| item.as_str())
|
||||
.is_some_and(|pointer| is_owned_pointer(codex_dir, pointer))
|
||||
{
|
||||
doc.as_table_mut().remove(CATALOG_FIELD);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
let mut catalog = build_catalog(&mappings, doc);
|
||||
catalog[OWNERSHIP_FIELD] = json!({
|
||||
"source": "codex-x", "format": 1,
|
||||
"provider_hash": format!("{:x}", Sha256::digest(provider_id.as_bytes()))
|
||||
});
|
||||
let filename = catalog_filename(&catalog)?;
|
||||
let directory = prepare_owned_directory(codex_dir)?;
|
||||
let path = directory.join(filename);
|
||||
match fs::symlink_metadata(&path) {
|
||||
Ok(metadata) => {
|
||||
if is_link(&metadata) || !metadata.is_file() || metadata.len() > MAX_CATALOG_BYTES {
|
||||
return Err(CodexxError::Config(
|
||||
"已生成的模型目录文件无效,请检查后重试".into(),
|
||||
));
|
||||
}
|
||||
let existing = fs::read(&path).map_err(|error| io_err(&path, error))?;
|
||||
if serde_json::from_slice::<Value>(&existing).ok().as_ref() != Some(&catalog) {
|
||||
return Err(CodexxError::Config(
|
||||
"已生成的模型目录文件被修改,未覆盖原文件".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
||||
write_private_json(&path, &catalog)?
|
||||
}
|
||||
Err(error) => return Err(io_err(&path, error)),
|
||||
}
|
||||
// Change the caller's document only after a complete catalog is available.
|
||||
// The caller owns the live config transaction; no auth/config files are
|
||||
// read or written here, and models_cache.json is intentionally untouched.
|
||||
doc[CATALOG_FIELD] = value(path.to_string_lossy().to_string());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
struct Fixture(PathBuf);
|
||||
impl Fixture {
|
||||
fn new() -> Self {
|
||||
static NEXT: AtomicU64 = AtomicU64::new(0);
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"codex-x-catalog-{}-{}",
|
||||
std::process::id(),
|
||||
NEXT.fetch_add(1, Ordering::Relaxed)
|
||||
));
|
||||
fs::create_dir_all(&path).unwrap();
|
||||
Self(path)
|
||||
}
|
||||
}
|
||||
impl Drop for Fixture {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
fn mapping(model: &str, display_name: &str, context: Option<i64>) -> ProviderModelMapping {
|
||||
ProviderModelMapping {
|
||||
model: model.into(),
|
||||
display_name: display_name.into(),
|
||||
context_window: context,
|
||||
}
|
||||
}
|
||||
fn path(doc: &DocumentMut) -> PathBuf {
|
||||
PathBuf::from(doc[CATALOG_FIELD].as_str().unwrap())
|
||||
}
|
||||
fn catalog(doc: &DocumentMut) -> Value {
|
||||
serde_json::from_slice(&fs::read(path(doc)).unwrap()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalization_preserves_real_models_and_adds_missing_default() {
|
||||
let result = normalize_mappings(
|
||||
&[
|
||||
mapping(" vendor/model-A ", "菜单名称", Some(1_048_576)),
|
||||
mapping("vendor/model-A", "duplicate", Some(128_000)),
|
||||
mapping("vendor/model-a", "", None),
|
||||
],
|
||||
"deepseek-chat",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(result.len(), 3);
|
||||
assert_eq!(result[0], mapping("deepseek-chat", "deepseek-chat", None));
|
||||
assert_eq!(
|
||||
result[1],
|
||||
mapping("vendor/model-A", "菜单名称", Some(1_048_576))
|
||||
);
|
||||
assert_eq!(result[2].display_name, "vendor/model-a");
|
||||
assert!(normalize_mappings(&[], "").unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_ids_names_context_and_too_many_models() {
|
||||
for invalid in ["", "model\nname", &"a".repeat(201)] {
|
||||
assert!(normalize_mappings(&[mapping(invalid, "", None)], "default").is_err());
|
||||
}
|
||||
assert!(normalize_mappings(&[mapping("model", "bad\nname", None)], "model").is_err());
|
||||
for invalid in [0, -1, MAX_CONTEXT_WINDOW + 1] {
|
||||
assert!(normalize_mappings(&[mapping("model", "", Some(invalid))], "model").is_err());
|
||||
}
|
||||
let rows: Vec<_> = (0..64)
|
||||
.map(|index| mapping(&format!("model-{index}"), "", None))
|
||||
.collect();
|
||||
assert_eq!(normalize_mappings(&rows, "model-0").unwrap().len(), 64);
|
||||
assert!(normalize_mappings(&rows, "missing-default").is_err());
|
||||
let mut excessive = rows;
|
||||
excessive.push(mapping("model-64", "", None));
|
||||
assert!(normalize_mappings(&excessive, "model-0").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serialized_mapping_allows_omitted_display_name_and_context() {
|
||||
let mapping: ProviderModelMapping =
|
||||
serde_json::from_str(r#"{"model":"deepseek-chat"}"#).unwrap();
|
||||
let normalized = normalize_mappings(&[mapping], "deepseek-chat").unwrap();
|
||||
assert_eq!(
|
||||
serde_json::to_value(&normalized[0]).unwrap(),
|
||||
json!({"model":"deepseek-chat","displayName":"deepseek-chat","contextWindow":null})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn catalog_never_impersonates_gpt_or_copies_cached_capabilities() {
|
||||
let fixture = Fixture::new();
|
||||
let cache = json!({"models":[{"slug":"gpt-5.5","model_messages":{"instructions_template":"PRIVATE CACHED INSTRUCTIONS"},"apply_patch_tool_type":"freeform","experimental_supported_tools":["namespace-secret"],"supports_image_detail_original":true}]});
|
||||
let cache_text = serde_json::to_vec(&cache).unwrap();
|
||||
fs::write(fixture.0.join("models_cache.json"), &cache_text).unwrap();
|
||||
fs::write(fixture.0.join("config.toml"), "original-config").unwrap();
|
||||
fs::write(fixture.0.join("auth.json"), "original-auth").unwrap();
|
||||
let mut doc = DocumentMut::new();
|
||||
prepare_model_catalog(
|
||||
&fixture.0,
|
||||
"provider-a",
|
||||
&[mapping("deepseek-chat", "DeepSeek 常用", Some(131072))],
|
||||
"deepseek-chat",
|
||||
&mut doc,
|
||||
)
|
||||
.unwrap();
|
||||
let catalog = catalog(&doc);
|
||||
let model = &catalog["models"][0];
|
||||
assert_eq!(model["slug"], "deepseek-chat");
|
||||
assert_eq!(model["display_name"], "DeepSeek 常用");
|
||||
assert_eq!(model["context_window"], 131072);
|
||||
assert_eq!(model["apply_patch_tool_type"], Value::Null);
|
||||
assert_eq!(model["input_modalities"], json!(["text"]));
|
||||
assert!(!catalog.to_string().contains("gpt-5.5"));
|
||||
assert!(!catalog.to_string().contains("PRIVATE"));
|
||||
assert!(!catalog.to_string().contains("namespace-secret"));
|
||||
assert_eq!(
|
||||
fs::read(fixture.0.join("models_cache.json")).unwrap(),
|
||||
cache_text
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read_to_string(fixture.0.join("config.toml")).unwrap(),
|
||||
"original-config"
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read_to_string(fixture.0.join("auth.json")).unwrap(),
|
||||
"original-auth"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn providers_and_changed_content_have_distinct_immutable_catalogs() {
|
||||
let fixture = Fixture::new();
|
||||
let rows = [mapping("model", "Name", None)];
|
||||
let mut first = DocumentMut::new();
|
||||
let mut second = DocumentMut::new();
|
||||
prepare_model_catalog(&fixture.0, "../../provider-a", &rows, "model", &mut first).unwrap();
|
||||
let original_path = path(&first);
|
||||
let original_bytes = fs::read(&original_path).unwrap();
|
||||
prepare_model_catalog(&fixture.0, "provider-b", &rows, "model", &mut second).unwrap();
|
||||
assert_ne!(original_path, path(&second));
|
||||
assert!(
|
||||
original_path.starts_with(owned_catalog_directory(&fixture.0.canonicalize().unwrap()))
|
||||
);
|
||||
assert!(!original_path.to_string_lossy().contains("provider-a"));
|
||||
prepare_model_catalog(
|
||||
&fixture.0,
|
||||
"../../provider-a",
|
||||
&[mapping("model", "Changed", None)],
|
||||
"model",
|
||||
&mut first,
|
||||
)
|
||||
.unwrap();
|
||||
assert_ne!(original_path, path(&first));
|
||||
assert_eq!(fs::read(original_path).unwrap(), original_bytes);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unchanged_content_is_reused_and_tampered_file_is_not_overwritten() {
|
||||
let fixture = Fixture::new();
|
||||
let rows = [mapping("model", "Name", None)];
|
||||
let mut doc = DocumentMut::new();
|
||||
prepare_model_catalog(&fixture.0, "provider", &rows, "model", &mut doc).unwrap();
|
||||
let generated = path(&doc);
|
||||
let modified = fs::metadata(&generated).unwrap().modified().unwrap();
|
||||
prepare_model_catalog(&fixture.0, "provider", &rows, "model", &mut doc).unwrap();
|
||||
assert_eq!(
|
||||
fs::metadata(&generated).unwrap().modified().unwrap(),
|
||||
modified
|
||||
);
|
||||
fs::write(&generated, "keep this changed file").unwrap();
|
||||
let before = doc.to_string();
|
||||
assert!(prepare_model_catalog(&fixture.0, "provider", &rows, "model", &mut doc).is_err());
|
||||
assert_eq!(doc.to_string(), before);
|
||||
assert_eq!(
|
||||
fs::read_to_string(generated).unwrap(),
|
||||
"keep this changed file"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabling_removes_only_our_pointer_without_deleting_catalog() {
|
||||
let fixture = Fixture::new();
|
||||
let mut doc = DocumentMut::new();
|
||||
prepare_model_catalog(
|
||||
&fixture.0,
|
||||
"provider",
|
||||
&[mapping("model", "Name", None)],
|
||||
"model",
|
||||
&mut doc,
|
||||
)
|
||||
.unwrap();
|
||||
let generated = path(&doc);
|
||||
prepare_model_catalog(&fixture.0, "provider", &[], "model", &mut doc).unwrap();
|
||||
assert!(doc.get(CATALOG_FIELD).is_none());
|
||||
assert!(generated.is_file());
|
||||
for custom in [
|
||||
"custom-models.json",
|
||||
".codex-x/model-catalogs/my-custom.json",
|
||||
".codex-x/model-catalogs/../custom.json",
|
||||
] {
|
||||
doc[CATALOG_FIELD] = value(custom);
|
||||
prepare_model_catalog(&fixture.0, "provider", &[], "model", &mut doc).unwrap();
|
||||
assert_eq!(doc[CATALOG_FIELD].as_str(), Some(custom));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relative_and_stale_owned_pointers_can_be_removed() {
|
||||
let fixture = Fixture::new();
|
||||
let mut doc = DocumentMut::new();
|
||||
let pointer = format!(
|
||||
"{OWNED_DIRECTORY}/{CATALOG_DIRECTORY}/{}.json",
|
||||
"a".repeat(64)
|
||||
);
|
||||
doc[CATALOG_FIELD] = value(pointer);
|
||||
prepare_model_catalog(&fixture.0, "provider", &[], "", &mut doc).unwrap();
|
||||
assert!(doc.get(CATALOG_FIELD).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabling_after_switching_codex_home_clears_verified_generated_pointer() {
|
||||
let first_home = Fixture::new();
|
||||
let second_home = Fixture::new();
|
||||
let mut doc = DocumentMut::new();
|
||||
prepare_model_catalog(
|
||||
&first_home.0,
|
||||
"provider-a",
|
||||
&[mapping("deepseek-v4-pro", "DeepSeek", None)],
|
||||
"deepseek-v4-pro",
|
||||
&mut doc,
|
||||
)
|
||||
.unwrap();
|
||||
let generated = path(&doc);
|
||||
let before = fs::read(&generated).unwrap();
|
||||
prepare_model_catalog(&second_home.0, "provider-b", &[], "another-model", &mut doc)
|
||||
.unwrap();
|
||||
assert!(doc.get(CATALOG_FIELD).is_none());
|
||||
assert_eq!(fs::read(&generated).unwrap(), before);
|
||||
assert!(!owned_catalog_directory(&second_home.0).exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn official_cleanup_recognizes_other_home_but_preserves_unverified_user_catalog() {
|
||||
let first_home = Fixture::new();
|
||||
let second_home = Fixture::new();
|
||||
let mut doc = DocumentMut::new();
|
||||
prepare_model_catalog(
|
||||
&first_home.0,
|
||||
"provider-a",
|
||||
&[mapping("deepseek-v4-pro", "DeepSeek", None)],
|
||||
"deepseek-v4-pro",
|
||||
&mut doc,
|
||||
)
|
||||
.unwrap();
|
||||
let generated = path(&doc);
|
||||
let original: Value = serde_json::from_slice(&fs::read(&generated).unwrap()).unwrap();
|
||||
prepare_model_catalog(&second_home.0, "official", &[], "", &mut doc).unwrap();
|
||||
assert!(doc.get(CATALOG_FIELD).is_none());
|
||||
|
||||
// A matching-looking name without the app's marker is user content.
|
||||
let mut no_marker = original.clone();
|
||||
no_marker.as_object_mut().unwrap().remove(OWNERSHIP_FIELD);
|
||||
// A copied marker does not authorize a modified catalog either.
|
||||
let mut modified = original;
|
||||
modified["models"][0]["display_name"] = json!("User's own menu");
|
||||
for unverified in [no_marker, modified] {
|
||||
fs::write(&generated, serde_json::to_vec(&unverified).unwrap()).unwrap();
|
||||
doc[CATALOG_FIELD] = value(generated.to_string_lossy().to_string());
|
||||
prepare_model_catalog(&second_home.0, "official", &[], "", &mut doc).unwrap();
|
||||
assert_eq!(path(&doc), generated);
|
||||
assert_eq!(
|
||||
serde_json::from_slice::<Value>(&fs::read(&generated).unwrap()).unwrap(),
|
||||
unverified
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn omitted_context_uses_explicit_live_context_or_neutral_default() {
|
||||
let rows = [
|
||||
mapping("model", "Model", None),
|
||||
mapping("other", "Other", Some(64000)),
|
||||
];
|
||||
let mut doc: DocumentMut = "model_context_window = 1000000".parse().unwrap();
|
||||
let catalog = build_catalog(&rows, &doc);
|
||||
assert_eq!(catalog["models"][0]["context_window"], 1_000_000);
|
||||
assert_eq!(catalog["models"][1]["context_window"], 64_000);
|
||||
doc["model_context_window"] = value(-1);
|
||||
assert_eq!(
|
||||
build_catalog(&rows, &doc)["models"][0]["context_window"],
|
||||
DEFAULT_CONTEXT_WINDOW
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn generated_files_are_private_and_owned_directory_links_are_rejected() {
|
||||
use std::os::unix::fs::{symlink, PermissionsExt};
|
||||
let fixture = Fixture::new();
|
||||
let mut doc = DocumentMut::new();
|
||||
let rows = [mapping("model", "Model", None)];
|
||||
prepare_model_catalog(&fixture.0, "provider", &rows, "model", &mut doc).unwrap();
|
||||
assert_eq!(
|
||||
fs::metadata(path(&doc)).unwrap().permissions().mode() & 0o777,
|
||||
0o600
|
||||
);
|
||||
let linked = Fixture::new();
|
||||
symlink(&fixture.0, linked.0.join(OWNED_DIRECTORY)).unwrap();
|
||||
let before = doc.to_string();
|
||||
assert!(prepare_model_catalog(&linked.0, "provider", &rows, "model", &mut doc).is_err());
|
||||
assert_eq!(doc.to_string(), before);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn redirected_codex_home_is_supported_but_custom_file_links_are_not_removed() {
|
||||
use std::os::unix::fs::symlink;
|
||||
let fixture = Fixture::new();
|
||||
let links = Fixture::new();
|
||||
let linked_home = links.0.join("codex-home");
|
||||
symlink(&fixture.0, &linked_home).unwrap();
|
||||
let mut doc = DocumentMut::new();
|
||||
prepare_model_catalog(
|
||||
&linked_home,
|
||||
"provider",
|
||||
&[mapping("model", "Model", None)],
|
||||
"model",
|
||||
&mut doc,
|
||||
)
|
||||
.unwrap();
|
||||
let generated = path(&doc);
|
||||
prepare_model_catalog(&linked_home, "provider", &[], "model", &mut doc).unwrap();
|
||||
assert!(doc.get(CATALOG_FIELD).is_none());
|
||||
fs::remove_file(&generated).unwrap();
|
||||
let custom = links.0.join("custom.json");
|
||||
fs::write(&custom, "user catalog").unwrap();
|
||||
symlink(&custom, &generated).unwrap();
|
||||
doc[CATALOG_FIELD] = value(generated.to_string_lossy().to_string());
|
||||
prepare_model_catalog(&fixture.0, "provider", &[], "model", &mut doc).unwrap();
|
||||
assert!(doc.get(CATALOG_FIELD).is_some());
|
||||
assert_eq!(fs::read_to_string(custom).unwrap(), "user catalog");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,946 @@
|
||||
//! Read-only subscription quota lookup for one saved official login. Credentials
|
||||
//! stay in native code; neither network errors nor response bodies are logged.
|
||||
|
||||
use super::official_profiles::{
|
||||
official_profile_quota_credentials, OfficialProfileQuotaCredentials,
|
||||
};
|
||||
use crate::error::{CodexxError, Result};
|
||||
use crate::live_config::acquire_live_config_lock;
|
||||
use crate::remote::ensure_crypto_provider;
|
||||
use chrono::{DateTime, Duration as ChronoDuration, Utc};
|
||||
use reqwest::blocking::Client;
|
||||
use reqwest::header::{HeaderMap, HeaderValue, ACCEPT, AUTHORIZATION, USER_AGENT};
|
||||
use reqwest::StatusCode;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::io::Read;
|
||||
use std::path::Path;
|
||||
use std::sync::Mutex;
|
||||
use std::time::Duration;
|
||||
|
||||
const QUOTA_URL: &str = "https://chatgpt.com/backend-api/wham/usage";
|
||||
const RESET_CREDITS_URL: &str = "https://chatgpt.com/backend-api/wham/rate-limit-reset-credits";
|
||||
const MAX_RESPONSE_BYTES: usize = 1024 * 1024;
|
||||
// The file lock is nonblocking. Concurrent quota and credit queries share this
|
||||
// brief gate while reading credentials, so they cannot fail each other's read.
|
||||
// Neither this gate nor the file lock is held during a network request.
|
||||
static QUERY_CREDENTIAL_LOCK: Mutex<()> = Mutex::new(());
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum OfficialQuery {
|
||||
Quota,
|
||||
ResetCredits,
|
||||
}
|
||||
|
||||
impl OfficialQuery {
|
||||
fn url(self) -> &'static str {
|
||||
match self {
|
||||
Self::Quota => QUOTA_URL,
|
||||
Self::ResetCredits => RESET_CREDITS_URL,
|
||||
}
|
||||
}
|
||||
|
||||
fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Quota => "额度",
|
||||
Self::ResetCredits => "重置次数",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct OfficialResetCreditsSnapshot {
|
||||
profile_id: String,
|
||||
available_count: u32,
|
||||
checked_at: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct RawResetCreditsResponse {
|
||||
// Required and unsigned: unknown or invalid counts must never look like 0.
|
||||
available_count: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct OfficialQuotaSnapshot {
|
||||
profile_id: String,
|
||||
email: Option<String>,
|
||||
plan_type: Option<String>,
|
||||
limits: Vec<OfficialQuotaLimit>,
|
||||
checked_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct OfficialQuotaLimit {
|
||||
id: String,
|
||||
name: Option<String>,
|
||||
allowed: Option<bool>,
|
||||
limit_reached: Option<bool>,
|
||||
windows: Vec<OfficialQuotaWindow>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct OfficialQuotaWindow {
|
||||
id: String,
|
||||
window_seconds: Option<i64>,
|
||||
used_percent: Option<f64>,
|
||||
remaining_percent: Option<f64>,
|
||||
resets_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Default, Deserialize)]
|
||||
struct RawWindow {
|
||||
limit_window_seconds: Option<i64>,
|
||||
used_percent: Option<f64>,
|
||||
reset_at: Option<i64>,
|
||||
reset_after_seconds: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Default, Deserialize)]
|
||||
struct RawLimit {
|
||||
allowed: Option<bool>,
|
||||
limit_reached: Option<bool>,
|
||||
primary_window: Option<RawWindow>,
|
||||
secondary_window: Option<RawWindow>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct RawAdditionalLimit {
|
||||
limit_name: Option<String>,
|
||||
name: Option<String>,
|
||||
metered_feature: Option<String>,
|
||||
rate_limit: Option<RawLimit>,
|
||||
#[serde(flatten)]
|
||||
direct_limit: RawLimit,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct RawQuotaResponse {
|
||||
account_id: Option<String>,
|
||||
email: Option<String>,
|
||||
plan_type: Option<String>,
|
||||
rate_limit: Option<RawLimit>,
|
||||
code_review_rate_limit: Option<RawLimit>,
|
||||
additional_rate_limits: Option<Vec<Option<RawAdditionalLimit>>>,
|
||||
}
|
||||
|
||||
fn config_error(message: &str) -> CodexxError {
|
||||
CodexxError::Config(message.to_owned())
|
||||
}
|
||||
|
||||
fn nonempty(value: Option<&str>) -> Option<&str> {
|
||||
value.map(str::trim).filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
fn display_email(value: Option<&str>) -> Option<String> {
|
||||
nonempty(value)
|
||||
.filter(|email| email.len() <= 320 && email.contains('@'))
|
||||
.filter(|email| {
|
||||
!email
|
||||
.chars()
|
||||
.any(|character| character.is_control() || character.is_whitespace())
|
||||
})
|
||||
.map(str::to_owned)
|
||||
}
|
||||
|
||||
fn quota_headers(credentials: &OfficialProfileQuotaCredentials) -> Result<HeaderMap> {
|
||||
let token = nonempty(Some(&credentials.access_token))
|
||||
.ok_or_else(|| config_error("此官方配置尚未登录,请先在 Codex 中登录"))?;
|
||||
let mut authorization = HeaderValue::from_str(&format!("Bearer {token}"))
|
||||
.map_err(|_| config_error("此官方配置的登录凭据无效,请重新登录"))?;
|
||||
authorization.set_sensitive(true);
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(AUTHORIZATION, authorization);
|
||||
headers.insert(ACCEPT, HeaderValue::from_static("application/json"));
|
||||
headers.insert(USER_AGENT, HeaderValue::from_static("codex-cli"));
|
||||
if let Some(account_id) = nonempty(credentials.account_id.as_deref()) {
|
||||
let mut account = HeaderValue::from_str(account_id)
|
||||
.map_err(|_| config_error("此官方配置的账号标识无效,请重新登录"))?;
|
||||
account.set_sensitive(true);
|
||||
headers.insert("ChatGPT-Account-Id", account);
|
||||
}
|
||||
Ok(headers)
|
||||
}
|
||||
|
||||
fn quota_client() -> Result<Client> {
|
||||
ensure_crypto_provider();
|
||||
Client::builder()
|
||||
.https_only(true)
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.timeout(Duration::from_secs(15))
|
||||
.connect_timeout(Duration::from_secs(5))
|
||||
.build()
|
||||
.map_err(|_| config_error("官方账号查询客户端初始化失败"))
|
||||
}
|
||||
|
||||
fn check_status(status: StatusCode, query: OfficialQuery) -> Result<()> {
|
||||
if status.is_success() {
|
||||
return Ok(());
|
||||
}
|
||||
let label = query.label();
|
||||
let message = match status.as_u16() {
|
||||
401 => format!("官方登录已失效,请重新登录此账号后查询{label}(HTTP 401)"),
|
||||
403 => format!("无法访问官方{label}服务(HTTP 403),请稍后重试或检查网络"),
|
||||
429 => format!("官方{label}查询过于频繁,请稍后重试(HTTP 429)"),
|
||||
300..=399 => format!("官方{label}接口发生重定向,已停止请求,请稍后重试"),
|
||||
code @ 500..=599 => format!("官方{label}服务暂不可用(HTTP {code}),请稍后重试"),
|
||||
code => format!("官方{label}查询失败(HTTP {code}),请稍后重试"),
|
||||
};
|
||||
Err(CodexxError::Config(message))
|
||||
}
|
||||
|
||||
fn read_bounded_body(reader: impl Read, content_length: Option<u64>) -> Result<Vec<u8>> {
|
||||
if content_length.is_some_and(|length| length > MAX_RESPONSE_BYTES as u64) {
|
||||
return Err(config_error("官方查询响应过大,已停止读取"));
|
||||
}
|
||||
let mut bytes = Vec::new();
|
||||
reader
|
||||
.take(MAX_RESPONSE_BYTES as u64 + 1)
|
||||
.read_to_end(&mut bytes)
|
||||
.map_err(|_| config_error("读取官方查询响应失败,请稍后重试"))?;
|
||||
if bytes.len() > MAX_RESPONSE_BYTES {
|
||||
return Err(config_error("官方查询响应过大,已停止读取"));
|
||||
}
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
fn window(id: &str, raw: RawWindow, checked_at: DateTime<Utc>) -> OfficialQuotaWindow {
|
||||
let used_percent = raw
|
||||
.used_percent
|
||||
.filter(|value| value.is_finite())
|
||||
.map(|value| value.clamp(0.0, 100.0));
|
||||
let resets_at = raw
|
||||
.reset_at
|
||||
.and_then(|seconds| DateTime::from_timestamp(seconds, 0))
|
||||
.or_else(|| {
|
||||
raw.reset_after_seconds
|
||||
.filter(|seconds| *seconds >= 0)
|
||||
.and_then(ChronoDuration::try_seconds)
|
||||
.and_then(|duration| checked_at.checked_add_signed(duration))
|
||||
})
|
||||
.map(|time| time.to_rfc3339());
|
||||
OfficialQuotaWindow {
|
||||
id: id.to_owned(),
|
||||
window_seconds: raw.limit_window_seconds.filter(|seconds| *seconds > 0),
|
||||
used_percent,
|
||||
remaining_percent: used_percent.map(|value| (100.0 - value).clamp(0.0, 100.0)),
|
||||
resets_at,
|
||||
}
|
||||
}
|
||||
|
||||
fn limit(
|
||||
id: String,
|
||||
name: Option<String>,
|
||||
raw: RawLimit,
|
||||
checked_at: DateTime<Utc>,
|
||||
) -> OfficialQuotaLimit {
|
||||
let windows = [
|
||||
("primary", raw.primary_window),
|
||||
("secondary", raw.secondary_window),
|
||||
]
|
||||
.into_iter()
|
||||
.filter_map(|(id, raw)| raw.map(|raw| window(id, raw, checked_at)))
|
||||
.collect();
|
||||
OfficialQuotaLimit {
|
||||
id,
|
||||
name,
|
||||
allowed: raw.allowed,
|
||||
limit_reached: raw.limit_reached,
|
||||
windows,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_quota_response(
|
||||
bytes: &[u8],
|
||||
profile_id: &str,
|
||||
credentials: &OfficialProfileQuotaCredentials,
|
||||
checked_at: DateTime<Utc>,
|
||||
) -> Result<OfficialQuotaSnapshot> {
|
||||
let raw: RawQuotaResponse = serde_json::from_slice(bytes)
|
||||
.map_err(|_| config_error("官方额度响应格式无法识别,请稍后重试"))?;
|
||||
if nonempty(raw.account_id.as_deref())
|
||||
.zip(nonempty(credentials.account_id.as_deref()))
|
||||
.is_some_and(|(actual, expected)| actual != expected)
|
||||
{
|
||||
return Err(config_error(
|
||||
"额度响应与所选官方账号不一致,请重新登录此配置后重试",
|
||||
));
|
||||
}
|
||||
let mut limits = Vec::new();
|
||||
if let Some(raw) = raw.rate_limit {
|
||||
limits.push(limit("main".to_owned(), None, raw, checked_at));
|
||||
}
|
||||
if let Some(raw) = raw.code_review_rate_limit {
|
||||
limits.push(limit("code_review".to_owned(), None, raw, checked_at));
|
||||
}
|
||||
for (index, additional) in raw
|
||||
.additional_rate_limits
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
let Some(additional) = additional else {
|
||||
continue;
|
||||
};
|
||||
let name = nonempty(additional.limit_name.as_deref())
|
||||
.or_else(|| nonempty(additional.name.as_deref()))
|
||||
.or_else(|| nonempty(additional.metered_feature.as_deref()))
|
||||
.map(str::to_owned);
|
||||
limits.push(limit(
|
||||
format!("additional-{index}"),
|
||||
name,
|
||||
additional.rate_limit.unwrap_or(additional.direct_limit),
|
||||
checked_at,
|
||||
));
|
||||
}
|
||||
Ok(OfficialQuotaSnapshot {
|
||||
profile_id: profile_id.to_owned(),
|
||||
email: display_email(raw.email.as_deref())
|
||||
.or_else(|| display_email(credentials.email.as_deref())),
|
||||
plan_type: nonempty(raw.plan_type.as_deref()).map(str::to_owned),
|
||||
limits,
|
||||
checked_at: checked_at.to_rfc3339(),
|
||||
})
|
||||
}
|
||||
|
||||
fn credential_fingerprint(credentials: &OfficialProfileQuotaCredentials) -> [u8; 32] {
|
||||
let mut digest = Sha256::new();
|
||||
// Length-delimit components so two different identities cannot have the
|
||||
// same concatenated input. Only this digest is compared after the request.
|
||||
for value in [
|
||||
Some(credentials.access_token.as_str()),
|
||||
credentials.account_id.as_deref(),
|
||||
credentials.email.as_deref(),
|
||||
] {
|
||||
let bytes = value.unwrap_or_default().as_bytes();
|
||||
digest.update((bytes.len() as u64).to_le_bytes());
|
||||
digest.update(bytes);
|
||||
}
|
||||
digest.finalize().into()
|
||||
}
|
||||
|
||||
fn parse_reset_credits_response(
|
||||
bytes: &[u8],
|
||||
profile_id: &str,
|
||||
checked_at: DateTime<Utc>,
|
||||
) -> Result<OfficialResetCreditsSnapshot> {
|
||||
let raw: RawResetCreditsResponse = serde_json::from_slice(bytes)
|
||||
.map_err(|_| config_error("官方重置次数响应格式无法识别,请稍后重试"))?;
|
||||
Ok(OfficialResetCreditsSnapshot {
|
||||
profile_id: profile_id.to_owned(),
|
||||
available_count: raw.available_count,
|
||||
checked_at: checked_at.to_rfc3339(),
|
||||
})
|
||||
}
|
||||
|
||||
fn ensure_same_credentials(
|
||||
expected: [u8; 32],
|
||||
current: &OfficialProfileQuotaCredentials,
|
||||
) -> Result<()> {
|
||||
if expected != credential_fingerprint(current) {
|
||||
return Err(config_error("此官方账号的登录信息已变化,请重新刷新"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn fetch_official_response(
|
||||
query: OfficialQuery,
|
||||
credentials: &OfficialProfileQuotaCredentials,
|
||||
) -> Result<Vec<u8>> {
|
||||
let headers = quota_headers(credentials)?;
|
||||
let label = query.label();
|
||||
let response = quota_client()?
|
||||
.get(query.url())
|
||||
.headers(headers)
|
||||
.send()
|
||||
.map_err(|error| {
|
||||
if error.is_timeout() {
|
||||
config_error(&format!("官方{label}查询超时,请检查网络后重试"))
|
||||
} else if error.is_connect() {
|
||||
config_error(&format!("无法连接官方{label}接口,请检查网络或代理设置"))
|
||||
} else {
|
||||
config_error(&format!("官方{label}网络请求失败,请稍后重试"))
|
||||
}
|
||||
})?;
|
||||
check_status(response.status(), query)?;
|
||||
let length = response.content_length();
|
||||
read_bounded_body(response, length)
|
||||
}
|
||||
|
||||
fn read_query_credentials(
|
||||
codex_dir: &Path,
|
||||
profile_id: &str,
|
||||
) -> Result<OfficialProfileQuotaCredentials> {
|
||||
let _query_guard = QUERY_CREDENTIAL_LOCK
|
||||
.lock()
|
||||
.map_err(|_| config_error("官方账号查询暂不可用,请重试"))?;
|
||||
let _file_guard = acquire_live_config_lock(codex_dir)
|
||||
.map_err(|_| config_error("官方配置正在修改,请稍后刷新"))?;
|
||||
official_profile_quota_credentials(codex_dir, profile_id)
|
||||
}
|
||||
|
||||
fn read_official_snapshot<T>(
|
||||
codex_dir: &Path,
|
||||
profile_id: &str,
|
||||
fetch: impl FnOnce(&OfficialProfileQuotaCredentials) -> Result<Vec<u8>>,
|
||||
parse: impl FnOnce(&[u8], &str, &OfficialProfileQuotaCredentials, DateTime<Utc>) -> Result<T>,
|
||||
) -> Result<T> {
|
||||
let credentials = read_query_credentials(codex_dir, profile_id)?;
|
||||
let fingerprint = credential_fingerprint(&credentials);
|
||||
let bytes = fetch(&credentials)?;
|
||||
let snapshot = parse(&bytes, profile_id, &credentials, Utc::now())?;
|
||||
let current = read_query_credentials(codex_dir, profile_id)
|
||||
.map_err(|_| config_error("此官方配置已变化或被删除,请重新选择账号后查询"))?;
|
||||
ensure_same_credentials(fingerprint, ¤t)?;
|
||||
Ok(snapshot)
|
||||
}
|
||||
|
||||
pub(crate) fn get_official_profile_quota_inner(
|
||||
config_dir: Option<String>,
|
||||
profile_id: String,
|
||||
) -> Result<OfficialQuotaSnapshot> {
|
||||
let codex_dir = crate::resolve_codex_dir(config_dir)?;
|
||||
read_official_snapshot(
|
||||
&codex_dir,
|
||||
profile_id.trim(),
|
||||
|credentials| fetch_official_response(OfficialQuery::Quota, credentials),
|
||||
parse_quota_response,
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn get_official_profile_reset_credits_inner(
|
||||
config_dir: Option<String>,
|
||||
profile_id: String,
|
||||
) -> Result<OfficialResetCreditsSnapshot> {
|
||||
let codex_dir = crate::resolve_codex_dir(config_dir)?;
|
||||
read_official_snapshot(
|
||||
&codex_dir,
|
||||
profile_id.trim(),
|
||||
|credentials| fetch_official_response(OfficialQuery::ResetCredits, credentials),
|
||||
|bytes, profile_id, _, checked_at| {
|
||||
parse_reset_credits_response(bytes, profile_id, checked_at)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn credentials(account: Option<&str>) -> OfficialProfileQuotaCredentials {
|
||||
OfficialProfileQuotaCredentials {
|
||||
access_token: "synthetic-access-token".to_owned(),
|
||||
account_id: account.map(str::to_owned),
|
||||
email: Some("saved@example.test".to_owned()),
|
||||
}
|
||||
}
|
||||
|
||||
fn checked_at() -> DateTime<Utc> {
|
||||
DateTime::parse_from_rfc3339("2026-09-09T03:00:00Z")
|
||||
.unwrap()
|
||||
.with_timezone(&Utc)
|
||||
}
|
||||
|
||||
fn parse(value: serde_json::Value, account: Option<&str>) -> OfficialQuotaSnapshot {
|
||||
parse_quota_response(
|
||||
&serde_json::to_vec(&value).unwrap(),
|
||||
"official-profile-a",
|
||||
&credentials(account),
|
||||
checked_at(),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pro_primary_window_can_be_weekly_and_account_id_can_be_absent() {
|
||||
let snapshot = parse(
|
||||
json!({
|
||||
"plan_type":"pro", "email":"active@example.test", "account_id":"server-account",
|
||||
"rate_limit":{"allowed":true,"limit_reached":false,"primary_window":{"limit_window_seconds":604800,"used_percent":37.5,"reset_after_seconds":600}},
|
||||
"additional_rate_limits":null,"code_review_rate_limit":null
|
||||
}),
|
||||
None,
|
||||
);
|
||||
assert_eq!(snapshot.profile_id, "official-profile-a");
|
||||
assert_eq!(snapshot.email.as_deref(), Some("active@example.test"));
|
||||
assert_eq!(snapshot.plan_type.as_deref(), Some("pro"));
|
||||
assert_eq!(snapshot.limits.len(), 1);
|
||||
let limit = &snapshot.limits[0];
|
||||
assert_eq!(limit.id, "main");
|
||||
assert_eq!(limit.allowed, Some(true));
|
||||
assert_eq!(limit.windows[0].id, "primary");
|
||||
assert_eq!(limit.windows[0].window_seconds, Some(604800));
|
||||
assert_eq!(limit.windows[0].remaining_percent, Some(62.5));
|
||||
assert_eq!(
|
||||
limit.windows[0].resets_at.as_deref(),
|
||||
Some("2026-09-09T03:10:00+00:00")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn team_two_windows_preserve_real_durations_and_absolute_reset_priority() {
|
||||
let reset = checked_at().timestamp() + 3600;
|
||||
let snapshot = parse(
|
||||
json!({
|
||||
"account_id":"team-a","plan_type":"team",
|
||||
"rate_limit":{"allowed":false,"limit_reached":true,
|
||||
"primary_window":{"limit_window_seconds":18000,"used_percent":100,"reset_at":reset,"reset_after_seconds":20},
|
||||
"secondary_window":{"limit_window_seconds":604800,"used_percent":23.25}}
|
||||
}),
|
||||
Some("team-a"),
|
||||
);
|
||||
let limit = &snapshot.limits[0];
|
||||
assert_eq!(limit.windows.len(), 2);
|
||||
assert_eq!(limit.limit_reached, Some(true));
|
||||
assert_eq!(limit.windows[0].window_seconds, Some(18000));
|
||||
assert_eq!(limit.windows[0].remaining_percent, Some(0.0));
|
||||
assert_eq!(
|
||||
limit.windows[0].resets_at.as_deref(),
|
||||
Some("2026-09-09T04:00:00+00:00")
|
||||
);
|
||||
assert_eq!(limit.windows[1].window_seconds, Some(604800));
|
||||
assert_eq!(limit.windows[1].remaining_percent, Some(76.75));
|
||||
assert!(limit.windows[1].resets_at.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn code_review_and_additional_features_remain_separate_limits() {
|
||||
let snapshot = parse(
|
||||
json!({
|
||||
"rate_limit":null,
|
||||
"code_review_rate_limit":{"allowed":true,"primary_window":{"used_percent":10,"limit_window_seconds":604800}},
|
||||
"additional_rate_limits":[null,
|
||||
{"limit_name":"Codex Spark","metered_feature":"codex_bengal","rate_limit":{"allowed":true,"primary_window":{"used_percent":40,"limit_window_seconds":18000}}},
|
||||
{"metered_feature":"future_feature","allowed":false,"limit_reached":true,"primary_window":{"used_percent":100,"limit_window_seconds":86400}}]
|
||||
}),
|
||||
None,
|
||||
);
|
||||
assert_eq!(snapshot.limits.len(), 3);
|
||||
assert_eq!(snapshot.limits[0].id, "code_review");
|
||||
assert_eq!(snapshot.limits[1].id, "additional-1");
|
||||
assert_eq!(snapshot.limits[1].name.as_deref(), Some("Codex Spark"));
|
||||
assert_eq!(snapshot.limits[2].name.as_deref(), Some("future_feature"));
|
||||
assert_eq!(snapshot.limits[2].allowed, Some(false));
|
||||
assert_eq!(snapshot.limits[2].windows[0].window_seconds, Some(86400));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_usage_is_unknown_instead_of_zero_or_full_remaining() {
|
||||
let snapshot = parse(
|
||||
json!({"rate_limit":{"primary_window":{"limit_window_seconds":18000}}}),
|
||||
None,
|
||||
);
|
||||
let window = &snapshot.limits[0].windows[0];
|
||||
assert!(window.used_percent.is_none());
|
||||
assert!(window.remaining_percent.is_none());
|
||||
assert!(snapshot.limits[0].allowed.is_none());
|
||||
assert!(snapshot.limits[0].limit_reached.is_none());
|
||||
let empty = parse(json!({}), None);
|
||||
assert!(empty.limits.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finite_percentages_are_clamped_and_nonfinite_values_stay_unknown() {
|
||||
for (used, expected) in [
|
||||
(-20.0, Some(0.0)),
|
||||
(130.0, Some(100.0)),
|
||||
(f64::NAN, None),
|
||||
(f64::INFINITY, None),
|
||||
] {
|
||||
let value = window(
|
||||
"primary",
|
||||
RawWindow {
|
||||
used_percent: Some(used),
|
||||
..Default::default()
|
||||
},
|
||||
checked_at(),
|
||||
);
|
||||
assert_eq!(value.used_percent, expected);
|
||||
assert_eq!(value.remaining_percent, expected.map(|used| 100.0 - used));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_reset_and_window_numbers_do_not_panic_or_invent_dates() {
|
||||
let value = window(
|
||||
"primary",
|
||||
RawWindow {
|
||||
limit_window_seconds: Some(-1),
|
||||
reset_at: Some(i64::MAX),
|
||||
reset_after_seconds: Some(i64::MAX),
|
||||
..Default::default()
|
||||
},
|
||||
checked_at(),
|
||||
);
|
||||
assert!(value.window_seconds.is_none());
|
||||
assert!(value.resets_at.is_none());
|
||||
let negative = window(
|
||||
"primary",
|
||||
RawWindow {
|
||||
reset_after_seconds: Some(-10),
|
||||
..Default::default()
|
||||
},
|
||||
checked_at(),
|
||||
);
|
||||
assert!(negative.resets_at.is_none());
|
||||
let now = window(
|
||||
"primary",
|
||||
RawWindow {
|
||||
reset_after_seconds: Some(0),
|
||||
..Default::default()
|
||||
},
|
||||
checked_at(),
|
||||
);
|
||||
assert_eq!(now.resets_at.as_deref(), Some("2026-09-09T03:00:00+00:00"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn different_nonempty_response_account_is_rejected_without_identifiers_in_error() {
|
||||
let error = parse_quota_response(
|
||||
br#"{"account_id":"other-private-account"}"#,
|
||||
"official-profile-a",
|
||||
&credentials(Some("selected-private-account")),
|
||||
checked_at(),
|
||||
)
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(error.contains("不一致"));
|
||||
assert!(!error.contains("private-account"));
|
||||
assert!(!error.contains("synthetic-access-token"));
|
||||
assert!(
|
||||
parse(json!({"account_id":null}), Some("selected-private-account"))
|
||||
.limits
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn email_uses_response_first_then_safe_saved_display_metadata() {
|
||||
assert_eq!(
|
||||
parse(json!({"email":"response@example.test"}), None)
|
||||
.email
|
||||
.as_deref(),
|
||||
Some("response@example.test")
|
||||
);
|
||||
assert_eq!(
|
||||
parse(json!({"email":"bad\n@example.test"}), None)
|
||||
.email
|
||||
.as_deref(),
|
||||
Some("saved@example.test")
|
||||
);
|
||||
assert_eq!(
|
||||
parse(json!({}), None).email.as_deref(),
|
||||
Some("saved@example.test")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_has_only_fixed_https_endpoint_and_sensitive_auth_headers() {
|
||||
let request = quota_client()
|
||||
.unwrap()
|
||||
.get(QUOTA_URL)
|
||||
.headers(quota_headers(&credentials(Some("account-a"))).unwrap())
|
||||
.build()
|
||||
.unwrap();
|
||||
assert_eq!(request.method(), reqwest::Method::GET);
|
||||
assert_eq!(request.url().as_str(), QUOTA_URL);
|
||||
assert_eq!(request.url().host_str(), Some("chatgpt.com"));
|
||||
assert_eq!(request.url().scheme(), "https");
|
||||
assert!(request.headers()[AUTHORIZATION].is_sensitive());
|
||||
assert_eq!(
|
||||
request.headers()[AUTHORIZATION],
|
||||
"Bearer synthetic-access-token"
|
||||
);
|
||||
assert!(request.headers()["chatgpt-account-id"].is_sensitive());
|
||||
assert_eq!(request.headers()["chatgpt-account-id"], "account-a");
|
||||
assert_eq!(request.headers()[ACCEPT], "application/json");
|
||||
assert_eq!(request.headers()[USER_AGENT], "codex-cli");
|
||||
assert!(!format!("{request:?}").contains("synthetic-access-token"));
|
||||
assert!(!quota_headers(&credentials(None))
|
||||
.unwrap()
|
||||
.contains_key("chatgpt-account-id"));
|
||||
let credits_request = quota_client()
|
||||
.unwrap()
|
||||
.get(OfficialQuery::ResetCredits.url())
|
||||
.headers(quota_headers(&credentials(Some("account-a"))).unwrap())
|
||||
.build()
|
||||
.unwrap();
|
||||
assert_eq!(credits_request.method(), reqwest::Method::GET);
|
||||
assert_eq!(credits_request.url().as_str(), RESET_CREDITS_URL);
|
||||
assert_eq!(credits_request.url().scheme(), "https");
|
||||
assert_eq!(credits_request.url().host_str(), Some("chatgpt.com"));
|
||||
assert!(credits_request.headers()[AUTHORIZATION].is_sensitive());
|
||||
assert!(credits_request.headers()["chatgpt-account-id"].is_sensitive());
|
||||
assert!(!format!("{credits_request:?}").contains("synthetic-access-token"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_or_injected_credentials_fail_without_revealing_content() {
|
||||
let mut empty = credentials(None);
|
||||
empty.access_token = " ".to_owned();
|
||||
assert!(quota_headers(&empty)
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("尚未登录"));
|
||||
let mut invalid = credentials(None);
|
||||
invalid.access_token = "private-secret\r\nInjected: true".to_owned();
|
||||
let error = quota_headers(&invalid).unwrap_err().to_string();
|
||||
assert!(!error.contains("private-secret"));
|
||||
assert!(!error.contains("Injected"));
|
||||
invalid = credentials(Some("private-account\r\nInjected: true"));
|
||||
let error = quota_headers(&invalid).unwrap_err().to_string();
|
||||
assert!(!error.contains("private-account"));
|
||||
assert!(error.contains("账号标识无效"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn error_statuses_have_safe_actionable_messages() {
|
||||
for query in [OfficialQuery::Quota, OfficialQuery::ResetCredits] {
|
||||
assert!(check_status(StatusCode::OK, query).is_ok());
|
||||
for (status, expected) in [
|
||||
(401, "重新登录"),
|
||||
(403, "检查网络"),
|
||||
(429, "过于频繁"),
|
||||
(302, "重定向"),
|
||||
(503, "HTTP 503"),
|
||||
] {
|
||||
let error = check_status(StatusCode::from_u16(status).unwrap(), query)
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(error.contains(expected));
|
||||
assert!(error.contains(query.label()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn body_limit_applies_even_without_or_with_false_content_length() {
|
||||
assert_eq!(read_bounded_body(&b"{}"[..], Some(2)).unwrap(), b"{}");
|
||||
let large = vec![b'x'; MAX_RESPONSE_BYTES + 1];
|
||||
for content_length in [None, Some(1), Some(large.len() as u64)] {
|
||||
assert!(read_bounded_body(&large[..], content_length)
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("响应过大"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_response_never_enters_error_text() {
|
||||
let error = parse_quota_response(
|
||||
b"server accidentally returned private-secret-token",
|
||||
"official-profile-a",
|
||||
&credentials(None),
|
||||
checked_at(),
|
||||
)
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(error.contains("格式无法识别"));
|
||||
assert!(!error.contains("private-secret-token"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn credential_change_rejects_stale_result_for_token_account_or_email() {
|
||||
let original = credentials(Some("account-a"));
|
||||
let fingerprint = credential_fingerprint(&original);
|
||||
assert!(ensure_same_credentials(fingerprint, &credentials(Some("account-a"))).is_ok());
|
||||
for field in ["token", "account", "email"] {
|
||||
let mut changed = credentials(Some("account-a"));
|
||||
match field {
|
||||
"token" => changed.access_token = "private-replacement-token".into(),
|
||||
"account" => changed.account_id = Some("account-b".into()),
|
||||
_ => changed.email = Some("changed@example.test".into()),
|
||||
}
|
||||
let error = ensure_same_credentials(fingerprint, &changed)
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(error.contains("登录信息已变化"));
|
||||
assert!(!error.contains("private-replacement-token"));
|
||||
assert!(!error.contains("account-b"));
|
||||
assert!(!error.contains("changed@example.test"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serialized_snapshot_contains_only_display_fields_and_no_credentials() {
|
||||
let snapshot = parse(
|
||||
json!({"plan_type":"team","rate_limit":{"primary_window":{"used_percent":40,"limit_window_seconds":18000}}}),
|
||||
Some("account-a"),
|
||||
);
|
||||
let value = serde_json::to_value(&snapshot).unwrap();
|
||||
assert_eq!(value["profileId"], "official-profile-a");
|
||||
assert_eq!(value["planType"], "team");
|
||||
assert_eq!(value["limits"][0]["windows"][0]["remainingPercent"], 60.0);
|
||||
let serialized = value.to_string();
|
||||
assert!(!serialized.contains("access_token"));
|
||||
assert!(!serialized.contains("synthetic-access-token"));
|
||||
assert!(!serialized.contains("account-a"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_credits_uses_available_count_and_returns_no_credit_details() {
|
||||
let raw = json!({
|
||||
"available_count": 3,
|
||||
"total_earned_count": 0,
|
||||
"credits": [{"id":"private-credit-id", "status":"available", "title":"private-credit-title"}],
|
||||
"immediate_reset_purchase_eligible": true
|
||||
});
|
||||
let snapshot = parse_reset_credits_response(
|
||||
&serde_json::to_vec(&raw).unwrap(),
|
||||
"official-profile-a",
|
||||
checked_at(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
serde_json::to_value(snapshot).unwrap(),
|
||||
json!({
|
||||
"profileId":"official-profile-a",
|
||||
"availableCount":3,
|
||||
"checkedAt":"2026-09-09T03:00:00+00:00"
|
||||
})
|
||||
);
|
||||
let zero = parse_reset_credits_response(
|
||||
br#"{"available_count":0,"credits":[{"status":"available"}]}"#,
|
||||
"official-profile-a",
|
||||
checked_at(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(zero.available_count, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_credits_unknown_or_invalid_counts_are_not_reported_as_zero() {
|
||||
for raw in [
|
||||
"{}",
|
||||
"null",
|
||||
r#"{"available_count":null}"#,
|
||||
r#"{"available_count":-1}"#,
|
||||
r#"{"available_count":1.5}"#,
|
||||
r#"{"available_count":"3"}"#,
|
||||
r#"{"available_count":true}"#,
|
||||
r#"{"available_count":4294967296}"#,
|
||||
"private-malformed-response",
|
||||
] {
|
||||
let error =
|
||||
parse_reset_credits_response(raw.as_bytes(), "official-profile-a", checked_at())
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(error.contains("重置次数响应格式无法识别"));
|
||||
assert!(!error.contains("private-malformed-response"));
|
||||
}
|
||||
}
|
||||
|
||||
fn query_fixture(label: &str) -> std::path::PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"codex-x-official-query-{label}-{}",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
crate::file_io::write_text(
|
||||
&crate::config_path(&dir),
|
||||
"model_provider = \"openai\"\nmodel = \"synthetic-model\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
crate::file_io::write_json(
|
||||
&crate::auth_path(&dir),
|
||||
&json!({
|
||||
"auth_mode":"chatgpt", "tokens":{
|
||||
"access_token":"synthetic-query-token", "account_id":"synthetic-account"
|
||||
}
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quota_and_reset_queries_overlap_without_holding_the_live_config_lock() {
|
||||
use super::super::official_profiles::DEFAULT_OFFICIAL_PROFILE_ID;
|
||||
use std::sync::mpsc;
|
||||
|
||||
let _db_guard = crate::app_db::test_db_guard();
|
||||
let dir = query_fixture("parallel");
|
||||
// Initialize the test database before starting the two independent reads.
|
||||
read_query_credentials(&dir, DEFAULT_OFFICIAL_PROFILE_ID).unwrap();
|
||||
let (started_tx, started_rx) = mpsc::channel();
|
||||
let (quota_release_tx, quota_release_rx) = mpsc::channel();
|
||||
let (credits_release_tx, credits_release_rx) = mpsc::channel();
|
||||
std::thread::scope(|scope| {
|
||||
let quota_started = started_tx.clone();
|
||||
let quota_dir = &dir;
|
||||
let quota = scope.spawn(move || {
|
||||
read_official_snapshot(
|
||||
quota_dir,
|
||||
DEFAULT_OFFICIAL_PROFILE_ID,
|
||||
|_| {
|
||||
quota_started.send("quota").unwrap();
|
||||
quota_release_rx
|
||||
.recv_timeout(Duration::from_secs(5))
|
||||
.unwrap();
|
||||
Ok(b"{}".to_vec())
|
||||
},
|
||||
parse_quota_response,
|
||||
)
|
||||
});
|
||||
let credits_dir = &dir;
|
||||
let credits = scope.spawn(move || {
|
||||
read_official_snapshot(
|
||||
credits_dir,
|
||||
DEFAULT_OFFICIAL_PROFILE_ID,
|
||||
|_| {
|
||||
started_tx.send("credits").unwrap();
|
||||
credits_release_rx
|
||||
.recv_timeout(Duration::from_secs(5))
|
||||
.unwrap();
|
||||
Ok(br#"{"available_count":3}"#.to_vec())
|
||||
},
|
||||
|bytes, id, _, time| parse_reset_credits_response(bytes, id, time),
|
||||
)
|
||||
});
|
||||
// Both requests must start before either can finish. A lock held over
|
||||
// the simulated network wait would make this fail instead of hang.
|
||||
let first = started_rx.recv_timeout(Duration::from_secs(3)).unwrap();
|
||||
let second = started_rx.recv_timeout(Duration::from_secs(3)).unwrap();
|
||||
assert_ne!(first, second);
|
||||
drop(acquire_live_config_lock(&dir).unwrap());
|
||||
quota_release_tx.send(()).unwrap();
|
||||
credits_release_tx.send(()).unwrap();
|
||||
assert_eq!(
|
||||
quota.join().unwrap().unwrap().profile_id,
|
||||
DEFAULT_OFFICIAL_PROFILE_ID
|
||||
);
|
||||
assert_eq!(credits.join().unwrap().unwrap().available_count, 3);
|
||||
});
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_query_rejects_login_replaced_during_request() {
|
||||
use super::super::official_profiles::DEFAULT_OFFICIAL_PROFILE_ID;
|
||||
|
||||
let _db_guard = crate::app_db::test_db_guard();
|
||||
let dir = query_fixture("changed-login");
|
||||
let error = read_official_snapshot(
|
||||
&dir, DEFAULT_OFFICIAL_PROFILE_ID,
|
||||
|_| {
|
||||
let _guard = acquire_live_config_lock(&dir).unwrap();
|
||||
crate::file_io::write_json(&crate::auth_path(&dir), &json!({
|
||||
"auth_mode":"chatgpt", "tokens":{
|
||||
"access_token":"synthetic-replacement-token", "account_id":"synthetic-account"
|
||||
}
|
||||
})).unwrap();
|
||||
Ok(br#"{"available_count":3}"#.to_vec())
|
||||
},
|
||||
|bytes, id, _, time| parse_reset_credits_response(bytes, id, time),
|
||||
).unwrap_err().to_string();
|
||||
assert!(error.contains("登录信息已变化"));
|
||||
assert!(!error.contains("synthetic-replacement-token"));
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
use crate::error::{CodexxError, Result};
|
||||
use crate::now_rfc3339;
|
||||
use crate::paths::normalized_path_scope;
|
||||
use rusqlite::{params, Connection, OptionalExtension};
|
||||
use std::collections::HashSet;
|
||||
use std::path::Path;
|
||||
|
||||
pub(super) fn selected_provider_id_on_connection(
|
||||
conn: &Connection,
|
||||
codex_dir: &Path,
|
||||
) -> Result<Option<String>> {
|
||||
conn.query_row(
|
||||
"SELECT provider_id
|
||||
FROM active_provider_selections
|
||||
WHERE codex_dir = ?1",
|
||||
[normalized_path_scope(codex_dir)],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.optional()
|
||||
.map_err(|error| CodexxError::Database(error.to_string()))
|
||||
}
|
||||
|
||||
pub(crate) fn remember_active_provider_on_connection(
|
||||
conn: &Connection,
|
||||
codex_dir: &Path,
|
||||
provider_id: &str,
|
||||
) -> Result<()> {
|
||||
let provider_id = provider_id.trim();
|
||||
if provider_id.is_empty() {
|
||||
return Err(CodexxError::Config("当前供应商 ID 不能为空".to_string()));
|
||||
}
|
||||
let exists = conn
|
||||
.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM providers WHERE id = ?1)",
|
||||
[provider_id],
|
||||
|row| row.get::<_, bool>(0),
|
||||
)
|
||||
.map_err(|error| CodexxError::Database(error.to_string()))?;
|
||||
if !exists {
|
||||
return Err(CodexxError::Config(format!(
|
||||
"无法记录当前供应商,未找到 ID {provider_id}"
|
||||
)));
|
||||
}
|
||||
conn.execute(
|
||||
"INSERT INTO active_provider_selections (codex_dir, provider_id, updated_at)
|
||||
VALUES (?1, ?2, ?3)
|
||||
ON CONFLICT(codex_dir) DO UPDATE SET
|
||||
provider_id = excluded.provider_id,
|
||||
updated_at = excluded.updated_at",
|
||||
params![normalized_path_scope(codex_dir), provider_id, now_rfc3339()],
|
||||
)
|
||||
.map_err(|error| CodexxError::Database(error.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn clear_active_provider_on_connection(
|
||||
conn: &Connection,
|
||||
codex_dir: &Path,
|
||||
) -> Result<()> {
|
||||
conn.execute(
|
||||
"DELETE FROM active_provider_selections WHERE codex_dir = ?1",
|
||||
[normalized_path_scope(codex_dir)],
|
||||
)
|
||||
.map_err(|error| CodexxError::Database(error.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn clear_provider_selections_on_connection(
|
||||
conn: &Connection,
|
||||
provider_id: &str,
|
||||
) -> Result<()> {
|
||||
conn.execute(
|
||||
"DELETE FROM active_provider_selections WHERE provider_id = ?1",
|
||||
[provider_id],
|
||||
)
|
||||
.map_err(|error| CodexxError::Database(error.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn reconcile_active_provider_on_connection(
|
||||
conn: &Connection,
|
||||
codex_dir: &Path,
|
||||
candidate_ids: &[String],
|
||||
) -> Result<Option<String>> {
|
||||
let candidates = candidate_ids
|
||||
.iter()
|
||||
.map(|id| id.trim())
|
||||
.filter(|id| !id.is_empty())
|
||||
.collect::<HashSet<_>>();
|
||||
let selected = selected_provider_id_on_connection(conn, codex_dir)?;
|
||||
if let Some(selected) = selected.as_deref() {
|
||||
if candidates.contains(selected) {
|
||||
return Ok(Some(selected.to_string()));
|
||||
}
|
||||
clear_active_provider_on_connection(conn, codex_dir)?;
|
||||
}
|
||||
|
||||
if candidates.len() != 1 {
|
||||
return Ok(None);
|
||||
}
|
||||
let provider_id = candidates
|
||||
.into_iter()
|
||||
.next()
|
||||
.expect("one candidate must be present");
|
||||
remember_active_provider_on_connection(conn, codex_dir, provider_id)?;
|
||||
Ok(Some(provider_id.to_string()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn test_connection() -> Connection {
|
||||
let conn = Connection::open_in_memory().expect("open selection test database");
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE providers (id TEXT PRIMARY KEY);
|
||||
CREATE TABLE active_provider_selections (
|
||||
codex_dir TEXT PRIMARY KEY,
|
||||
provider_id TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
INSERT INTO providers (id) VALUES ('original'), ('copy'), ('other');",
|
||||
)
|
||||
.expect("create selection test schema");
|
||||
conn
|
||||
}
|
||||
|
||||
fn scope(name: &str) -> PathBuf {
|
||||
std::env::temp_dir().join(format!("codex-x-selection-{name}"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_duplicate_does_not_replace_the_remembered_current_provider() {
|
||||
let conn = test_connection();
|
||||
let codex_dir = scope("duplicate");
|
||||
remember_active_provider_on_connection(&conn, &codex_dir, "original")
|
||||
.expect("remember original provider");
|
||||
|
||||
let active = reconcile_active_provider_on_connection(
|
||||
&conn,
|
||||
&codex_dir,
|
||||
&["original".to_string(), "copy".to_string()],
|
||||
)
|
||||
.expect("reconcile duplicate profiles");
|
||||
|
||||
assert_eq!(active.as_deref(), Some("original"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selections_are_independent_for_each_codex_directory() {
|
||||
let conn = test_connection();
|
||||
let first = scope("first");
|
||||
let second = scope("second");
|
||||
remember_active_provider_on_connection(&conn, &first, "original")
|
||||
.expect("remember first selection");
|
||||
remember_active_provider_on_connection(&conn, &second, "copy")
|
||||
.expect("remember second selection");
|
||||
let candidates = ["original".to_string(), "copy".to_string()];
|
||||
|
||||
assert_eq!(
|
||||
reconcile_active_provider_on_connection(&conn, &first, &candidates)
|
||||
.expect("reconcile first selection")
|
||||
.as_deref(),
|
||||
Some("original")
|
||||
);
|
||||
assert_eq!(
|
||||
reconcile_active_provider_on_connection(&conn, &second, &candidates)
|
||||
.expect("reconcile second selection")
|
||||
.as_deref(),
|
||||
Some("copy")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_selection_falls_back_only_when_the_live_match_is_unique() {
|
||||
let conn = test_connection();
|
||||
let codex_dir = scope("stale");
|
||||
remember_active_provider_on_connection(&conn, &codex_dir, "original")
|
||||
.expect("remember stale selection");
|
||||
|
||||
assert_eq!(
|
||||
reconcile_active_provider_on_connection(&conn, &codex_dir, &["other".to_string()])
|
||||
.expect("reconcile unique replacement")
|
||||
.as_deref(),
|
||||
Some("other")
|
||||
);
|
||||
assert_eq!(
|
||||
selected_provider_id_on_connection(&conn, &codex_dir)
|
||||
.expect("read repaired selection")
|
||||
.as_deref(),
|
||||
Some("other")
|
||||
);
|
||||
|
||||
assert!(
|
||||
reconcile_active_provider_on_connection(&conn, &codex_dir, &[])
|
||||
.expect("clear unmatched selection")
|
||||
.is_none()
|
||||
);
|
||||
assert!(selected_provider_id_on_connection(&conn, &codex_dir)
|
||||
.expect("read cleared selection")
|
||||
.is_none());
|
||||
}
|
||||
}
|
||||
@@ -314,7 +314,9 @@ fn active_session_storage_snapshot(
|
||||
|
||||
if cols.contains("model_provider") {
|
||||
let cwd_col = sql_select_column(&cols, "cwd", "NULL");
|
||||
let query = format!("SELECT id, model_provider, {cwd_col} FROM threads");
|
||||
let archived_col = sql_select_column(&cols, "archived", "0");
|
||||
let query =
|
||||
format!("SELECT id, model_provider, {cwd_col}, {archived_col} FROM threads");
|
||||
let mut mismatch_stmt = conn
|
||||
.prepare(&query)
|
||||
.map_err(|error| CodexxError::Database(error.to_string()))?;
|
||||
@@ -324,11 +326,12 @@ fn active_session_storage_snapshot(
|
||||
row.get::<_, String>(0)?,
|
||||
row.get::<_, Option<String>>(1)?,
|
||||
row.get::<_, Option<String>>(2)?,
|
||||
row.get::<_, i64>(3)?,
|
||||
))
|
||||
})
|
||||
.map_err(|error| CodexxError::Database(error.to_string()))?;
|
||||
for row in mismatches {
|
||||
let (id, provider, cwd) =
|
||||
let (id, provider, cwd, archived) =
|
||||
row.map_err(|error| CodexxError::Database(error.to_string()))?;
|
||||
if sqlite_thread_needs_alignment(
|
||||
&rollouts,
|
||||
@@ -338,6 +341,7 @@ fn active_session_storage_snapshot(
|
||||
provider: provider.as_deref(),
|
||||
cwd: cwd.as_deref(),
|
||||
cwd_column: cols.contains("cwd"),
|
||||
archived: archived != 0,
|
||||
},
|
||||
) {
|
||||
snapshot.mismatched_ids.insert(id);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
mod app_server;
|
||||
mod backup;
|
||||
mod catalog;
|
||||
mod delete;
|
||||
mod global_state;
|
||||
mod storage;
|
||||
@@ -14,11 +15,14 @@ pub(crate) use backup::{
|
||||
#[cfg(test)]
|
||||
pub(crate) use delete::{active_session_ids_present, hard_delete_sessions_locally};
|
||||
pub(crate) use delete::{delete_codex_sessions_inner, SessionDeleteInput, SessionDeleteResult};
|
||||
pub(crate) use storage::sqlite_candidate_paths;
|
||||
#[cfg(test)]
|
||||
pub(crate) use storage::{
|
||||
apply_session_changes, list_session_previews, restore_session_changes, scan_rollouts,
|
||||
scan_sqlite, sqlite_session_db_paths,
|
||||
};
|
||||
pub(crate) use storage::{
|
||||
session_project_title, session_titles_by_id, source_value_is_subagent, sqlite_candidate_paths,
|
||||
usage_thread_identities, UsageThreadIdentity,
|
||||
};
|
||||
pub(crate) use sync::{session_sync_status_inner, sync_sessions_provider_inner};
|
||||
pub(crate) use types::{SessionSyncResult, SessionSyncStatus};
|
||||
|
||||
@@ -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,53 +176,86 @@ fn apply_sqlite_updates(
|
||||
pending: &mut [PendingSqliteUpdate],
|
||||
rollouts: &RolloutScan,
|
||||
target_provider: &str,
|
||||
catalog_sources: &HashMap<String, CatalogRepairThread>,
|
||||
syncable_thread_ids: &HashSet<String>,
|
||||
) -> Result<()> {
|
||||
let mut sorted_thread_ids = syncable_thread_ids.iter().collect::<Vec<_>>();
|
||||
sorted_thread_ids.sort();
|
||||
for update in pending.iter_mut() {
|
||||
update
|
||||
.conn
|
||||
.execute(
|
||||
if update.thread_columns.contains("id") && update.thread_columns.contains("model_provider")
|
||||
{
|
||||
let archived_filter = if update.thread_columns.contains("archived") {
|
||||
" AND COALESCE(archived, 0) = 0"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let snapshot_sql = format!(
|
||||
"INSERT INTO temp.codexx_session_rollback
|
||||
(id, model_provider, provider_changed)
|
||||
SELECT id, model_provider, 1 FROM threads
|
||||
WHERE COALESCE(model_provider, '') <> ?1
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
model_provider = excluded.model_provider,
|
||||
provider_changed = 1",
|
||||
[target_provider],
|
||||
)
|
||||
.map_err(|error| CodexxError::Database(error.to_string()))?;
|
||||
update.counts.provider_rows = update
|
||||
.conn
|
||||
.execute(
|
||||
"UPDATE threads SET model_provider = ?1 WHERE COALESCE(model_provider, '') <> ?1",
|
||||
[target_provider],
|
||||
)
|
||||
.map_err(|error| CodexxError::Database(error.to_string()))?;
|
||||
|
||||
if update.columns.contains("id") && update.columns.contains("cwd") {
|
||||
for (thread_id, cwd) in &rollouts.cwd_by_thread_id {
|
||||
(id, model_provider, provider_changed)
|
||||
SELECT id, model_provider, 1 FROM threads
|
||||
WHERE id = ?2 AND COALESCE(model_provider, '') <> ?1{archived_filter}
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
model_provider = excluded.model_provider,
|
||||
provider_changed = 1"
|
||||
);
|
||||
let update_sql = format!(
|
||||
"UPDATE threads SET model_provider = ?1 \
|
||||
WHERE id = ?2 AND COALESCE(model_provider, '') <> ?1{archived_filter}"
|
||||
);
|
||||
for thread_id in &sorted_thread_ids {
|
||||
update
|
||||
.conn
|
||||
.execute(
|
||||
"INSERT INTO temp.codexx_session_rollback
|
||||
(id, cwd, cwd_changed)
|
||||
SELECT id, cwd, 1 FROM threads
|
||||
WHERE id = ?2 AND COALESCE(cwd, '') <> ?1
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
cwd = excluded.cwd,
|
||||
cwd_changed = 1",
|
||||
(cwd, thread_id),
|
||||
)
|
||||
.execute(&snapshot_sql, (target_provider, thread_id))
|
||||
.map_err(|error| CodexxError::Database(error.to_string()))?;
|
||||
update.counts.cwd_rows += update
|
||||
update.counts.provider_rows += update
|
||||
.conn
|
||||
.execute(
|
||||
"UPDATE threads SET cwd = ?1 WHERE id = ?2 AND COALESCE(cwd, '') <> ?1",
|
||||
(cwd, thread_id),
|
||||
)
|
||||
.execute(&update_sql, (target_provider, thread_id))
|
||||
.map_err(|error| CodexxError::Database(error.to_string()))?;
|
||||
}
|
||||
}
|
||||
|
||||
if update.thread_columns.contains("id") && update.thread_columns.contains("cwd") {
|
||||
let archived_filter = if update.thread_columns.contains("archived") {
|
||||
" AND COALESCE(archived, 0) = 0"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let snapshot_sql = format!(
|
||||
"INSERT INTO temp.codexx_session_rollback
|
||||
(id, cwd, cwd_changed)
|
||||
SELECT id, cwd, 1 FROM threads
|
||||
WHERE id = ?2 AND COALESCE(cwd, '') <> ?1{archived_filter}
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
cwd = excluded.cwd,
|
||||
cwd_changed = 1"
|
||||
);
|
||||
let update_sql = format!(
|
||||
"UPDATE threads SET cwd = ?1 \
|
||||
WHERE id = ?2 AND COALESCE(cwd, '') <> ?1{archived_filter}"
|
||||
);
|
||||
for (thread_id, cwd) in &rollouts.cwd_by_thread_id {
|
||||
if !syncable_thread_ids.contains(thread_id) {
|
||||
continue;
|
||||
}
|
||||
update
|
||||
.conn
|
||||
.execute(&snapshot_sql, (cwd, thread_id))
|
||||
.map_err(|error| CodexxError::Database(error.to_string()))?;
|
||||
update.counts.cwd_rows += update
|
||||
.conn
|
||||
.execute(&update_sql, (cwd, thread_id))
|
||||
.map_err(|error| CodexxError::Database(error.to_string()))?;
|
||||
}
|
||||
}
|
||||
let catalog_counts = apply_catalog_updates(
|
||||
&update.conn,
|
||||
&update.catalog_columns,
|
||||
target_provider,
|
||||
catalog_sources,
|
||||
syncable_thread_ids,
|
||||
)?;
|
||||
update.counts.provider_rows += catalog_counts.provider_rows;
|
||||
update.counts.catalog_insert_rows += catalog_counts.inserted_rows;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -269,18 +317,23 @@ fn restore_sqlite_update(
|
||||
)));
|
||||
}
|
||||
|
||||
let mut statements = vec![
|
||||
"UPDATE threads
|
||||
SET model_provider = (
|
||||
SELECT rollback.model_provider
|
||||
FROM temp.codexx_session_rollback AS rollback
|
||||
WHERE rollback.id = threads.id
|
||||
)
|
||||
WHERE id IN (
|
||||
SELECT id FROM temp.codexx_session_rollback WHERE provider_changed = 1
|
||||
)",
|
||||
];
|
||||
if update.columns.contains("cwd") {
|
||||
restore_catalog_updates(&update.conn, &update.catalog_columns)?;
|
||||
let mut statements = Vec::new();
|
||||
if update.thread_columns.contains("id") && update.thread_columns.contains("model_provider")
|
||||
{
|
||||
statements.push(
|
||||
"UPDATE threads
|
||||
SET model_provider = (
|
||||
SELECT rollback.model_provider
|
||||
FROM temp.codexx_session_rollback AS rollback
|
||||
WHERE rollback.id = threads.id
|
||||
)
|
||||
WHERE id IN (
|
||||
SELECT id FROM temp.codexx_session_rollback WHERE provider_changed = 1
|
||||
)",
|
||||
);
|
||||
}
|
||||
if update.thread_columns.contains("id") && update.thread_columns.contains("cwd") {
|
||||
statements.push(
|
||||
"UPDATE threads
|
||||
SET cwd = (
|
||||
@@ -348,6 +401,7 @@ pub(super) fn mutation_error(original: CodexxError, recovery_errors: Vec<String>
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(super) enum MutationPoint {
|
||||
BeforeSqliteLock,
|
||||
AfterSqliteCommit(usize),
|
||||
}
|
||||
|
||||
@@ -361,6 +415,8 @@ pub(super) fn execute_provider_sync_mutation<F>(
|
||||
rollouts: &RolloutScan,
|
||||
pending_sqlite: &mut [PendingSqliteUpdate],
|
||||
target_provider: &str,
|
||||
catalog_sources: &HashMap<String, CatalogRepairThread>,
|
||||
syncable_thread_ids: &HashSet<String>,
|
||||
journal: &mut MutationJournal,
|
||||
hook: &mut F,
|
||||
) -> Result<MutationResult>
|
||||
@@ -370,7 +426,13 @@ 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,
|
||||
syncable_thread_ids,
|
||||
)?;
|
||||
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,
|
||||
}];
|
||||
|
||||
@@ -60,6 +60,7 @@ pub(crate) struct RolloutScan {
|
||||
pub(crate) mismatched_session_meta: usize,
|
||||
pub(crate) changes: Vec<SessionFileChange>,
|
||||
pub(crate) cwd_by_thread_id: HashMap<String, String>,
|
||||
pub(crate) thread_ids: HashSet<String>,
|
||||
pub(crate) mismatched_thread_ids: HashSet<String>,
|
||||
pub(crate) warnings: Vec<String>,
|
||||
pub(crate) scan_failures: Vec<String>,
|
||||
@@ -81,6 +82,9 @@ pub(crate) struct SqliteScan {
|
||||
pub(crate) subagent_threads: usize,
|
||||
pub(crate) mismatched_threads: usize,
|
||||
pub(crate) thread_ids: HashSet<String>,
|
||||
pub(crate) syncable_thread_ids: HashSet<String>,
|
||||
pub(crate) archived_thread_ids: HashSet<String>,
|
||||
pub(crate) subagent_thread_ids: HashSet<String>,
|
||||
pub(crate) rollout_paths_by_thread_id: HashMap<String, String>,
|
||||
pub(crate) mismatched_thread_ids: HashSet<String>,
|
||||
pub(crate) warnings: Vec<String>,
|
||||
|
||||
@@ -1,16 +1,25 @@
|
||||
use super::build_skills_mcp_state_inner;
|
||||
use super::types::{ManagedMcpServer, SkillsMcpState};
|
||||
use crate::backups::create_backup;
|
||||
use crate::ccswitch::default_ccswitch_db_path;
|
||||
use crate::error::{CodexxError, Result};
|
||||
use crate::file_io::{ensure_directory, parse_toml_document, read_to_string_if_exists, write_text};
|
||||
use crate::file_io::{ensure_directory, parse_toml_document, read_to_string_if_exists};
|
||||
use crate::live_config::{
|
||||
acquire_live_config_lock, apply_file_change, fail_with_file_rollback, read_file_snapshot,
|
||||
text_from_snapshot,
|
||||
};
|
||||
use crate::toml_utils::ensure_table;
|
||||
use crate::{config_path, now_rfc3339, open_db, resolve_codex_dir};
|
||||
use rusqlite::{params, Connection, OpenFlags};
|
||||
use rusqlite::{
|
||||
params, Connection, OpenFlags, OptionalExtension, Transaction, TransactionBehavior,
|
||||
};
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::HashSet;
|
||||
use std::path::Path;
|
||||
use toml_edit::{value, Item, Table};
|
||||
|
||||
type CcSwitchMcpCandidate = (String, String, Value, bool);
|
||||
|
||||
fn toml_value_to_json(value: &toml_edit::Value) -> Value {
|
||||
if let Some(s) = value.as_str() {
|
||||
return json!(s);
|
||||
@@ -134,8 +143,13 @@ pub(super) fn mcp_summary(config: &Value) -> (String, Option<String>, Option<Str
|
||||
(transport, command, url, summary)
|
||||
}
|
||||
|
||||
pub(super) fn save_managed_mcp(id: &str, name: &str, config: &Value, enabled: bool) -> Result<()> {
|
||||
let conn = open_db()?;
|
||||
fn save_managed_mcp_on_connection(
|
||||
conn: &Connection,
|
||||
id: &str,
|
||||
name: &str,
|
||||
config: &Value,
|
||||
enabled: bool,
|
||||
) -> Result<()> {
|
||||
conn.execute(
|
||||
"INSERT INTO managed_mcp_servers (id, name, server_config, enabled, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5)
|
||||
@@ -156,6 +170,83 @@ pub(super) fn save_managed_mcp(id: &str, name: &str, config: &Value, enabled: bo
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn save_managed_mcp(id: &str, name: &str, config: &Value, enabled: bool) -> Result<()> {
|
||||
let conn = open_db()?;
|
||||
save_managed_mcp_on_connection(&conn, id, name, config, enabled)
|
||||
}
|
||||
|
||||
fn managed_mcp_on_connection(conn: &Connection, id: &str) -> Result<Option<(String, Value, bool)>> {
|
||||
conn.query_row(
|
||||
"SELECT name, server_config, enabled FROM managed_mcp_servers WHERE id = ?1 LIMIT 1",
|
||||
[id],
|
||||
|row| {
|
||||
let config_text: String = row.get(1)?;
|
||||
Ok((
|
||||
row.get(0)?,
|
||||
serde_json::from_str(&config_text).unwrap_or(Value::Object(Default::default())),
|
||||
row.get(2)?,
|
||||
))
|
||||
},
|
||||
)
|
||||
.optional()
|
||||
.map_err(|error| CodexxError::Database(error.to_string()))
|
||||
}
|
||||
|
||||
fn document_mcp_ids(doc: &toml_edit::DocumentMut) -> HashSet<String> {
|
||||
doc.get("mcp_servers")
|
||||
.and_then(|item| item.as_table())
|
||||
.map(|table| {
|
||||
table
|
||||
.iter()
|
||||
.filter(|(_, item)| item.is_table())
|
||||
.map(|(id, _)| id.to_string())
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn document_bytes(snapshot: Option<&[u8]>, doc: &toml_edit::DocumentMut) -> Option<Vec<u8>> {
|
||||
let bytes = doc.to_string().into_bytes();
|
||||
if snapshot.is_none() && bytes.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(bytes)
|
||||
}
|
||||
}
|
||||
|
||||
fn commit_mcp_transaction_with_config<BeforeApply, BeforeCommit>(
|
||||
transaction: Transaction<'_>,
|
||||
codex_dir: &Path,
|
||||
before: Option<Vec<u8>>,
|
||||
after: Option<Vec<u8>>,
|
||||
backup_action: &str,
|
||||
before_apply: BeforeApply,
|
||||
before_commit: BeforeCommit,
|
||||
) -> Result<()>
|
||||
where
|
||||
BeforeApply: FnOnce(&Path) -> Result<()>,
|
||||
BeforeCommit: FnOnce(&Transaction<'_>) -> Result<()>,
|
||||
{
|
||||
let cfg = config_path(codex_dir);
|
||||
let mut changes = Vec::new();
|
||||
if before != after {
|
||||
create_backup(codex_dir, backup_action)?;
|
||||
before_apply(&cfg)?;
|
||||
changes.push(apply_file_change(&cfg, before, after)?);
|
||||
}
|
||||
|
||||
if let Err(error) = before_commit(&transaction) {
|
||||
return fail_with_file_rollback(error, &changes);
|
||||
}
|
||||
if let Err(error) = transaction
|
||||
.commit()
|
||||
.map_err(|error| CodexxError::Database(error.to_string()))
|
||||
{
|
||||
return fail_with_file_rollback(error, &changes);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn db_managed_mcp() -> Result<Vec<(String, String, Value, bool)>> {
|
||||
let conn = open_db()?;
|
||||
let mut stmt = conn.prepare("SELECT id, name, server_config, enabled FROM managed_mcp_servers ORDER BY name ASC, id ASC")
|
||||
@@ -206,6 +297,7 @@ pub(super) fn list_mcp_from_config(codex_dir: &Path) -> Result<Vec<ManagedMcpSer
|
||||
enabled: true,
|
||||
source: "config.toml".to_string(),
|
||||
summary,
|
||||
note: None,
|
||||
command,
|
||||
url,
|
||||
config_json: config,
|
||||
@@ -264,36 +356,76 @@ pub(super) fn import_ccswitch_mcp_servers_for_codex(
|
||||
))
|
||||
})
|
||||
.map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
|
||||
let cfg = config_path(codex_dir);
|
||||
let text = read_to_string_if_exists(&cfg)?;
|
||||
let mut doc = parse_toml_document(&cfg, &text)?;
|
||||
let live_enabled = list_mcp_from_config(codex_dir)?
|
||||
.into_iter()
|
||||
.map(|server| server.id)
|
||||
.collect::<HashSet<_>>();
|
||||
let mut imported = 0usize;
|
||||
let mut changed_config = false;
|
||||
let mut candidates = Vec::new();
|
||||
for row in rows {
|
||||
let (id, name, config_text, enabled_codex) =
|
||||
row.map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
let config: Value =
|
||||
serde_json::from_str(&config_text).unwrap_or(Value::Object(Default::default()));
|
||||
if !imported_ids.insert(id.clone()) {
|
||||
continue;
|
||||
}
|
||||
let enabled = enabled_codex || live_enabled.contains(&id);
|
||||
save_managed_mcp(&id, &name, &config, enabled)?;
|
||||
if enabled_codex && !live_enabled.contains(&id) {
|
||||
ensure_table(doc.as_table_mut(), "mcp_servers")?
|
||||
.insert(&id, json_to_toml_item(&config));
|
||||
changed_config = true;
|
||||
}
|
||||
imported += 1;
|
||||
candidates.push((id, name, config, enabled_codex));
|
||||
}
|
||||
if changed_config {
|
||||
write_text(&cfg, &doc.to_string())?;
|
||||
|
||||
import_ccswitch_mcp_candidates_with_hooks(
|
||||
codex_dir,
|
||||
imported_ids,
|
||||
candidates,
|
||||
|_| Ok(()),
|
||||
|_| Ok(()),
|
||||
)
|
||||
}
|
||||
|
||||
fn import_ccswitch_mcp_candidates_with_hooks<BeforeApply, BeforeCommit>(
|
||||
codex_dir: &Path,
|
||||
imported_ids: &mut HashSet<String>,
|
||||
candidates: Vec<CcSwitchMcpCandidate>,
|
||||
before_apply: BeforeApply,
|
||||
before_commit: BeforeCommit,
|
||||
) -> Result<usize>
|
||||
where
|
||||
BeforeApply: FnOnce(&Path) -> Result<()>,
|
||||
BeforeCommit: FnOnce(&Transaction<'_>) -> Result<()>,
|
||||
{
|
||||
let mut staged_ids = HashSet::new();
|
||||
let candidates = candidates
|
||||
.into_iter()
|
||||
.filter(|(id, _, _, _)| !imported_ids.contains(id) && staged_ids.insert(id.clone()))
|
||||
.collect::<Vec<_>>();
|
||||
if candidates.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
ensure_directory(codex_dir)?;
|
||||
let _live_lock = acquire_live_config_lock(codex_dir)?;
|
||||
let cfg = config_path(codex_dir);
|
||||
let before = read_file_snapshot(&cfg)?;
|
||||
let text = text_from_snapshot(&cfg, before.as_deref())?;
|
||||
let mut doc = parse_toml_document(&cfg, &text)?;
|
||||
let live_enabled = document_mcp_ids(&doc);
|
||||
|
||||
let mut app_conn = open_db()?;
|
||||
let transaction = app_conn
|
||||
.transaction_with_behavior(TransactionBehavior::Immediate)
|
||||
.map_err(|error| CodexxError::Database(error.to_string()))?;
|
||||
for (id, name, config, enabled_codex) in &candidates {
|
||||
let enabled = *enabled_codex || live_enabled.contains(id);
|
||||
save_managed_mcp_on_connection(&transaction, id, name, config, enabled)?;
|
||||
if *enabled_codex && !live_enabled.contains(id) {
|
||||
ensure_table(doc.as_table_mut(), "mcp_servers")?.insert(id, json_to_toml_item(config));
|
||||
}
|
||||
}
|
||||
let after = document_bytes(before.as_deref(), &doc);
|
||||
commit_mcp_transaction_with_config(
|
||||
transaction,
|
||||
codex_dir,
|
||||
before,
|
||||
after,
|
||||
"import-ccswitch-mcp",
|
||||
before_apply,
|
||||
before_commit,
|
||||
)?;
|
||||
|
||||
let imported = candidates.len();
|
||||
imported_ids.extend(staged_ids);
|
||||
Ok(imported)
|
||||
}
|
||||
|
||||
@@ -355,6 +487,7 @@ pub(super) fn preview_ccswitch_mcp_servers_for_codex(
|
||||
enabled: enabled_codex || live_enabled.contains(&id),
|
||||
source: "cc-switch".to_string(),
|
||||
summary,
|
||||
note: None,
|
||||
command,
|
||||
url,
|
||||
config_json: config,
|
||||
@@ -368,31 +501,287 @@ pub(crate) fn toggle_codex_mcp_inner(
|
||||
id: String,
|
||||
enabled: bool,
|
||||
) -> Result<SkillsMcpState> {
|
||||
toggle_codex_mcp_with_hooks(config_dir, id, enabled, |_| Ok(()), |_| Ok(()))
|
||||
}
|
||||
|
||||
fn toggle_codex_mcp_with_hooks<BeforeApply, BeforeCommit>(
|
||||
config_dir: Option<String>,
|
||||
id: String,
|
||||
enabled: bool,
|
||||
before_apply: BeforeApply,
|
||||
before_commit: BeforeCommit,
|
||||
) -> Result<SkillsMcpState>
|
||||
where
|
||||
BeforeApply: FnOnce(&Path) -> Result<()>,
|
||||
BeforeCommit: FnOnce(&Transaction<'_>) -> Result<()>,
|
||||
{
|
||||
let codex_dir = resolve_codex_dir(config_dir.clone())?;
|
||||
ensure_directory(&codex_dir)?;
|
||||
let _live_lock = acquire_live_config_lock(&codex_dir)?;
|
||||
let cfg = config_path(&codex_dir);
|
||||
let text = read_to_string_if_exists(&cfg)?;
|
||||
let before = read_file_snapshot(&cfg)?;
|
||||
let text = text_from_snapshot(&cfg, before.as_deref())?;
|
||||
let mut doc = parse_toml_document(&cfg, &text)?;
|
||||
|
||||
let mut conn = open_db()?;
|
||||
let transaction = conn
|
||||
.transaction_with_behavior(TransactionBehavior::Immediate)
|
||||
.map_err(|error| CodexxError::Database(error.to_string()))?;
|
||||
let stored = managed_mcp_on_connection(&transaction, &id)?;
|
||||
if enabled {
|
||||
let db = db_managed_mcp()?
|
||||
.into_iter()
|
||||
.find(|(sid, _, _, _)| sid == &id)
|
||||
.ok_or_else(|| CodexxError::Config(format!("未找到 MCP: {id}")))?;
|
||||
ensure_table(doc.as_table_mut(), "mcp_servers")?.insert(&id, json_to_toml_item(&db.2));
|
||||
save_managed_mcp(&id, &db.1, &db.2, true)?;
|
||||
let (name, config, _) =
|
||||
stored.ok_or_else(|| CodexxError::Config(format!("未找到 MCP: {id}")))?;
|
||||
ensure_table(doc.as_table_mut(), "mcp_servers")?.insert(&id, json_to_toml_item(&config));
|
||||
save_managed_mcp_on_connection(&transaction, &id, &name, &config, true)?;
|
||||
} else {
|
||||
if let Some(item) = doc
|
||||
let live_config = doc
|
||||
.get("mcp_servers")
|
||||
.and_then(|m| m.as_table())
|
||||
.and_then(|tbl| tbl.get(&id))
|
||||
{
|
||||
let config = toml_item_to_json(item);
|
||||
save_managed_mcp(&id, &id, &config, false)?;
|
||||
.map(toml_item_to_json);
|
||||
if let Some(config) = live_config {
|
||||
let name = stored
|
||||
.as_ref()
|
||||
.map(|(name, _, _)| name.as_str())
|
||||
.unwrap_or(&id);
|
||||
save_managed_mcp_on_connection(&transaction, &id, name, &config, false)?;
|
||||
} else if let Some((name, config, _)) = stored {
|
||||
save_managed_mcp_on_connection(&transaction, &id, &name, &config, false)?;
|
||||
}
|
||||
if let Some(tbl) = doc.get_mut("mcp_servers").and_then(|m| m.as_table_mut()) {
|
||||
tbl.remove(&id);
|
||||
}
|
||||
}
|
||||
write_text(&cfg, &doc.to_string())?;
|
||||
|
||||
let after = document_bytes(before.as_deref(), &doc);
|
||||
commit_mcp_transaction_with_config(
|
||||
transaction,
|
||||
&codex_dir,
|
||||
before,
|
||||
after,
|
||||
"toggle-mcp",
|
||||
before_apply,
|
||||
before_commit,
|
||||
)?;
|
||||
build_skills_mcp_state_inner(config_dir)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
fn test_case(name: &str) -> (PathBuf, String) {
|
||||
static COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
let suffix = COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"codex-x-mcp-{name}-{}-{suffix}",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
fs::create_dir_all(&dir).expect("create MCP test directory");
|
||||
(dir, format!("test-mcp-{name}-{suffix}"))
|
||||
}
|
||||
|
||||
fn database_error(error: rusqlite::Error) -> CodexxError {
|
||||
CodexxError::Database(error.to_string())
|
||||
}
|
||||
|
||||
fn remove_test_mcp(id: &str) {
|
||||
open_db()
|
||||
.expect("open test database")
|
||||
.execute("DELETE FROM managed_mcp_servers WHERE id = ?1", [id])
|
||||
.expect("remove test MCP");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toggle_updates_only_the_target_mcp_and_keeps_database_in_sync() {
|
||||
let _db_guard = crate::app_db::test_db_guard();
|
||||
let (codex_dir, id) = test_case("success");
|
||||
let cfg = config_path(&codex_dir);
|
||||
let original = b"# keep this comment\nmodel = \"gpt-5.5\"\n\n[features]\njs_repl = false\n\n[mcp_servers.existing]\ncommand = \"existing\"\n";
|
||||
fs::write(&cfg, original).expect("seed original config");
|
||||
save_managed_mcp(&id, "Managed", &json!({ "command": "managed" }), false)
|
||||
.expect("seed managed MCP");
|
||||
|
||||
let enabled_state =
|
||||
toggle_codex_mcp_inner(Some(codex_dir.display().to_string()), id.clone(), true)
|
||||
.expect("enable managed MCP");
|
||||
assert!(enabled_state
|
||||
.mcp_servers
|
||||
.iter()
|
||||
.any(|server| server.id == id && server.enabled));
|
||||
let enabled_text = fs::read_to_string(&cfg).expect("read enabled config");
|
||||
let enabled_doc = enabled_text
|
||||
.parse::<toml_edit::DocumentMut>()
|
||||
.expect("parse enabled config");
|
||||
assert!(enabled_text.contains("# keep this comment"));
|
||||
assert_eq!(enabled_doc["features"]["js_repl"].as_bool(), Some(false));
|
||||
assert_eq!(
|
||||
enabled_doc["mcp_servers"]["existing"]["command"].as_str(),
|
||||
Some("existing")
|
||||
);
|
||||
assert_eq!(
|
||||
enabled_doc["mcp_servers"][&id]["command"].as_str(),
|
||||
Some("managed")
|
||||
);
|
||||
let conn = open_db().expect("open test database");
|
||||
assert!(
|
||||
managed_mcp_on_connection(&conn, &id)
|
||||
.expect("read enabled MCP")
|
||||
.expect("enabled MCP exists")
|
||||
.2
|
||||
);
|
||||
drop(conn);
|
||||
|
||||
toggle_codex_mcp_inner(Some(codex_dir.display().to_string()), id.clone(), false)
|
||||
.expect("disable managed MCP");
|
||||
let disabled_text = fs::read_to_string(&cfg).expect("read disabled config");
|
||||
let disabled_doc = disabled_text
|
||||
.parse::<toml_edit::DocumentMut>()
|
||||
.expect("parse disabled config");
|
||||
assert!(disabled_text.contains("# keep this comment"));
|
||||
assert!(disabled_doc["mcp_servers"].get(&id).is_none());
|
||||
assert_eq!(
|
||||
disabled_doc["mcp_servers"]["existing"]["command"].as_str(),
|
||||
Some("existing")
|
||||
);
|
||||
let conn = open_db().expect("open test database");
|
||||
let (name, config, enabled) = managed_mcp_on_connection(&conn, &id)
|
||||
.expect("read disabled MCP")
|
||||
.expect("disabled MCP exists");
|
||||
assert_eq!(name, "Managed");
|
||||
assert_eq!(config["command"].as_str(), Some("managed"));
|
||||
assert!(!enabled);
|
||||
drop(conn);
|
||||
assert!(codex_dir.join(".codexx-test-backups").is_dir());
|
||||
|
||||
remove_test_mcp(&id);
|
||||
fs::remove_dir_all(codex_dir).expect("remove MCP test directory");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toggle_rejects_a_stale_snapshot_without_changing_database_or_external_config() {
|
||||
let _db_guard = crate::app_db::test_db_guard();
|
||||
let (codex_dir, id) = test_case("stale");
|
||||
let cfg = config_path(&codex_dir);
|
||||
let original = b"# original\nmodel = \"gpt-5.5\"\n";
|
||||
let external = b"# external update\nmodel = \"gpt-5.5\"\n\n[mcp_servers.external]\ncommand = \"external\"\n";
|
||||
fs::write(&cfg, original).expect("seed original config");
|
||||
save_managed_mcp(&id, "Managed", &json!({ "command": "managed" }), false)
|
||||
.expect("seed managed MCP");
|
||||
|
||||
let error = toggle_codex_mcp_with_hooks(
|
||||
Some(codex_dir.display().to_string()),
|
||||
id.clone(),
|
||||
true,
|
||||
|path| {
|
||||
fs::write(path, external).map_err(|error| CodexxError::Config(error.to_string()))
|
||||
},
|
||||
|_| Ok(()),
|
||||
)
|
||||
.expect_err("stale MCP toggle must fail");
|
||||
|
||||
assert!(error.to_string().contains("已被其他程序修改"));
|
||||
assert_eq!(fs::read(&cfg).expect("read external config"), external);
|
||||
let conn = open_db().expect("open test database");
|
||||
let (_, config, enabled) = managed_mcp_on_connection(&conn, &id)
|
||||
.expect("read managed MCP")
|
||||
.expect("managed MCP still exists");
|
||||
assert_eq!(config["command"].as_str(), Some("managed"));
|
||||
assert!(!enabled);
|
||||
drop(conn);
|
||||
|
||||
remove_test_mcp(&id);
|
||||
fs::remove_dir_all(codex_dir).expect("remove MCP test directory");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toggle_rolls_back_config_when_database_commit_fails() {
|
||||
let _db_guard = crate::app_db::test_db_guard();
|
||||
let (codex_dir, id) = test_case("commit");
|
||||
let cfg = config_path(&codex_dir);
|
||||
let original = b"# keep exact bytes\nmodel = \"gpt-5.5\"\n\n[features]\njs_repl = false\n";
|
||||
fs::write(&cfg, original).expect("seed original config");
|
||||
save_managed_mcp(&id, "Managed", &json!({ "command": "managed" }), false)
|
||||
.expect("seed managed MCP");
|
||||
|
||||
toggle_codex_mcp_with_hooks(
|
||||
Some(codex_dir.display().to_string()),
|
||||
id.clone(),
|
||||
true,
|
||||
|_| Ok(()),
|
||||
|transaction| {
|
||||
transaction
|
||||
.execute_batch("ROLLBACK")
|
||||
.map_err(database_error)
|
||||
},
|
||||
)
|
||||
.expect_err("database commit failure must fail the toggle");
|
||||
|
||||
assert_eq!(fs::read(&cfg).expect("read rolled-back config"), original);
|
||||
let conn = open_db().expect("open test database");
|
||||
let (_, _, enabled) = managed_mcp_on_connection(&conn, &id)
|
||||
.expect("read managed MCP")
|
||||
.expect("managed MCP still exists");
|
||||
assert!(!enabled);
|
||||
drop(conn);
|
||||
assert!(codex_dir.join(".codexx-test-backups").is_dir());
|
||||
|
||||
remove_test_mcp(&id);
|
||||
fs::remove_dir_all(codex_dir).expect("remove MCP test directory");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ccswitch_import_rolls_back_file_database_and_ids_when_commit_fails() {
|
||||
let _db_guard = crate::app_db::test_db_guard();
|
||||
let (codex_dir, id) = test_case("import-commit");
|
||||
let second_id = format!("{id}-second");
|
||||
let cfg = config_path(&codex_dir);
|
||||
let original = b"# import baseline\nmodel = \"gpt-5.5\"\n";
|
||||
fs::write(&cfg, original).expect("seed original config");
|
||||
let mut imported_ids = HashSet::new();
|
||||
|
||||
import_ccswitch_mcp_candidates_with_hooks(
|
||||
&codex_dir,
|
||||
&mut imported_ids,
|
||||
vec![
|
||||
(
|
||||
id.clone(),
|
||||
"Imported".to_string(),
|
||||
json!({ "command": "imported" }),
|
||||
true,
|
||||
),
|
||||
(
|
||||
second_id.clone(),
|
||||
"Imported second".to_string(),
|
||||
json!({ "command": "imported-second" }),
|
||||
true,
|
||||
),
|
||||
],
|
||||
|_| Ok(()),
|
||||
|transaction| {
|
||||
transaction
|
||||
.execute_batch("ROLLBACK")
|
||||
.map_err(database_error)
|
||||
},
|
||||
)
|
||||
.expect_err("database commit failure must fail the import");
|
||||
|
||||
assert_eq!(fs::read(&cfg).expect("read rolled-back config"), original);
|
||||
assert!(imported_ids.is_empty());
|
||||
let conn = open_db().expect("open test database");
|
||||
assert!(managed_mcp_on_connection(&conn, &id)
|
||||
.expect("read imported MCP")
|
||||
.is_none());
|
||||
assert!(managed_mcp_on_connection(&conn, &second_id)
|
||||
.expect("read second imported MCP")
|
||||
.is_none());
|
||||
drop(conn);
|
||||
assert!(codex_dir.join(".codexx-test-backups").is_dir());
|
||||
|
||||
fs::remove_dir_all(codex_dir).expect("remove MCP test directory");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,17 +18,74 @@ pub(crate) use types::ManagedSkill;
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::file_io::{ensure_directory, io_err};
|
||||
use crate::paths::home_dir;
|
||||
use crate::paths::{home_dir, normalized_path_scope};
|
||||
use crate::resolve_codex_dir;
|
||||
use crate::{now_rfc3339, open_db};
|
||||
use mcp::{
|
||||
db_managed_mcp, import_ccswitch_mcp_servers_for_codex, list_mcp_from_config, mcp_summary,
|
||||
preview_ccswitch_mcp_servers_for_codex, save_managed_mcp,
|
||||
};
|
||||
use rusqlite::params;
|
||||
use skills::{
|
||||
codex_skills_dir, copy_dir_recursive, disabled_skills_dir, sanitize_dir_name, scan_skill_dir,
|
||||
};
|
||||
use std::collections::HashSet;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
const SKILLS_MCP_NOTE_MAX_CHARS: usize = 1000;
|
||||
|
||||
fn normalized_note_item_kind(item_kind: &str) -> Result<&'static str> {
|
||||
match item_kind.trim().to_ascii_lowercase().as_str() {
|
||||
"skill" => Ok("skill"),
|
||||
"mcp" => Ok("mcp"),
|
||||
_ => Err(crate::error::CodexxError::Config(
|
||||
"备注类型必须是 skill 或 mcp".to_string(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn skills_mcp_notes(codex_dir: &Path) -> Result<HashMap<(String, String), String>> {
|
||||
let conn = open_db()?;
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT item_kind, item_id, note
|
||||
FROM skills_mcp_notes
|
||||
WHERE codex_dir = ?1
|
||||
ORDER BY item_kind ASC, item_id ASC",
|
||||
)
|
||||
.map_err(|error| crate::error::CodexxError::Database(error.to_string()))?;
|
||||
let rows = stmt
|
||||
.query_map([normalized_path_scope(codex_dir)], |row| {
|
||||
Ok((
|
||||
(row.get::<_, String>(0)?, row.get::<_, String>(1)?),
|
||||
row.get::<_, String>(2)?,
|
||||
))
|
||||
})
|
||||
.map_err(|error| crate::error::CodexxError::Database(error.to_string()))?;
|
||||
let mut notes = HashMap::new();
|
||||
for row in rows {
|
||||
let (key, note) =
|
||||
row.map_err(|error| crate::error::CodexxError::Database(error.to_string()))?;
|
||||
notes.insert(key, note);
|
||||
}
|
||||
Ok(notes)
|
||||
}
|
||||
|
||||
fn attach_skills_mcp_notes(
|
||||
codex_dir: &Path,
|
||||
skills: &mut [types::ManagedSkill],
|
||||
mcp_servers: &mut [ManagedMcpServer],
|
||||
) -> Result<()> {
|
||||
let notes = skills_mcp_notes(codex_dir)?;
|
||||
for skill in skills {
|
||||
skill.note = notes.get(&("skill".to_string(), skill.id.clone())).cloned();
|
||||
}
|
||||
for server in mcp_servers {
|
||||
server.note = notes.get(&("mcp".to_string(), server.id.clone())).cloned();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn extend_unmanaged_mcp_candidates(
|
||||
output: &mut Vec<ManagedMcpServer>,
|
||||
@@ -82,11 +139,13 @@ pub(crate) fn build_skills_mcp_state_inner(config_dir: Option<String>) -> Result
|
||||
enabled,
|
||||
source: "Codex-X".to_string(),
|
||||
summary,
|
||||
note: None,
|
||||
command,
|
||||
url,
|
||||
config_json: config,
|
||||
});
|
||||
}
|
||||
attach_skills_mcp_notes(&codex_dir, &mut skills, &mut mcp_servers)?;
|
||||
sort_managed_mcp_servers(&mut mcp_servers);
|
||||
sort_managed_skills(&mut skills);
|
||||
Ok(SkillsMcpState {
|
||||
@@ -99,6 +158,63 @@ pub(crate) fn build_skills_mcp_state_inner(config_dir: Option<String>) -> Result
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn save_skills_mcp_note_inner(
|
||||
config_dir: Option<String>,
|
||||
item_kind: String,
|
||||
id: String,
|
||||
note: String,
|
||||
) -> Result<SkillsMcpState> {
|
||||
let item_kind = normalized_note_item_kind(&item_kind)?;
|
||||
if id.trim().is_empty() {
|
||||
return Err(crate::error::CodexxError::Config(
|
||||
"备注对象 ID 不能为空".to_string(),
|
||||
));
|
||||
}
|
||||
let id = id.as_str();
|
||||
let note = note.trim();
|
||||
if note.chars().count() > SKILLS_MCP_NOTE_MAX_CHARS {
|
||||
return Err(crate::error::CodexxError::Config(format!(
|
||||
"备注不能超过 {SKILLS_MCP_NOTE_MAX_CHARS} 个字符"
|
||||
)));
|
||||
}
|
||||
|
||||
let codex_dir = resolve_codex_dir(config_dir.clone())?;
|
||||
let codex_dir_scope = normalized_path_scope(&codex_dir);
|
||||
let current = build_skills_mcp_state_inner(config_dir.clone())?;
|
||||
let exists = match item_kind {
|
||||
"skill" => current.skills.iter().any(|skill| skill.id == id),
|
||||
"mcp" => current.mcp_servers.iter().any(|server| server.id == id),
|
||||
_ => false,
|
||||
};
|
||||
if !exists {
|
||||
return Err(crate::error::CodexxError::Config(format!(
|
||||
"未找到要备注的 {item_kind}: {id}"
|
||||
)));
|
||||
}
|
||||
|
||||
let conn = open_db()?;
|
||||
if note.is_empty() {
|
||||
conn.execute(
|
||||
"DELETE FROM skills_mcp_notes
|
||||
WHERE codex_dir = ?1 AND item_kind = ?2 AND item_id = ?3",
|
||||
params![codex_dir_scope, item_kind, id],
|
||||
)
|
||||
.map_err(|error| crate::error::CodexxError::Database(error.to_string()))?;
|
||||
} else {
|
||||
conn.execute(
|
||||
"INSERT INTO skills_mcp_notes
|
||||
(codex_dir, item_kind, item_id, note, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5)
|
||||
ON CONFLICT(codex_dir, item_kind, item_id) DO UPDATE SET
|
||||
note = excluded.note,
|
||||
updated_at = excluded.updated_at",
|
||||
params![codex_dir_scope, item_kind, id, note, now_rfc3339()],
|
||||
)
|
||||
.map_err(|error| crate::error::CodexxError::Database(error.to_string()))?;
|
||||
}
|
||||
build_skills_mcp_state_inner(config_dir)
|
||||
}
|
||||
|
||||
pub(crate) fn import_existing_skills_mcp_inner(
|
||||
config_dir: Option<String>,
|
||||
) -> Result<SkillsMcpActionResult> {
|
||||
@@ -223,6 +339,7 @@ mod tests {
|
||||
enabled: true,
|
||||
source: source.to_string(),
|
||||
summary: id.to_string(),
|
||||
note: None,
|
||||
command: Some(id.to_string()),
|
||||
url: None,
|
||||
config_json: json!({ "command": id }),
|
||||
@@ -271,4 +388,161 @@ mod tests {
|
||||
);
|
||||
assert!(second_preview.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_notes_are_isolated_by_kind_and_codex_home_and_empty_text_clears_them() {
|
||||
let _db_guard = crate::app_db::test_db_guard();
|
||||
let item_id = format!("shared-note-item-{}", std::process::id());
|
||||
let spaced_id = " spaced-mcp ";
|
||||
let codex_dir =
|
||||
std::env::temp_dir().join(format!("codex-x-note-state-{}", std::process::id()));
|
||||
let other_codex_dir =
|
||||
std::env::temp_dir().join(format!("codex-x-note-state-other-{}", std::process::id()));
|
||||
let prepare_codex_dir = |dir: &Path| {
|
||||
let _ = fs::remove_dir_all(dir);
|
||||
let skill_dir = dir.join("skills").join(&item_id);
|
||||
fs::create_dir_all(&skill_dir).expect("create test skill directory");
|
||||
fs::write(
|
||||
skill_dir.join("SKILL.md"),
|
||||
format!("---\nname: {item_id}\ndescription: test\n---\n"),
|
||||
)
|
||||
.expect("write test skill");
|
||||
fs::write(
|
||||
dir.join("config.toml"),
|
||||
format!(
|
||||
"[mcp_servers.{item_id}]\ncommand = \"test-server\"\n\
|
||||
[mcp_servers.\"{spaced_id}\"]\ncommand = \"spaced-server\"\n"
|
||||
),
|
||||
)
|
||||
.expect("write test MCP config");
|
||||
};
|
||||
prepare_codex_dir(&codex_dir);
|
||||
prepare_codex_dir(&other_codex_dir);
|
||||
|
||||
let conn = open_db().expect("open app database");
|
||||
conn.execute(
|
||||
"DELETE FROM skills_mcp_notes WHERE item_id IN (?1, ?2)",
|
||||
params![&item_id, spaced_id],
|
||||
)
|
||||
.expect("clear stale test notes");
|
||||
drop(conn);
|
||||
let config_dir = Some(codex_dir.display().to_string());
|
||||
|
||||
save_skills_mcp_note_inner(
|
||||
config_dir.clone(),
|
||||
"skill".to_string(),
|
||||
item_id.clone(),
|
||||
" 我的 Skill 备注 ".to_string(),
|
||||
)
|
||||
.expect("save skill note");
|
||||
let state = save_skills_mcp_note_inner(
|
||||
config_dir.clone(),
|
||||
"mcp".to_string(),
|
||||
item_id.clone(),
|
||||
"MCP note".to_string(),
|
||||
)
|
||||
.expect("save MCP note");
|
||||
assert_eq!(
|
||||
state
|
||||
.skills
|
||||
.iter()
|
||||
.find(|skill| skill.id == item_id)
|
||||
.and_then(|skill| skill.note.as_deref()),
|
||||
Some("我的 Skill 备注")
|
||||
);
|
||||
assert_eq!(
|
||||
state
|
||||
.mcp_servers
|
||||
.iter()
|
||||
.find(|server| server.id == item_id)
|
||||
.and_then(|server| server.note.as_deref()),
|
||||
Some("MCP note")
|
||||
);
|
||||
let state = save_skills_mcp_note_inner(
|
||||
config_dir.clone(),
|
||||
"mcp".to_string(),
|
||||
spaced_id.to_string(),
|
||||
"Spaced ID note".to_string(),
|
||||
)
|
||||
.expect("save note without normalizing the MCP ID");
|
||||
assert_eq!(
|
||||
state
|
||||
.mcp_servers
|
||||
.iter()
|
||||
.find(|server| server.id == spaced_id)
|
||||
.and_then(|server| server.note.as_deref()),
|
||||
Some("Spaced ID note")
|
||||
);
|
||||
|
||||
let other_config_dir = Some(other_codex_dir.display().to_string());
|
||||
let other_state = build_skills_mcp_state_inner(other_config_dir.clone())
|
||||
.expect("load other CODEX_HOME state");
|
||||
assert!(other_state
|
||||
.mcp_servers
|
||||
.iter()
|
||||
.find(|server| server.id == item_id)
|
||||
.is_some_and(|server| server.note.is_none()));
|
||||
save_skills_mcp_note_inner(
|
||||
other_config_dir,
|
||||
"mcp".to_string(),
|
||||
item_id.clone(),
|
||||
"Other MCP note".to_string(),
|
||||
)
|
||||
.expect("save note for other CODEX_HOME");
|
||||
let original_state = build_skills_mcp_state_inner(config_dir.clone())
|
||||
.expect("reload original CODEX_HOME state");
|
||||
assert_eq!(
|
||||
original_state
|
||||
.mcp_servers
|
||||
.iter()
|
||||
.find(|server| server.id == item_id)
|
||||
.and_then(|server| server.note.as_deref()),
|
||||
Some("MCP note")
|
||||
);
|
||||
|
||||
let state = save_skills_mcp_note_inner(
|
||||
config_dir.clone(),
|
||||
"skill".to_string(),
|
||||
item_id.clone(),
|
||||
" ".to_string(),
|
||||
)
|
||||
.expect("clear skill note");
|
||||
assert!(state
|
||||
.skills
|
||||
.iter()
|
||||
.find(|skill| skill.id == item_id)
|
||||
.is_some_and(|skill| skill.note.is_none()));
|
||||
assert_eq!(
|
||||
state
|
||||
.mcp_servers
|
||||
.iter()
|
||||
.find(|server| server.id == item_id)
|
||||
.and_then(|server| server.note.as_deref()),
|
||||
Some("MCP note")
|
||||
);
|
||||
assert!(save_skills_mcp_note_inner(
|
||||
config_dir.clone(),
|
||||
"unknown".to_string(),
|
||||
item_id.clone(),
|
||||
"note".to_string(),
|
||||
)
|
||||
.is_err());
|
||||
assert!(save_skills_mcp_note_inner(
|
||||
config_dir,
|
||||
"mcp".to_string(),
|
||||
item_id.clone(),
|
||||
"x".repeat(SKILLS_MCP_NOTE_MAX_CHARS + 1),
|
||||
)
|
||||
.is_err());
|
||||
|
||||
let conn = open_db().expect("reopen app database");
|
||||
conn.execute(
|
||||
"DELETE FROM skills_mcp_notes WHERE item_id IN (?1, ?2)",
|
||||
params![&item_id, spaced_id],
|
||||
)
|
||||
.expect("remove test notes");
|
||||
drop(conn);
|
||||
fs::remove_dir_all(&codex_dir).expect("remove test Codex directory");
|
||||
fs::remove_dir_all(&other_codex_dir).expect("remove other test Codex directory");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -258,6 +258,7 @@ pub(super) fn scan_skill_dir(
|
||||
id: id.clone(),
|
||||
name,
|
||||
description,
|
||||
note: None,
|
||||
directory,
|
||||
enabled,
|
||||
source: source.to_string(),
|
||||
|
||||
@@ -10,6 +10,7 @@ pub(crate) struct ManagedMcpServer {
|
||||
pub(crate) enabled: bool,
|
||||
pub(crate) source: String,
|
||||
pub(crate) summary: String,
|
||||
pub(crate) note: Option<String>,
|
||||
pub(crate) command: Option<String>,
|
||||
pub(crate) url: Option<String>,
|
||||
pub(crate) config_json: Value,
|
||||
@@ -21,6 +22,7 @@ pub(crate) struct ManagedSkill {
|
||||
pub(crate) id: String,
|
||||
pub(crate) name: String,
|
||||
pub(crate) description: Option<String>,
|
||||
pub(crate) note: Option<String>,
|
||||
pub(crate) directory: String,
|
||||
pub(crate) enabled: bool,
|
||||
pub(crate) source: String,
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
pub(crate) struct BuiltinSkinAsset {
|
||||
pub(crate) id: &'static str,
|
||||
pub(crate) manifest: &'static str,
|
||||
pub(crate) image: &'static [u8],
|
||||
}
|
||||
|
||||
pub(crate) const BUILTIN_SKIN_ID: &str = "shiina-mashiro-blossom";
|
||||
|
||||
pub(crate) fn retired_builtin_skin_ids() -> &'static [&'static str] {
|
||||
&[
|
||||
"preset-midnight-aurora",
|
||||
"preset-sakura-dawn",
|
||||
"preset-amber-dusk",
|
||||
"preset-forest-mist",
|
||||
"preset-cyber-neon",
|
||||
]
|
||||
}
|
||||
|
||||
pub(crate) fn builtin_skin_assets() -> &'static [BuiltinSkinAsset] {
|
||||
&[]
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
use crate::error::{CodexxError, Result};
|
||||
use crate::file_io::{atomic_write, ensure_directory};
|
||||
use crate::skins::skins_root;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub(super) const RUNTIME_VERSION: &str = "1.2.2-codexx.3";
|
||||
pub(super) const UPSTREAM_COMMIT: &str = "5fd8af532efbaa87d2d0092297fd2d45cd56574e";
|
||||
|
||||
struct EmbeddedRuntimeAsset {
|
||||
relative_path: &'static str,
|
||||
bytes: &'static [u8],
|
||||
}
|
||||
|
||||
fn embedded_assets() -> &'static [EmbeddedRuntimeAsset] {
|
||||
&[
|
||||
EmbeddedRuntimeAsset {
|
||||
relative_path: "scripts/injector.mjs",
|
||||
bytes: include_bytes!("../../resources/skin-runtime/scripts/injector.mjs"),
|
||||
},
|
||||
EmbeddedRuntimeAsset {
|
||||
relative_path: "scripts/image-metadata.mjs",
|
||||
bytes: include_bytes!("../../resources/skin-runtime/scripts/image-metadata.mjs"),
|
||||
},
|
||||
EmbeddedRuntimeAsset {
|
||||
relative_path: "scripts/stage-theme.mjs",
|
||||
bytes: include_bytes!("../../resources/skin-runtime/scripts/stage-theme.mjs"),
|
||||
},
|
||||
EmbeddedRuntimeAsset {
|
||||
relative_path: "assets/dream-skin.css",
|
||||
bytes: include_bytes!("../../resources/skin-runtime/assets/dream-skin.css"),
|
||||
},
|
||||
EmbeddedRuntimeAsset {
|
||||
relative_path: "assets/renderer-inject.js",
|
||||
bytes: include_bytes!("../../resources/skin-runtime/assets/renderer-inject.js"),
|
||||
},
|
||||
EmbeddedRuntimeAsset {
|
||||
relative_path: "windows/codexx-windows.ps1",
|
||||
bytes: include_bytes!("../../resources/skin-runtime/windows/codexx-windows.ps1"),
|
||||
},
|
||||
EmbeddedRuntimeAsset {
|
||||
relative_path: "windows/common-windows.ps1",
|
||||
bytes: include_bytes!("../../resources/skin-runtime/windows/common-windows.ps1"),
|
||||
},
|
||||
EmbeddedRuntimeAsset {
|
||||
relative_path: "windows/config-utf8.ps1",
|
||||
bytes: include_bytes!("../../resources/skin-runtime/windows/config-utf8.ps1"),
|
||||
},
|
||||
EmbeddedRuntimeAsset {
|
||||
relative_path: "LICENSE",
|
||||
bytes: include_bytes!("../../resources/skin-runtime/LICENSE"),
|
||||
},
|
||||
EmbeddedRuntimeAsset {
|
||||
relative_path: "NOTICE.md",
|
||||
bytes: include_bytes!("../../resources/skin-runtime/NOTICE.md"),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
fn bytes_match(path: &Path, expected: &[u8]) -> bool {
|
||||
if !fs::symlink_metadata(path)
|
||||
.is_ok_and(|metadata| metadata.is_file() && !metadata.file_type().is_symlink())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let Ok(actual) = fs::read(path) else {
|
||||
return false;
|
||||
};
|
||||
Sha256::digest(actual) == Sha256::digest(expected)
|
||||
}
|
||||
|
||||
pub(super) fn ensure_runtime_directory(path: &Path) -> Result<()> {
|
||||
ensure_directory(path)?;
|
||||
let metadata =
|
||||
fs::symlink_metadata(path).map_err(|source| crate::file_io::io_err(path, source))?;
|
||||
if metadata.file_type().is_symlink() || !metadata.is_dir() {
|
||||
return Err(CodexxError::Config(format!(
|
||||
"皮肤运行时目录不能是符号链接或普通文件: {}",
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
set_private_permissions(path, true)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn set_private_permissions(path: &Path, directory: bool) -> Result<()> {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let mode = if directory { 0o700 } else { 0o600 };
|
||||
fs::set_permissions(path, fs::Permissions::from_mode(mode))
|
||||
.map_err(|source| crate::file_io::io_err(path, source))
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn set_private_permissions(_path: &Path, _directory: bool) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) struct RuntimeAssets {
|
||||
pub(super) injector: PathBuf,
|
||||
pub(super) stage_theme: PathBuf,
|
||||
#[cfg(any(target_os = "windows", feature = "windows-runtime-check"))]
|
||||
#[cfg_attr(feature = "windows-runtime-check", allow(dead_code))]
|
||||
pub(super) windows_adapter: PathBuf,
|
||||
}
|
||||
|
||||
pub(super) fn ensure_runtime_assets() -> Result<RuntimeAssets> {
|
||||
let base = skins_root()?.join("runtime");
|
||||
ensure_runtime_directory(&base)?;
|
||||
let root = base.join(RUNTIME_VERSION);
|
||||
ensure_runtime_directory(&root)?;
|
||||
for asset in embedded_assets() {
|
||||
let path = root.join(asset.relative_path);
|
||||
let parent = path.parent().ok_or_else(|| {
|
||||
CodexxError::Config(format!("内置皮肤资源路径无效: {}", path.display()))
|
||||
})?;
|
||||
ensure_runtime_directory(parent)?;
|
||||
if !bytes_match(&path, asset.bytes) {
|
||||
atomic_write(&path, asset.bytes)?;
|
||||
}
|
||||
set_private_permissions(&path, false)?;
|
||||
}
|
||||
Ok(RuntimeAssets {
|
||||
injector: root.join("scripts/injector.mjs"),
|
||||
stage_theme: root.join("scripts/stage-theme.mjs"),
|
||||
#[cfg(any(target_os = "windows", feature = "windows-runtime-check"))]
|
||||
windows_adapter: root.join("windows/codexx-windows.ps1"),
|
||||
})
|
||||
}
|
||||
@@ -1,786 +0,0 @@
|
||||
use super::assets::{
|
||||
ensure_runtime_assets, ensure_runtime_directory, RuntimeAssets, RUNTIME_VERSION,
|
||||
UPSTREAM_COMMIT,
|
||||
};
|
||||
use super::macos_injector::{
|
||||
launch_watcher, remove_live_skin, run_injector_once, stage_theme_snapshot,
|
||||
};
|
||||
use super::macos_system::{discover_codex_runtime, CodexRuntime};
|
||||
use super::{SkinRuntimeAction, SkinRuntimeStatus};
|
||||
use crate::error::{CodexxError, Result};
|
||||
use crate::file_io::{atomic_write, io_err};
|
||||
use crate::now_rfc3339;
|
||||
use crate::skins::skins_root;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs::{self, File, OpenOptions};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Stdio};
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
const DEFAULT_CDP_PORT: u16 = 9341;
|
||||
const MAX_CDP_PORT_OFFSET: u16 = 100;
|
||||
const RUNTIME_STATE_SCHEMA: u32 = 1;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct RuntimeStateFile {
|
||||
schema_version: u32,
|
||||
runtime_version: String,
|
||||
upstream_commit: String,
|
||||
session: String,
|
||||
port: u16,
|
||||
injector_pid: u32,
|
||||
injector_started_at: String,
|
||||
injector_path: String,
|
||||
node_path: String,
|
||||
node_version: String,
|
||||
codex_bundle: String,
|
||||
codex_executable: String,
|
||||
codex_version: String,
|
||||
codex_team_id: String,
|
||||
theme_id: String,
|
||||
theme_dir: String,
|
||||
created_at: String,
|
||||
updated_at: String,
|
||||
}
|
||||
|
||||
fn runtime_root() -> Result<PathBuf> {
|
||||
Ok(skins_root()?.join("runtime"))
|
||||
}
|
||||
|
||||
fn state_path() -> Result<PathBuf> {
|
||||
Ok(runtime_root()?.join("state.json"))
|
||||
}
|
||||
|
||||
pub(super) fn snapshots_root() -> Result<PathBuf> {
|
||||
Ok(runtime_root()?.join("themes"))
|
||||
}
|
||||
|
||||
fn logs_root() -> Result<PathBuf> {
|
||||
Ok(runtime_root()?.join("logs"))
|
||||
}
|
||||
|
||||
fn read_runtime_state() -> Result<Option<RuntimeStateFile>> {
|
||||
let path = state_path()?;
|
||||
let metadata = match fs::symlink_metadata(&path) {
|
||||
Ok(metadata) => metadata,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
|
||||
Err(error) => return Err(io_err(&path, error)),
|
||||
};
|
||||
if metadata.file_type().is_symlink() || !metadata.is_file() || metadata.len() > 256 * 1024 {
|
||||
return Err(CodexxError::Config(
|
||||
"皮肤运行状态文件不是安全的普通文件,已停止自动处理".to_string(),
|
||||
));
|
||||
}
|
||||
let bytes = fs::read(&path).map_err(|source| io_err(&path, source))?;
|
||||
let state: RuntimeStateFile = serde_json::from_slice(&bytes)
|
||||
.map_err(|error| CodexxError::Config(format!("解析皮肤运行状态失败: {error}")))?;
|
||||
if state.schema_version != RUNTIME_STATE_SCHEMA || !(1024..=65535).contains(&state.port) {
|
||||
return Err(CodexxError::Config(
|
||||
"皮肤运行状态版本或端口无效,已停止自动处理".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(Some(state))
|
||||
}
|
||||
|
||||
fn write_runtime_state(state: &RuntimeStateFile) -> Result<()> {
|
||||
let path = state_path()?;
|
||||
let text = serde_json::to_string_pretty(state)
|
||||
.map_err(|error| CodexxError::Config(format!("序列化皮肤运行状态失败: {error}")))?;
|
||||
atomic_write(&path, format!("{text}\n").as_bytes())?;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
fs::set_permissions(&path, fs::Permissions::from_mode(0o600))
|
||||
.map_err(|source| io_err(&path, source))
|
||||
}
|
||||
|
||||
fn remove_runtime_state() -> Result<()> {
|
||||
let path = state_path()?;
|
||||
match fs::remove_file(&path) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||
Err(error) => Err(io_err(&path, error)),
|
||||
}
|
||||
}
|
||||
|
||||
fn process_table() -> Vec<(u32, String)> {
|
||||
let Ok(output) = Command::new("/bin/ps")
|
||||
.args(["-axo", "pid=,command="])
|
||||
.output()
|
||||
else {
|
||||
return Vec::new();
|
||||
};
|
||||
String::from_utf8_lossy(&output.stdout)
|
||||
.lines()
|
||||
.filter_map(|line| {
|
||||
let trimmed = line.trim_start();
|
||||
let split = trimmed.find(char::is_whitespace)?;
|
||||
let pid = trimmed[..split].parse::<u32>().ok()?;
|
||||
Some((pid, trimmed[split..].trim_start().to_string()))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(super) fn process_command(pid: u32) -> Option<String> {
|
||||
let output = Command::new("/bin/ps")
|
||||
.args(["-p", &pid.to_string(), "-o", "command="])
|
||||
.output()
|
||||
.ok()?;
|
||||
let command = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
(!command.is_empty()).then_some(command)
|
||||
}
|
||||
|
||||
pub(super) fn process_started_at(pid: u32) -> Option<String> {
|
||||
let output = Command::new("/bin/ps")
|
||||
.args(["-p", &pid.to_string(), "-o", "lstart="])
|
||||
.output()
|
||||
.ok()?;
|
||||
let value = String::from_utf8_lossy(&output.stdout)
|
||||
.split_whitespace()
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
(!value.is_empty()).then_some(value)
|
||||
}
|
||||
|
||||
pub(super) fn process_alive(pid: u32) -> bool {
|
||||
pid > 0
|
||||
&& Command::new("/bin/kill")
|
||||
.args(["-0", &pid.to_string()])
|
||||
.status()
|
||||
.is_ok_and(|status| status.success())
|
||||
}
|
||||
|
||||
fn codex_main_pids(runtime: &CodexRuntime) -> Vec<u32> {
|
||||
let prefix = runtime.executable.to_string_lossy();
|
||||
process_table()
|
||||
.into_iter()
|
||||
.filter_map(|(pid, command)| command.starts_with(prefix.as_ref()).then_some(pid))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn codex_is_running(runtime: &CodexRuntime) -> bool {
|
||||
!codex_main_pids(runtime).is_empty()
|
||||
}
|
||||
|
||||
fn debug_port_from_command(command: &str) -> Option<u16> {
|
||||
command.split_whitespace().find_map(|argument| {
|
||||
argument
|
||||
.strip_prefix("--remote-debugging-port=")?
|
||||
.parse::<u16>()
|
||||
.ok()
|
||||
.filter(|port| *port >= 1024)
|
||||
})
|
||||
}
|
||||
|
||||
fn process_parent(pid: u32) -> Option<u32> {
|
||||
let output = Command::new("/bin/ps")
|
||||
.args(["-p", &pid.to_string(), "-o", "ppid="])
|
||||
.output()
|
||||
.ok()?;
|
||||
String::from_utf8_lossy(&output.stdout).trim().parse().ok()
|
||||
}
|
||||
|
||||
fn pid_is_codex_descendant(runtime: &CodexRuntime, mut pid: u32) -> bool {
|
||||
let executable = runtime.executable.to_string_lossy();
|
||||
for _ in 0..32 {
|
||||
if process_command(pid).is_some_and(|command| command.starts_with(executable.as_ref())) {
|
||||
return true;
|
||||
}
|
||||
let Some(parent) = process_parent(pid) else {
|
||||
return false;
|
||||
};
|
||||
if parent <= 1 || parent == pid {
|
||||
return false;
|
||||
}
|
||||
pid = parent;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn listener_pids(port: u16) -> Vec<u32> {
|
||||
let Ok(output) = Command::new("/usr/sbin/lsof")
|
||||
.args(["-nP", &format!("-iTCP:{port}"), "-sTCP:LISTEN", "-t"])
|
||||
.output()
|
||||
else {
|
||||
return Vec::new();
|
||||
};
|
||||
String::from_utf8_lossy(&output.stdout)
|
||||
.lines()
|
||||
.filter_map(|line| line.trim().parse().ok())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn port_belongs_to_codex(runtime: &CodexRuntime, port: u16) -> bool {
|
||||
let pids = listener_pids(port);
|
||||
!pids.is_empty()
|
||||
&& pids
|
||||
.into_iter()
|
||||
.all(|pid| pid_is_codex_descendant(runtime, pid))
|
||||
}
|
||||
|
||||
fn cdp_http_ready(port: u16) -> bool {
|
||||
let Ok(client) = reqwest::blocking::Client::builder()
|
||||
.no_proxy()
|
||||
.timeout(Duration::from_secs(2))
|
||||
.build()
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
client
|
||||
.get(format!("http://127.0.0.1:{port}/json/version"))
|
||||
.send()
|
||||
.is_ok_and(|response| response.status().is_success())
|
||||
}
|
||||
|
||||
fn verified_cdp_endpoint(runtime: &CodexRuntime, port: u16) -> bool {
|
||||
port_belongs_to_codex(runtime, port) && cdp_http_ready(port)
|
||||
}
|
||||
|
||||
fn running_cdp_port(runtime: &CodexRuntime) -> Option<u16> {
|
||||
codex_main_pids(runtime)
|
||||
.into_iter()
|
||||
.filter_map(process_command)
|
||||
.filter_map(|command| debug_port_from_command(&command))
|
||||
.find(|port| verified_cdp_endpoint(runtime, *port))
|
||||
}
|
||||
|
||||
fn select_available_port(preferred: u16) -> Result<u16> {
|
||||
let last = preferred.saturating_add(MAX_CDP_PORT_OFFSET);
|
||||
(preferred..=last)
|
||||
.find(|port| listener_pids(*port).is_empty())
|
||||
.ok_or_else(|| {
|
||||
CodexxError::Config(format!("未找到可用的本机 CDP 端口: {preferred}-{last}"))
|
||||
})
|
||||
}
|
||||
|
||||
fn wait_for_cdp(runtime: &CodexRuntime, port: u16, timeout: Duration) -> Result<()> {
|
||||
let deadline = Instant::now() + timeout;
|
||||
while Instant::now() < deadline {
|
||||
if verified_cdp_endpoint(runtime, port) {
|
||||
return Ok(());
|
||||
}
|
||||
thread::sleep(Duration::from_millis(350));
|
||||
}
|
||||
Err(CodexxError::Config(format!(
|
||||
"Codex 未在 127.0.0.1:{port} 打开经过验证的调试端口"
|
||||
)))
|
||||
}
|
||||
|
||||
fn external_skin_watcher_pid(own_state: Option<&RuntimeStateFile>) -> Option<u32> {
|
||||
process_table().into_iter().find_map(|(pid, command)| {
|
||||
let is_external = command.contains("codex-dream-skin-studio")
|
||||
&& command.contains("injector.mjs")
|
||||
&& command.contains("--watch");
|
||||
(is_external && own_state.is_none_or(|state| state.injector_pid != pid)).then_some(pid)
|
||||
})
|
||||
}
|
||||
|
||||
fn canonical_managed_snapshot(path: &Path) -> Option<PathBuf> {
|
||||
let metadata = fs::symlink_metadata(path).ok()?;
|
||||
if metadata.file_type().is_symlink() || !metadata.is_dir() {
|
||||
return None;
|
||||
}
|
||||
let root = fs::canonicalize(snapshots_root().ok()?).ok()?;
|
||||
let candidate = fs::canonicalize(path).ok()?;
|
||||
candidate.starts_with(&root).then_some(candidate)
|
||||
}
|
||||
|
||||
fn is_managed_injector(path: &Path) -> bool {
|
||||
let Ok(metadata) = fs::symlink_metadata(path) else {
|
||||
return false;
|
||||
};
|
||||
if metadata.file_type().is_symlink() || !metadata.is_file() {
|
||||
return false;
|
||||
}
|
||||
let Ok(root) = runtime_root()
|
||||
.and_then(|path| fs::canonicalize(&path).map_err(|source| io_err(&path, source)))
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
fs::canonicalize(path).is_ok_and(|candidate| {
|
||||
candidate.starts_with(root)
|
||||
&& candidate.file_name().and_then(|name| name.to_str()) == Some("injector.mjs")
|
||||
})
|
||||
}
|
||||
|
||||
fn state_process_matches(state: &RuntimeStateFile, runtime: &CodexRuntime) -> bool {
|
||||
if state.injector_pid == 0
|
||||
|| state.node_path != runtime.node.to_string_lossy()
|
||||
|| !is_managed_injector(Path::new(&state.injector_path))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if canonical_managed_snapshot(Path::new(&state.theme_dir)).is_none() {
|
||||
return false;
|
||||
}
|
||||
let Some(command) = process_command(state.injector_pid) else {
|
||||
return false;
|
||||
};
|
||||
let expected = format!(
|
||||
"{} --watch --port {} --theme-dir {}",
|
||||
state.injector_path, state.port, state.theme_dir
|
||||
);
|
||||
command.starts_with(runtime.node.to_string_lossy().as_ref())
|
||||
&& command.contains(&expected)
|
||||
&& process_started_at(state.injector_pid).as_deref()
|
||||
== Some(state.injector_started_at.as_str())
|
||||
}
|
||||
|
||||
fn stop_recorded_injector(state: &RuntimeStateFile, runtime: &CodexRuntime) -> Result<()> {
|
||||
if state.injector_pid == 0 || !process_alive(state.injector_pid) {
|
||||
return Ok(());
|
||||
}
|
||||
if !state_process_matches(state, runtime) {
|
||||
return Err(CodexxError::Config(format!(
|
||||
"记录的皮肤进程 PID {} 身份不匹配,已拒绝终止",
|
||||
state.injector_pid
|
||||
)));
|
||||
}
|
||||
let pid = state.injector_pid.to_string();
|
||||
let _ = Command::new("/bin/kill").args(["-TERM", &pid]).status();
|
||||
let deadline = Instant::now() + Duration::from_secs(6);
|
||||
while Instant::now() < deadline && state_process_matches(state, runtime) {
|
||||
thread::sleep(Duration::from_millis(150));
|
||||
}
|
||||
if state_process_matches(state, runtime) {
|
||||
let _ = Command::new("/bin/kill").args(["-KILL", &pid]).status();
|
||||
}
|
||||
let deadline = Instant::now() + Duration::from_secs(2);
|
||||
while Instant::now() < deadline && state_process_matches(state, runtime) {
|
||||
thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
if state_process_matches(state, runtime) {
|
||||
return Err(CodexxError::Config(format!(
|
||||
"无法停止皮肤注入器 PID {}",
|
||||
state.injector_pid
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn stop_codex(runtime: &CodexRuntime) -> Result<()> {
|
||||
let _ = Command::new("/usr/bin/osascript")
|
||||
.args(["-e", "tell application id \"com.openai.codex\" to quit"])
|
||||
.status();
|
||||
let deadline = Instant::now() + Duration::from_secs(15);
|
||||
while Instant::now() < deadline && codex_is_running(runtime) {
|
||||
thread::sleep(Duration::from_millis(250));
|
||||
}
|
||||
if !codex_is_running(runtime) {
|
||||
return Ok(());
|
||||
}
|
||||
for pid in codex_main_pids(runtime) {
|
||||
let _ = Command::new("/bin/kill")
|
||||
.args(["-TERM", &pid.to_string()])
|
||||
.status();
|
||||
}
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
while Instant::now() < deadline && codex_is_running(runtime) {
|
||||
thread::sleep(Duration::from_millis(250));
|
||||
}
|
||||
if codex_is_running(runtime) {
|
||||
for pid in codex_main_pids(runtime) {
|
||||
let _ = Command::new("/bin/kill")
|
||||
.args(["-KILL", &pid.to_string()])
|
||||
.status();
|
||||
}
|
||||
thread::sleep(Duration::from_millis(500));
|
||||
}
|
||||
if codex_is_running(runtime) {
|
||||
Err(CodexxError::Config(
|
||||
"无法安全关闭 Codex,换肤操作已中止".to_string(),
|
||||
))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn log_files(prefix: &str) -> Result<(File, File)> {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let root = logs_root()?;
|
||||
ensure_runtime_directory(&root)?;
|
||||
let stdout_path = root.join(format!("{prefix}.log"));
|
||||
let stderr_path = root.join(format!("{prefix}-error.log"));
|
||||
let stdout = OpenOptions::new()
|
||||
.create(true)
|
||||
.write(true)
|
||||
.truncate(true)
|
||||
.open(&stdout_path)
|
||||
.map_err(|source| io_err(&stdout_path, source))?;
|
||||
let stderr = OpenOptions::new()
|
||||
.create(true)
|
||||
.write(true)
|
||||
.truncate(true)
|
||||
.open(&stderr_path)
|
||||
.map_err(|source| io_err(&stderr_path, source))?;
|
||||
fs::set_permissions(&stdout_path, fs::Permissions::from_mode(0o600))
|
||||
.map_err(|source| io_err(&stdout_path, source))?;
|
||||
fs::set_permissions(&stderr_path, fs::Permissions::from_mode(0o600))
|
||||
.map_err(|source| io_err(&stderr_path, source))?;
|
||||
Ok((stdout, stderr))
|
||||
}
|
||||
|
||||
fn launch_codex_with_cdp(runtime: &CodexRuntime, port: u16) -> Result<()> {
|
||||
let (stdout, stderr) = log_files("codex-launch")?;
|
||||
let status = Command::new("/usr/bin/open")
|
||||
.arg("-na")
|
||||
.arg(&runtime.bundle)
|
||||
.arg("--args")
|
||||
.arg("--remote-debugging-address=127.0.0.1")
|
||||
.arg(format!("--remote-debugging-port={port}"))
|
||||
.stdout(Stdio::from(stdout))
|
||||
.stderr(Stdio::from(stderr))
|
||||
.status()
|
||||
.map_err(|source| io_err(&runtime.bundle, source))?;
|
||||
if !status.success() {
|
||||
return Err(CodexxError::Config(
|
||||
"使用本机调试端口启动 Codex 失败".to_string(),
|
||||
));
|
||||
}
|
||||
if wait_for_cdp(runtime, port, Duration::from_secs(12)).is_ok() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if codex_is_running(runtime) {
|
||||
stop_codex(runtime)?;
|
||||
}
|
||||
let (stdout, stderr) = log_files("codex-launch-direct")?;
|
||||
let mut child = Command::new(&runtime.executable)
|
||||
.arg("--remote-debugging-address=127.0.0.1")
|
||||
.arg(format!("--remote-debugging-port={port}"))
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::from(stdout))
|
||||
.stderr(Stdio::from(stderr))
|
||||
.spawn()
|
||||
.map_err(|source| io_err(&runtime.executable, source))?;
|
||||
thread::Builder::new()
|
||||
.name("codex-x-codex-launch-reaper".to_string())
|
||||
.spawn(move || {
|
||||
let _ = child.wait();
|
||||
})
|
||||
.map_err(|error| CodexxError::Config(format!("启动 Codex 进程监控失败: {error}")))?;
|
||||
wait_for_cdp(runtime, port, Duration::from_secs(45))
|
||||
}
|
||||
|
||||
fn launch_codex_normally(runtime: &CodexRuntime) -> Result<()> {
|
||||
let status = Command::new("/usr/bin/open")
|
||||
.arg("-na")
|
||||
.arg(&runtime.bundle)
|
||||
.status()
|
||||
.map_err(|source| io_err(&runtime.bundle, source))?;
|
||||
if status.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(CodexxError::Config(
|
||||
"恢复官方外观后重新启动 Codex 失败".to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn active_state(
|
||||
runtime: &CodexRuntime,
|
||||
assets: &RuntimeAssets,
|
||||
port: u16,
|
||||
theme_id: &str,
|
||||
theme_dir: &Path,
|
||||
pid: u32,
|
||||
started_at: String,
|
||||
) -> RuntimeStateFile {
|
||||
let timestamp = now_rfc3339();
|
||||
RuntimeStateFile {
|
||||
schema_version: RUNTIME_STATE_SCHEMA,
|
||||
runtime_version: RUNTIME_VERSION.to_string(),
|
||||
upstream_commit: UPSTREAM_COMMIT.to_string(),
|
||||
session: "active".to_string(),
|
||||
port,
|
||||
injector_pid: pid,
|
||||
injector_started_at: started_at,
|
||||
injector_path: assets.injector.to_string_lossy().to_string(),
|
||||
node_path: runtime.node.to_string_lossy().to_string(),
|
||||
node_version: runtime.node_version.clone(),
|
||||
codex_bundle: runtime.bundle.to_string_lossy().to_string(),
|
||||
codex_executable: runtime.executable.to_string_lossy().to_string(),
|
||||
codex_version: runtime.version.clone(),
|
||||
codex_team_id: runtime.team_id.clone(),
|
||||
theme_id: theme_id.to_string(),
|
||||
theme_dir: theme_dir.to_string_lossy().to_string(),
|
||||
created_at: timestamp.clone(),
|
||||
updated_at: timestamp,
|
||||
}
|
||||
}
|
||||
|
||||
fn cleanup_snapshot(path: &str, keep: &Path) {
|
||||
let Some(path) = canonical_managed_snapshot(Path::new(path)) else {
|
||||
return;
|
||||
};
|
||||
let keep = canonical_managed_snapshot(keep);
|
||||
if keep.as_ref() != Some(&path) {
|
||||
let _ = fs::remove_dir_all(&path);
|
||||
}
|
||||
}
|
||||
|
||||
fn restore_previous_after_failure(
|
||||
runtime: &CodexRuntime,
|
||||
assets: &RuntimeAssets,
|
||||
previous: Option<&RuntimeStateFile>,
|
||||
) {
|
||||
let Some(previous) = previous else {
|
||||
return;
|
||||
};
|
||||
let theme_dir = Path::new(&previous.theme_dir);
|
||||
if previous.session != "active"
|
||||
|| !theme_dir.is_dir()
|
||||
|| !verified_cdp_endpoint(runtime, previous.port)
|
||||
|| run_injector_once(runtime, assets, previous.port, theme_dir).is_err()
|
||||
{
|
||||
return;
|
||||
}
|
||||
if let Ok((pid, started_at)) = launch_watcher(runtime, assets, previous.port, theme_dir) {
|
||||
let restored = active_state(
|
||||
runtime,
|
||||
assets,
|
||||
previous.port,
|
||||
&previous.theme_id,
|
||||
theme_dir,
|
||||
pid,
|
||||
started_at,
|
||||
);
|
||||
let _ = write_runtime_state(&restored);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn apply_theme(
|
||||
source: &Path,
|
||||
theme_id: &str,
|
||||
restart_existing: bool,
|
||||
) -> Result<SkinRuntimeAction> {
|
||||
let runtime = discover_codex_runtime()?;
|
||||
let assets = ensure_runtime_assets()?;
|
||||
let previous = read_runtime_state()?;
|
||||
if let Some(pid) = external_skin_watcher_pid(previous.as_ref()) {
|
||||
return Err(CodexxError::Config(format!(
|
||||
"检测到外部 Codex Dream Skin 注入器 PID {pid},请先在原工具中暂停或恢复官方外观"
|
||||
)));
|
||||
}
|
||||
|
||||
let running = codex_is_running(&runtime);
|
||||
let verified_port = previous
|
||||
.as_ref()
|
||||
.map(|state| state.port)
|
||||
.filter(|port| verified_cdp_endpoint(&runtime, *port))
|
||||
.or_else(|| running_cdp_port(&runtime));
|
||||
if running && verified_port.is_none() && !restart_existing {
|
||||
return Ok(SkinRuntimeAction::RestartRequired(
|
||||
"Codex 需要重启一次才能打开仅限本机的换肤调试端口".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let snapshot = stage_theme_snapshot(&runtime, &assets, source, theme_id)?;
|
||||
let apply_result = (|| -> Result<RuntimeStateFile> {
|
||||
if let Some(state) = previous.as_ref() {
|
||||
stop_recorded_injector(state, &runtime)?;
|
||||
}
|
||||
let port = if let Some(port) = verified_port {
|
||||
port
|
||||
} else {
|
||||
if running {
|
||||
stop_codex(&runtime)?;
|
||||
}
|
||||
let preferred = previous
|
||||
.as_ref()
|
||||
.map_or(DEFAULT_CDP_PORT, |state| state.port);
|
||||
let port = select_available_port(preferred)?;
|
||||
launch_codex_with_cdp(&runtime, port)?;
|
||||
port
|
||||
};
|
||||
run_injector_once(&runtime, &assets, port, &snapshot)?;
|
||||
let (pid, started_at) = launch_watcher(&runtime, &assets, port, &snapshot)?;
|
||||
let state = active_state(
|
||||
&runtime, &assets, port, theme_id, &snapshot, pid, started_at,
|
||||
);
|
||||
if let Err(error) = write_runtime_state(&state) {
|
||||
let _ = stop_recorded_injector(&state, &runtime);
|
||||
return Err(error);
|
||||
}
|
||||
Ok(state)
|
||||
})();
|
||||
|
||||
match apply_result {
|
||||
Ok(_) => {
|
||||
if let Some(previous) = previous.as_ref() {
|
||||
cleanup_snapshot(&previous.theme_dir, &snapshot);
|
||||
}
|
||||
Ok(SkinRuntimeAction::Applied("已应用 Codex 皮肤".to_string()))
|
||||
}
|
||||
Err(error) => {
|
||||
let _ = fs::remove_dir_all(&snapshot);
|
||||
restore_previous_after_failure(&runtime, &assets, previous.as_ref());
|
||||
Err(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn pause_theme() -> Result<SkinRuntimeAction> {
|
||||
let assets = ensure_runtime_assets()?;
|
||||
let Some(mut state) = read_runtime_state()? else {
|
||||
return Ok(SkinRuntimeAction::Paused(
|
||||
"Codex 皮肤当前没有运行".to_string(),
|
||||
));
|
||||
};
|
||||
let runtime = discover_codex_runtime()?;
|
||||
if let Some(pid) = external_skin_watcher_pid(Some(&state)) {
|
||||
return Err(CodexxError::Config(format!(
|
||||
"检测到外部 Codex Dream Skin 注入器 PID {pid},未处理该进程"
|
||||
)));
|
||||
}
|
||||
stop_recorded_injector(&state, &runtime)?;
|
||||
if verified_cdp_endpoint(&runtime, state.port) {
|
||||
if let Err(error) =
|
||||
remove_live_skin(&runtime, &assets, state.port, Path::new(&state.theme_dir))
|
||||
{
|
||||
restore_previous_after_failure(&runtime, &assets, Some(&state));
|
||||
return Err(error);
|
||||
}
|
||||
}
|
||||
state.session = "paused".to_string();
|
||||
state.injector_pid = 0;
|
||||
state.injector_started_at.clear();
|
||||
state.updated_at = now_rfc3339();
|
||||
write_runtime_state(&state)?;
|
||||
Ok(SkinRuntimeAction::Paused(
|
||||
"已关闭 Codex 皮肤;Codex 保持打开".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
pub(super) fn restore_official(restart_existing: bool) -> Result<SkinRuntimeAction> {
|
||||
let assets = ensure_runtime_assets()?;
|
||||
let Some(state) = read_runtime_state()? else {
|
||||
return Ok(SkinRuntimeAction::Restored(
|
||||
"Codex 当前已使用官方外观".to_string(),
|
||||
));
|
||||
};
|
||||
let runtime = discover_codex_runtime()?;
|
||||
if let Some(pid) = external_skin_watcher_pid(Some(&state)) {
|
||||
return Err(CodexxError::Config(format!(
|
||||
"检测到外部 Codex Dream Skin 注入器 PID {pid},未处理该进程"
|
||||
)));
|
||||
}
|
||||
let running = codex_is_running(&runtime);
|
||||
let debug_ready = verified_cdp_endpoint(&runtime, state.port);
|
||||
if running && (debug_ready || state.session == "active") && !restart_existing {
|
||||
return Ok(SkinRuntimeAction::RestartRequired(
|
||||
"完整恢复需要重启 Codex,以关闭本机调试端口并清除当前渲染状态".to_string(),
|
||||
));
|
||||
}
|
||||
stop_recorded_injector(&state, &runtime)?;
|
||||
if debug_ready {
|
||||
remove_live_skin(&runtime, &assets, state.port, Path::new(&state.theme_dir))?;
|
||||
}
|
||||
if running && (debug_ready || state.session == "active") {
|
||||
stop_codex(&runtime)?;
|
||||
launch_codex_normally(&runtime)?;
|
||||
}
|
||||
remove_runtime_state()?;
|
||||
cleanup_snapshot(&state.theme_dir, Path::new(""));
|
||||
Ok(SkinRuntimeAction::Restored(
|
||||
"已恢复 Codex 官方外观并关闭 Codex-X 管理的换肤运行时".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
pub(super) fn runtime_status() -> SkinRuntimeStatus {
|
||||
if let Err(error) = ensure_runtime_assets() {
|
||||
return SkinRuntimeStatus {
|
||||
supported: true,
|
||||
active: false,
|
||||
phase: "error".to_string(),
|
||||
port: None,
|
||||
theme_id: None,
|
||||
message: error.to_string(),
|
||||
};
|
||||
}
|
||||
let state = match read_runtime_state() {
|
||||
Ok(Some(state)) => state,
|
||||
Ok(None) => {
|
||||
return SkinRuntimeStatus {
|
||||
supported: true,
|
||||
active: false,
|
||||
phase: "inactive".to_string(),
|
||||
port: None,
|
||||
theme_id: None,
|
||||
message: "尚未应用 Codex 皮肤".to_string(),
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
return SkinRuntimeStatus {
|
||||
supported: true,
|
||||
active: false,
|
||||
phase: "error".to_string(),
|
||||
port: None,
|
||||
theme_id: None,
|
||||
message: error.to_string(),
|
||||
}
|
||||
}
|
||||
};
|
||||
if state.session == "paused" {
|
||||
return SkinRuntimeStatus {
|
||||
supported: true,
|
||||
active: false,
|
||||
phase: "paused".to_string(),
|
||||
port: Some(state.port),
|
||||
theme_id: Some(state.theme_id),
|
||||
message: "皮肤已暂停,Codex 保持打开".to_string(),
|
||||
};
|
||||
}
|
||||
let runtime = match discover_codex_runtime() {
|
||||
Ok(runtime) => runtime,
|
||||
Err(error) => {
|
||||
return SkinRuntimeStatus {
|
||||
supported: true,
|
||||
active: false,
|
||||
phase: "unavailable".to_string(),
|
||||
port: Some(state.port),
|
||||
theme_id: Some(state.theme_id),
|
||||
message: error.to_string(),
|
||||
}
|
||||
}
|
||||
};
|
||||
let active =
|
||||
state_process_matches(&state, &runtime) && verified_cdp_endpoint(&runtime, state.port);
|
||||
SkinRuntimeStatus {
|
||||
supported: true,
|
||||
active,
|
||||
phase: if active { "active" } else { "stale" }.to_string(),
|
||||
port: Some(state.port),
|
||||
theme_id: Some(state.theme_id),
|
||||
message: if active {
|
||||
"Codex 皮肤运行中".to_string()
|
||||
} else {
|
||||
"皮肤运行状态已失效,可重新应用或恢复官方外观".to_string()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn debug_port_parser_accepts_only_bounded_numeric_arguments() {
|
||||
assert_eq!(
|
||||
debug_port_from_command(
|
||||
"/Applications/ChatGPT.app/Contents/MacOS/ChatGPT --remote-debugging-port=9341"
|
||||
),
|
||||
Some(9341)
|
||||
);
|
||||
assert_eq!(
|
||||
debug_port_from_command("Codex --remote-debugging-port=80"),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
debug_port_from_command("Codex --remote-debugging-port=not-a-port"),
|
||||
None
|
||||
);
|
||||
assert_eq!(debug_port_from_command("Codex"), None);
|
||||
}
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
use super::assets::RuntimeAssets;
|
||||
use super::macos::{log_files, process_alive, process_started_at, snapshots_root};
|
||||
use super::macos_system::CodexRuntime;
|
||||
use super::node_runtime;
|
||||
use crate::error::{CodexxError, Result};
|
||||
use crate::file_io::io_err;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Stdio};
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
pub(super) fn stage_theme_snapshot(
|
||||
runtime: &CodexRuntime,
|
||||
assets: &RuntimeAssets,
|
||||
source: &Path,
|
||||
theme_id: &str,
|
||||
) -> Result<PathBuf> {
|
||||
node_runtime::stage_theme_snapshot(&runtime.node, assets, &snapshots_root()?, source, theme_id)
|
||||
}
|
||||
|
||||
pub(super) fn run_injector_once(
|
||||
runtime: &CodexRuntime,
|
||||
assets: &RuntimeAssets,
|
||||
port: u16,
|
||||
theme_dir: &Path,
|
||||
) -> Result<()> {
|
||||
node_runtime::run_injector_once(&runtime.node, assets, port, theme_dir)
|
||||
}
|
||||
|
||||
pub(super) fn remove_live_skin(
|
||||
runtime: &CodexRuntime,
|
||||
assets: &RuntimeAssets,
|
||||
port: u16,
|
||||
theme_dir: &Path,
|
||||
) -> Result<()> {
|
||||
node_runtime::remove_live_skin(&runtime.node, assets, port, theme_dir)
|
||||
}
|
||||
|
||||
pub(super) fn launch_watcher(
|
||||
runtime: &CodexRuntime,
|
||||
assets: &RuntimeAssets,
|
||||
port: u16,
|
||||
theme_dir: &Path,
|
||||
) -> Result<(u32, String)> {
|
||||
let (stdout, stderr) = log_files("injector")?;
|
||||
let mut child = Command::new(&runtime.node)
|
||||
.arg(&assets.injector)
|
||||
.args(["--watch", "--port"])
|
||||
.arg(port.to_string())
|
||||
.arg("--theme-dir")
|
||||
.arg(theme_dir)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::from(stdout))
|
||||
.stderr(Stdio::from(stderr))
|
||||
.spawn()
|
||||
.map_err(|source| io_err(&assets.injector, source))?;
|
||||
let pid = child.id();
|
||||
thread::Builder::new()
|
||||
.name("codex-x-skin-injector-reaper".to_string())
|
||||
.spawn(move || {
|
||||
let _ = child.wait();
|
||||
})
|
||||
.map_err(|error| CodexxError::Config(format!("启动皮肤进程监控失败: {error}")))?;
|
||||
thread::sleep(Duration::from_millis(180));
|
||||
if !process_alive(pid) {
|
||||
return Err(CodexxError::Config(
|
||||
"皮肤注入器启动后立即退出,请检查运行日志".to_string(),
|
||||
));
|
||||
}
|
||||
let started_at = process_started_at(pid)
|
||||
.ok_or_else(|| CodexxError::Config("无法记录皮肤注入器启动时间".to_string()))?;
|
||||
Ok((pid, started_at))
|
||||
}
|
||||
@@ -1,199 +0,0 @@
|
||||
use super::node_runtime::command_detail;
|
||||
use crate::error::{CodexxError, Result};
|
||||
use crate::file_io::io_err;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
const EXPECTED_BUNDLE_ID: &str = "com.openai.codex";
|
||||
const EXPECTED_TEAM_ID: &str = "2DC432GLL2";
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(super) struct CodexRuntime {
|
||||
pub(super) bundle: PathBuf,
|
||||
pub(super) executable: PathBuf,
|
||||
pub(super) version: String,
|
||||
pub(super) node: PathBuf,
|
||||
pub(super) node_version: String,
|
||||
pub(super) team_id: String,
|
||||
}
|
||||
|
||||
fn output_text(program: &Path, args: &[&str], label: &str) -> Result<String> {
|
||||
let output = Command::new(program)
|
||||
.args(args)
|
||||
.output()
|
||||
.map_err(|source| io_err(program, source))?;
|
||||
if !output.status.success() {
|
||||
let detail = command_detail(&output);
|
||||
return Err(CodexxError::Config(if detail.is_empty() {
|
||||
format!("{label}失败")
|
||||
} else {
|
||||
format!("{label}失败: {detail}")
|
||||
}));
|
||||
}
|
||||
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
|
||||
}
|
||||
|
||||
fn plist_value(bundle: &Path, key: &str) -> Result<String> {
|
||||
let plist = bundle.join("Contents/Info.plist");
|
||||
output_text(
|
||||
Path::new("/usr/bin/plutil"),
|
||||
&["-extract", key, "raw", "-o", "-", &plist.to_string_lossy()],
|
||||
"读取 Codex 应用信息",
|
||||
)
|
||||
}
|
||||
|
||||
fn bundle_candidates() -> Result<Vec<PathBuf>> {
|
||||
let home = crate::paths::home_dir()?;
|
||||
let mut candidates = vec![
|
||||
PathBuf::from("/Applications/ChatGPT.app"),
|
||||
home.join("Applications/ChatGPT.app"),
|
||||
PathBuf::from("/Applications/Codex.app"),
|
||||
home.join("Applications/Codex.app"),
|
||||
];
|
||||
if let Ok(output) = Command::new("/usr/bin/mdfind")
|
||||
.arg(format!(
|
||||
"kMDItemCFBundleIdentifier == \"{EXPECTED_BUNDLE_ID}\""
|
||||
))
|
||||
.output()
|
||||
{
|
||||
if output.status.success() {
|
||||
candidates.extend(
|
||||
String::from_utf8_lossy(&output.stdout)
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.filter(|line| !line.is_empty())
|
||||
.map(PathBuf::from),
|
||||
);
|
||||
}
|
||||
}
|
||||
candidates.dedup();
|
||||
Ok(candidates)
|
||||
}
|
||||
|
||||
fn codesign_team_id(path: &Path) -> Result<String> {
|
||||
let output = Command::new("/usr/bin/codesign")
|
||||
.args(["-dv", "--verbose=4"])
|
||||
.arg(path)
|
||||
.output()
|
||||
.map_err(|source| io_err(path, source))?;
|
||||
let combined = format!(
|
||||
"{}\n{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
combined
|
||||
.lines()
|
||||
.find_map(|line| line.trim().strip_prefix("TeamIdentifier="))
|
||||
.map(str::to_string)
|
||||
.ok_or_else(|| CodexxError::Config("无法读取 Codex 签名 Team ID".to_string()))
|
||||
}
|
||||
|
||||
fn verify_signature(path: &Path, deep: bool) -> Result<()> {
|
||||
let mut command = Command::new("/usr/bin/codesign");
|
||||
command.arg("--verify");
|
||||
if deep {
|
||||
command.arg("--deep");
|
||||
}
|
||||
let output = command
|
||||
.arg("--strict")
|
||||
.arg(path)
|
||||
.output()
|
||||
.map_err(|source| io_err(path, source))?;
|
||||
if output.status.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(CodexxError::Config(format!(
|
||||
"官方 Codex 代码签名校验失败: {}",
|
||||
path.display()
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn discover_codex_runtime() -> Result<CodexRuntime> {
|
||||
let mut bundle = None;
|
||||
for candidate in bundle_candidates()? {
|
||||
if !candidate.join("Contents/Info.plist").is_file() {
|
||||
continue;
|
||||
}
|
||||
if plist_value(&candidate, "CFBundleIdentifier")
|
||||
.ok()
|
||||
.as_deref()
|
||||
== Some(EXPECTED_BUNDLE_ID)
|
||||
{
|
||||
bundle = Some(candidate);
|
||||
break;
|
||||
}
|
||||
}
|
||||
let bundle = bundle.ok_or_else(|| {
|
||||
CodexxError::Config("未找到官方 Codex Desktop (com.openai.codex)".to_string())
|
||||
})?;
|
||||
let executable_name = plist_value(&bundle, "CFBundleExecutable")?;
|
||||
if executable_name.is_empty() || executable_name.contains('/') || executable_name.contains('\\')
|
||||
{
|
||||
return Err(CodexxError::Config(
|
||||
"Codex 应用声明了无效的可执行文件名".to_string(),
|
||||
));
|
||||
}
|
||||
let executable = bundle.join("Contents/MacOS").join(executable_name);
|
||||
let node = bundle.join("Contents/Resources/cua_node/bin/node");
|
||||
if !executable.is_file() || !node.is_file() {
|
||||
return Err(CodexxError::Config(
|
||||
"Codex 可执行文件或内置 Node.js 不存在,请更新或重新安装官方 Codex".to_string(),
|
||||
));
|
||||
}
|
||||
verify_signature(&bundle, true)?;
|
||||
verify_signature(&node, false)?;
|
||||
let team_id = codesign_team_id(&bundle)?;
|
||||
let node_team_id = codesign_team_id(&node)?;
|
||||
if team_id != EXPECTED_TEAM_ID || node_team_id != team_id {
|
||||
return Err(CodexxError::Config(format!(
|
||||
"Codex 签名身份不匹配,拒绝启动换肤运行时: {team_id}"
|
||||
)));
|
||||
}
|
||||
let node_version = output_text(&node, &["--version"], "读取 Codex Node.js 版本")?;
|
||||
let node_major = node_version
|
||||
.trim_start_matches('v')
|
||||
.split('.')
|
||||
.next()
|
||||
.and_then(|value| value.parse::<u32>().ok())
|
||||
.ok_or_else(|| CodexxError::Config(format!("无法解析 Node.js 版本: {node_version}")))?;
|
||||
if node_major < 20 {
|
||||
return Err(CodexxError::Config(format!(
|
||||
"Codex 内置 Node.js 版本过低: {node_version},需要 20 或更高版本"
|
||||
)));
|
||||
}
|
||||
let machine_arch = output_text(Path::new("/usr/bin/uname"), &["-m"], "读取系统架构")?;
|
||||
let node_file = output_text(
|
||||
Path::new("/usr/bin/file"),
|
||||
&[&node.to_string_lossy()],
|
||||
"读取 Codex Node.js 架构",
|
||||
)?;
|
||||
if !node_file.contains(&machine_arch) {
|
||||
return Err(CodexxError::Config(format!(
|
||||
"Codex 内置 Node.js 与当前 Mac 架构不匹配: {machine_arch}"
|
||||
)));
|
||||
}
|
||||
Ok(CodexRuntime {
|
||||
version: plist_value(&bundle, "CFBundleShortVersionString")?,
|
||||
bundle,
|
||||
executable,
|
||||
node,
|
||||
node_version,
|
||||
team_id,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn command_detail_is_bounded() {
|
||||
let output = Command::new("/usr/bin/printf")
|
||||
.arg("%0900d")
|
||||
.arg("1")
|
||||
.output()
|
||||
.expect("create fixture output");
|
||||
assert!(command_detail(&output).chars().count() <= 800);
|
||||
}
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
|
||||
use crate::error::CodexxError;
|
||||
use crate::error::Result;
|
||||
use serde::Serialize;
|
||||
use std::path::Path;
|
||||
|
||||
mod assets;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod macos;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod macos_injector;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod macos_system;
|
||||
mod node_runtime;
|
||||
#[cfg(any(target_os = "windows", feature = "windows-runtime-check"))]
|
||||
#[cfg_attr(feature = "windows-runtime-check", allow(dead_code))]
|
||||
mod windows;
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct SkinRuntimeStatus {
|
||||
pub(crate) supported: bool,
|
||||
pub(crate) active: bool,
|
||||
pub(crate) phase: String,
|
||||
pub(crate) port: Option<u16>,
|
||||
pub(crate) theme_id: Option<String>,
|
||||
pub(crate) message: String,
|
||||
}
|
||||
|
||||
impl SkinRuntimeStatus {
|
||||
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
|
||||
fn unsupported() -> Self {
|
||||
Self {
|
||||
supported: false,
|
||||
active: false,
|
||||
phase: "unsupported".to_string(),
|
||||
port: None,
|
||||
theme_id: None,
|
||||
message: "当前版本的 Codex 实机换肤仅支持 macOS 和 Windows".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) enum SkinRuntimeAction {
|
||||
Applied(String),
|
||||
RestartRequired(String),
|
||||
Paused(String),
|
||||
Restored(String),
|
||||
}
|
||||
|
||||
pub(crate) fn skin_runtime_status() -> SkinRuntimeStatus {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
macos::runtime_status()
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
windows::runtime_status()
|
||||
}
|
||||
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
|
||||
{
|
||||
SkinRuntimeStatus::unsupported()
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn apply_skin_runtime(
|
||||
theme_dir: &Path,
|
||||
theme_id: &str,
|
||||
restart_existing: bool,
|
||||
) -> Result<SkinRuntimeAction> {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
macos::apply_theme(theme_dir, theme_id, restart_existing)
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
windows::apply_theme(theme_dir, theme_id, restart_existing)
|
||||
}
|
||||
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
|
||||
{
|
||||
let _ = (theme_dir, theme_id, restart_existing);
|
||||
Err(CodexxError::Config(
|
||||
"当前版本的 Codex 实机换肤仅支持 macOS 和 Windows".to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn pause_skin_runtime() -> Result<SkinRuntimeAction> {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
macos::pause_theme()
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
windows::pause_theme()
|
||||
}
|
||||
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
|
||||
{
|
||||
Err(CodexxError::Config(
|
||||
"当前版本的 Codex 实机换肤仅支持 macOS 和 Windows".to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn restore_skin_runtime(restart_existing: bool) -> Result<SkinRuntimeAction> {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
macos::restore_official(restart_existing)
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
windows::restore_official(restart_existing)
|
||||
}
|
||||
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
|
||||
{
|
||||
let _ = restart_existing;
|
||||
Err(CodexxError::Config(
|
||||
"当前版本的 Codex 实机换肤仅支持 macOS 和 Windows".to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -1,234 +0,0 @@
|
||||
use super::assets::{ensure_runtime_directory, RuntimeAssets};
|
||||
use crate::error::{CodexxError, Result};
|
||||
use crate::file_io::io_err;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Stdio};
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
const COMMAND_POLL_INTERVAL: Duration = Duration::from_millis(25);
|
||||
const THEME_STAGE_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
const PAYLOAD_CHECK_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
const INJECTOR_ONCE_TIMEOUT: Duration = Duration::from_secs(35);
|
||||
const INJECTOR_REMOVE_TIMEOUT: Duration = Duration::from_secs(20);
|
||||
|
||||
pub(super) fn command_detail(output: &std::process::Output) -> String {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
let detail = if stderr.is_empty() { stdout } else { stderr };
|
||||
if detail.chars().count() <= 800 {
|
||||
detail
|
||||
} else {
|
||||
detail
|
||||
.chars()
|
||||
.rev()
|
||||
.take(800)
|
||||
.collect::<String>()
|
||||
.chars()
|
||||
.rev()
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn wait_for_output(
|
||||
mut child: std::process::Child,
|
||||
program: &Path,
|
||||
label: &str,
|
||||
timeout: Duration,
|
||||
) -> Result<std::process::Output> {
|
||||
let deadline = Instant::now() + timeout;
|
||||
loop {
|
||||
match child.try_wait().map_err(|source| io_err(program, source))? {
|
||||
Some(_) => {
|
||||
return child
|
||||
.wait_with_output()
|
||||
.map_err(|source| io_err(program, source))
|
||||
}
|
||||
None if Instant::now() >= deadline => {
|
||||
let _ = child.kill();
|
||||
let output = child
|
||||
.wait_with_output()
|
||||
.map_err(|source| io_err(program, source))?;
|
||||
let detail = command_detail(&output);
|
||||
let suffix = if detail.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(": {detail}")
|
||||
};
|
||||
return Err(CodexxError::Config(format!(
|
||||
"{label}超时({} 秒){suffix}",
|
||||
timeout.as_secs()
|
||||
)));
|
||||
}
|
||||
None => thread::sleep(COMMAND_POLL_INTERVAL),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn run_node(
|
||||
node: &Path,
|
||||
script: &Path,
|
||||
args: &[String],
|
||||
label: &str,
|
||||
timeout: Duration,
|
||||
) -> Result<String> {
|
||||
let child = Command::new(node)
|
||||
.arg(script)
|
||||
.args(args)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|source| io_err(script, source))?;
|
||||
let output = wait_for_output(child, script, label, timeout)?;
|
||||
if output.status.success() {
|
||||
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
|
||||
} else {
|
||||
let detail = command_detail(&output);
|
||||
Err(CodexxError::Config(if detail.is_empty() {
|
||||
format!("{label}失败")
|
||||
} else {
|
||||
format!("{label}失败: {detail}")
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn stage_theme_snapshot(
|
||||
node: &Path,
|
||||
assets: &RuntimeAssets,
|
||||
snapshots_root: &Path,
|
||||
source: &Path,
|
||||
theme_id: &str,
|
||||
) -> Result<PathBuf> {
|
||||
ensure_runtime_directory(snapshots_root)?;
|
||||
let snapshot = snapshots_root.join(format!(
|
||||
"{}-{}-{}",
|
||||
theme_id,
|
||||
std::process::id(),
|
||||
chrono::Local::now()
|
||||
.timestamp_nanos_opt()
|
||||
.unwrap_or_default()
|
||||
));
|
||||
ensure_runtime_directory(&snapshot)?;
|
||||
let result = (|| -> Result<()> {
|
||||
run_node(
|
||||
node,
|
||||
&assets.stage_theme,
|
||||
&[
|
||||
source.to_string_lossy().to_string(),
|
||||
snapshot.to_string_lossy().to_string(),
|
||||
],
|
||||
"暂存主题",
|
||||
THEME_STAGE_TIMEOUT,
|
||||
)?;
|
||||
run_node(
|
||||
node,
|
||||
&assets.injector,
|
||||
&[
|
||||
"--check-payload".to_string(),
|
||||
"--theme-dir".to_string(),
|
||||
snapshot.to_string_lossy().to_string(),
|
||||
],
|
||||
"校验主题注入负载",
|
||||
PAYLOAD_CHECK_TIMEOUT,
|
||||
)?;
|
||||
Ok(())
|
||||
})();
|
||||
if result.is_err() {
|
||||
let _ = fs::remove_dir_all(&snapshot);
|
||||
}
|
||||
result.map(|_| snapshot)
|
||||
}
|
||||
|
||||
pub(super) fn run_injector_once(
|
||||
node: &Path,
|
||||
assets: &RuntimeAssets,
|
||||
port: u16,
|
||||
theme_dir: &Path,
|
||||
) -> Result<()> {
|
||||
run_node(
|
||||
node,
|
||||
&assets.injector,
|
||||
&[
|
||||
"--once".to_string(),
|
||||
"--port".to_string(),
|
||||
port.to_string(),
|
||||
"--theme-dir".to_string(),
|
||||
theme_dir.to_string_lossy().to_string(),
|
||||
"--timeout-ms".to_string(),
|
||||
"20000".to_string(),
|
||||
],
|
||||
"注入并验证 Codex 皮肤",
|
||||
INJECTOR_ONCE_TIMEOUT,
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn remove_live_skin(
|
||||
node: &Path,
|
||||
assets: &RuntimeAssets,
|
||||
port: u16,
|
||||
theme_dir: &Path,
|
||||
) -> Result<()> {
|
||||
run_node(
|
||||
node,
|
||||
&assets.injector,
|
||||
&[
|
||||
"--remove".to_string(),
|
||||
"--port".to_string(),
|
||||
port.to_string(),
|
||||
"--theme-dir".to_string(),
|
||||
theme_dir.to_string_lossy().to_string(),
|
||||
"--timeout-ms".to_string(),
|
||||
"10000".to_string(),
|
||||
],
|
||||
"移除 Codex 皮肤",
|
||||
INJECTOR_REMOVE_TIMEOUT,
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn wait_for_output_collects_a_completed_child() {
|
||||
let child = Command::new("/bin/echo")
|
||||
.arg("skin-ready")
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.expect("spawn echo fixture");
|
||||
let output = wait_for_output(
|
||||
child,
|
||||
Path::new("/bin/echo"),
|
||||
"测试皮肤命令",
|
||||
Duration::from_secs(1),
|
||||
)
|
||||
.expect("collect output");
|
||||
assert!(output.status.success());
|
||||
assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "skin-ready");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wait_for_output_terminates_a_stuck_child() {
|
||||
let child = Command::new("/bin/sleep")
|
||||
.arg("5")
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.expect("spawn sleep fixture");
|
||||
let started = Instant::now();
|
||||
let error = wait_for_output(
|
||||
child,
|
||||
Path::new("/bin/sleep"),
|
||||
"测试皮肤命令",
|
||||
Duration::from_millis(80),
|
||||
)
|
||||
.expect_err("timeout stuck child");
|
||||
assert!(error.to_string().contains("超时"));
|
||||
assert!(started.elapsed() < Duration::from_secs(2));
|
||||
}
|
||||
}
|
||||
@@ -1,869 +0,0 @@
|
||||
use super::assets::{ensure_runtime_assets, ensure_runtime_directory, RuntimeAssets};
|
||||
use super::node_runtime;
|
||||
use super::{SkinRuntimeAction, SkinRuntimeStatus};
|
||||
use crate::error::{CodexxError, Result};
|
||||
use crate::file_io::{atomic_write, io_err};
|
||||
use crate::now_rfc3339;
|
||||
use crate::skins::skins_root;
|
||||
use serde::{de::DeserializeOwned, Deserialize, Serialize};
|
||||
use std::env;
|
||||
use std::fs::{self, File, OpenOptions};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Stdio};
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
use std::os::windows::process::CommandExt;
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
|
||||
const DEFAULT_CDP_PORT: u16 = 9341;
|
||||
const RUNTIME_STATE_SCHEMA: u32 = 1;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct RuntimeStateFile {
|
||||
schema_version: u32,
|
||||
platform: String,
|
||||
session: String,
|
||||
port: u16,
|
||||
browser_id: String,
|
||||
injector_pid: u32,
|
||||
injector_started_at: String,
|
||||
injector_path: String,
|
||||
node_path: String,
|
||||
node_version: String,
|
||||
codex_package_root: String,
|
||||
codex_executable: String,
|
||||
codex_version: String,
|
||||
codex_package_full_name: String,
|
||||
codex_package_family_name: String,
|
||||
codex_app_user_model_id: String,
|
||||
theme_id: String,
|
||||
theme_dir: String,
|
||||
created_at: String,
|
||||
updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct CodexRuntime {
|
||||
package_root: PathBuf,
|
||||
executable: PathBuf,
|
||||
version: String,
|
||||
package_full_name: String,
|
||||
package_family_name: String,
|
||||
app_user_model_id: String,
|
||||
running: bool,
|
||||
debug_port: Option<u16>,
|
||||
browser_id: Option<String>,
|
||||
watcher_pids: Vec<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct PortResult {
|
||||
port: u16,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct LaunchResult {
|
||||
port: u16,
|
||||
browser_id: String,
|
||||
strategy: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct VerifyPortResult {
|
||||
verified: bool,
|
||||
browser_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ProcessInfo {
|
||||
alive: bool,
|
||||
path: Option<String>,
|
||||
command_line: Option<String>,
|
||||
started_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ActiveResult {
|
||||
active: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct NodeRuntime {
|
||||
path: PathBuf,
|
||||
version: String,
|
||||
}
|
||||
|
||||
struct ActiveStateContext<'a> {
|
||||
runtime: &'a CodexRuntime,
|
||||
node: &'a NodeRuntime,
|
||||
assets: &'a RuntimeAssets,
|
||||
port: u16,
|
||||
browser_id: String,
|
||||
theme_id: &'a str,
|
||||
theme_dir: &'a Path,
|
||||
injector_pid: u32,
|
||||
injector_started_at: String,
|
||||
}
|
||||
|
||||
fn runtime_root() -> Result<PathBuf> {
|
||||
Ok(skins_root()?.join("runtime"))
|
||||
}
|
||||
|
||||
fn state_path() -> Result<PathBuf> {
|
||||
Ok(runtime_root()?.join("state.json"))
|
||||
}
|
||||
|
||||
fn snapshots_root() -> Result<PathBuf> {
|
||||
Ok(runtime_root()?.join("themes"))
|
||||
}
|
||||
|
||||
fn logs_root() -> Result<PathBuf> {
|
||||
Ok(runtime_root()?.join("logs"))
|
||||
}
|
||||
|
||||
fn powershell_path() -> PathBuf {
|
||||
env::var_os("SystemRoot")
|
||||
.map(PathBuf::from)
|
||||
.map(|root| {
|
||||
root.join("System32")
|
||||
.join("WindowsPowerShell")
|
||||
.join("v1.0")
|
||||
.join("powershell.exe")
|
||||
})
|
||||
.filter(|path| path.is_file())
|
||||
.unwrap_or_else(|| PathBuf::from("powershell.exe"))
|
||||
}
|
||||
|
||||
fn configure_background_command(command: &mut Command) {
|
||||
#[cfg(target_os = "windows")]
|
||||
command.creation_flags(CREATE_NO_WINDOW);
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
let _ = command;
|
||||
}
|
||||
|
||||
fn parse_last_json<T: DeserializeOwned>(stdout: &[u8], label: &str) -> Result<T> {
|
||||
let text = String::from_utf8_lossy(stdout);
|
||||
text.lines()
|
||||
.rev()
|
||||
.map(str::trim)
|
||||
.filter(|line| !line.is_empty())
|
||||
.find_map(|line| serde_json::from_str(line).ok())
|
||||
.ok_or_else(|| CodexxError::Config(format!("{label}没有返回有效结果")))
|
||||
}
|
||||
|
||||
fn run_adapter<T: DeserializeOwned>(
|
||||
assets: &RuntimeAssets,
|
||||
action: &str,
|
||||
args: &[String],
|
||||
timeout: Duration,
|
||||
) -> Result<T> {
|
||||
let powershell = powershell_path();
|
||||
let mut command = Command::new(&powershell);
|
||||
command
|
||||
.args(["-NoLogo", "-NoProfile", "-NonInteractive", "-File"])
|
||||
.arg(&assets.windows_adapter)
|
||||
.args(["-Action", action])
|
||||
.args(args)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
configure_background_command(&mut command);
|
||||
let child = command
|
||||
.spawn()
|
||||
.map_err(|source| io_err(&powershell, source))?;
|
||||
let output = node_runtime::wait_for_output(child, &powershell, action, timeout)?;
|
||||
if !output.status.success() {
|
||||
let detail = node_runtime::command_detail(&output);
|
||||
return Err(CodexxError::Config(if detail.is_empty() {
|
||||
format!("Windows 皮肤操作失败: {action}")
|
||||
} else {
|
||||
format!("Windows 皮肤操作失败: {detail}")
|
||||
}));
|
||||
}
|
||||
parse_last_json(&output.stdout, action)
|
||||
}
|
||||
|
||||
fn inspect_codex(assets: &RuntimeAssets) -> Result<CodexRuntime> {
|
||||
run_adapter(assets, "inspect", &[], Duration::from_secs(15))
|
||||
}
|
||||
|
||||
fn select_port(assets: &RuntimeAssets, preferred: u16) -> Result<u16> {
|
||||
let result: PortResult = run_adapter(
|
||||
assets,
|
||||
"selectPort",
|
||||
&["-Port".to_string(), preferred.to_string()],
|
||||
Duration::from_secs(10),
|
||||
)?;
|
||||
Ok(result.port)
|
||||
}
|
||||
|
||||
fn verify_port(assets: &RuntimeAssets, port: u16) -> Result<VerifyPortResult> {
|
||||
run_adapter(
|
||||
assets,
|
||||
"verifyPort",
|
||||
&["-Port".to_string(), port.to_string()],
|
||||
Duration::from_secs(10),
|
||||
)
|
||||
}
|
||||
|
||||
fn stop_codex(assets: &RuntimeAssets) -> Result<()> {
|
||||
let _: serde_json::Value = run_adapter(assets, "stopCodex", &[], Duration::from_secs(25))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn launch_codex_normally(assets: &RuntimeAssets) -> Result<()> {
|
||||
let _: serde_json::Value = run_adapter(assets, "launchNormal", &[], Duration::from_secs(15))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn launch_codex_with_cdp(assets: &RuntimeAssets, port: u16) -> Result<LaunchResult> {
|
||||
let result: LaunchResult = run_adapter(
|
||||
assets,
|
||||
"launch",
|
||||
&["-Port".to_string(), port.to_string()],
|
||||
Duration::from_secs(60),
|
||||
)?;
|
||||
if result.port != port || result.browser_id.is_empty() {
|
||||
return Err(CodexxError::Config(
|
||||
"Windows Codex 启动器返回了无效的调试端口身份".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn read_runtime_state() -> Result<Option<RuntimeStateFile>> {
|
||||
let path = state_path()?;
|
||||
let metadata = match fs::symlink_metadata(&path) {
|
||||
Ok(metadata) => metadata,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
|
||||
Err(error) => return Err(io_err(&path, error)),
|
||||
};
|
||||
if metadata.file_type().is_symlink() || !metadata.is_file() || metadata.len() > 256 * 1024 {
|
||||
return Err(CodexxError::Config(
|
||||
"皮肤运行状态文件不是安全的普通文件,已停止自动处理".to_string(),
|
||||
));
|
||||
}
|
||||
let bytes = fs::read(&path).map_err(|source| io_err(&path, source))?;
|
||||
let state: RuntimeStateFile = serde_json::from_slice(&bytes)
|
||||
.map_err(|error| CodexxError::Config(format!("解析皮肤运行状态失败: {error}")))?;
|
||||
if state.schema_version != RUNTIME_STATE_SCHEMA
|
||||
|| state.platform != "windows"
|
||||
|| !(1024..=65535).contains(&state.port)
|
||||
{
|
||||
return Err(CodexxError::Config(
|
||||
"皮肤运行状态版本或端口无效,已停止自动处理".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(Some(state))
|
||||
}
|
||||
|
||||
fn write_runtime_state(state: &RuntimeStateFile) -> Result<()> {
|
||||
let path = state_path()?;
|
||||
let text = serde_json::to_string_pretty(state)
|
||||
.map_err(|error| CodexxError::Config(format!("序列化皮肤运行状态失败: {error}")))?;
|
||||
atomic_write(&path, format!("{text}\n").as_bytes())
|
||||
}
|
||||
|
||||
fn remove_runtime_state() -> Result<()> {
|
||||
let path = state_path()?;
|
||||
match fs::remove_file(&path) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||
Err(error) => Err(io_err(&path, error)),
|
||||
}
|
||||
}
|
||||
|
||||
fn node_version(path: &Path) -> Result<String> {
|
||||
let mut command = Command::new(path);
|
||||
command.arg("--version");
|
||||
configure_background_command(&mut command);
|
||||
let output = command.output().map_err(|source| io_err(path, source))?;
|
||||
if !output.status.success() {
|
||||
return Err(CodexxError::Config(format!(
|
||||
"无法运行 Windows 皮肤 Node.js: {}",
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
let version = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
let major = version
|
||||
.trim_start_matches('v')
|
||||
.split('.')
|
||||
.next()
|
||||
.and_then(|value| value.parse::<u32>().ok())
|
||||
.ok_or_else(|| CodexxError::Config(format!("无法解析 Node.js 版本: {version}")))?;
|
||||
if major < 22 {
|
||||
return Err(CodexxError::Config(format!(
|
||||
"Windows 皮肤运行时需要 Node.js 22 或更高版本,当前为 {version}"
|
||||
)));
|
||||
}
|
||||
Ok(version)
|
||||
}
|
||||
|
||||
fn resolve_node_candidate(path: &Path) -> Option<PathBuf> {
|
||||
if let Ok(canonical) = fs::canonicalize(path) {
|
||||
return Some(canonical);
|
||||
}
|
||||
if path.components().count() != 1 {
|
||||
return None;
|
||||
}
|
||||
let mut command = Command::new("where.exe");
|
||||
command.arg(path);
|
||||
configure_background_command(&mut command);
|
||||
let output = command.output().ok()?;
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
String::from_utf8_lossy(&output.stdout)
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.filter(|line| !line.is_empty())
|
||||
.map(PathBuf::from)
|
||||
.find_map(|candidate| fs::canonicalize(candidate).ok())
|
||||
}
|
||||
|
||||
fn discover_node_runtime() -> Result<NodeRuntime> {
|
||||
let mut candidates = Vec::new();
|
||||
if let Some(path) = env::var_os("CODEX_X_SKIN_NODE") {
|
||||
candidates.push(PathBuf::from(path));
|
||||
}
|
||||
if let Ok(executable) = env::current_exe() {
|
||||
if let Some(parent) = executable.parent() {
|
||||
candidates.push(parent.join("skin-runtime").join("node").join("node.exe"));
|
||||
}
|
||||
}
|
||||
candidates.push(PathBuf::from("node.exe"));
|
||||
for candidate in candidates {
|
||||
if let Some(path) = resolve_node_candidate(&candidate) {
|
||||
if let Ok(version) = node_version(&path) {
|
||||
return Ok(NodeRuntime { path, version });
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(CodexxError::Config(
|
||||
"Windows 皮肤运行时缺少内置 Node.js;请重新安装 Codex-X".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
fn canonical_managed_snapshot(path: &Path) -> Option<PathBuf> {
|
||||
let metadata = fs::symlink_metadata(path).ok()?;
|
||||
if metadata.file_type().is_symlink() || !metadata.is_dir() {
|
||||
return None;
|
||||
}
|
||||
let root = fs::canonicalize(snapshots_root().ok()?).ok()?;
|
||||
let candidate = fs::canonicalize(path).ok()?;
|
||||
candidate.starts_with(&root).then_some(candidate)
|
||||
}
|
||||
|
||||
fn cleanup_snapshot(path: &str, keep: &Path) {
|
||||
let Some(path) = canonical_managed_snapshot(Path::new(path)) else {
|
||||
return;
|
||||
};
|
||||
let keep = canonical_managed_snapshot(keep);
|
||||
if keep.as_ref() != Some(&path) {
|
||||
let _ = fs::remove_dir_all(path);
|
||||
}
|
||||
}
|
||||
|
||||
fn process_info(assets: &RuntimeAssets, pid: u32) -> Result<ProcessInfo> {
|
||||
run_adapter(
|
||||
assets,
|
||||
"processInfo",
|
||||
&["-TargetPid".to_string(), pid.to_string()],
|
||||
Duration::from_secs(10),
|
||||
)
|
||||
}
|
||||
|
||||
fn injector_active(assets: &RuntimeAssets) -> Result<bool> {
|
||||
let result: ActiveResult = run_adapter(
|
||||
assets,
|
||||
"injectorStatus",
|
||||
&[
|
||||
"-StatePath".to_string(),
|
||||
state_path()?.to_string_lossy().to_string(),
|
||||
],
|
||||
Duration::from_secs(10),
|
||||
)?;
|
||||
Ok(result.active)
|
||||
}
|
||||
|
||||
fn write_adapter_state(state: &RuntimeStateFile) -> Result<PathBuf> {
|
||||
let root = runtime_root()?;
|
||||
ensure_runtime_directory(&root)?;
|
||||
let path = root.join(format!(
|
||||
".process-state-{}-{}.json",
|
||||
std::process::id(),
|
||||
state.injector_pid
|
||||
));
|
||||
let text = serde_json::to_string(state)
|
||||
.map_err(|error| CodexxError::Config(format!("序列化皮肤进程状态失败: {error}")))?;
|
||||
atomic_write(&path, text.as_bytes())?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
fn stop_recorded_injector(assets: &RuntimeAssets, state: &RuntimeStateFile) -> Result<()> {
|
||||
if state.injector_pid == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
let info = process_info(assets, state.injector_pid)?;
|
||||
if !info.alive {
|
||||
return Ok(());
|
||||
}
|
||||
let adapter_state = write_adapter_state(state)?;
|
||||
let result = run_adapter::<serde_json::Value>(
|
||||
assets,
|
||||
"stopInjector",
|
||||
&[
|
||||
"-StatePath".to_string(),
|
||||
adapter_state.to_string_lossy().to_string(),
|
||||
],
|
||||
Duration::from_secs(15),
|
||||
);
|
||||
let _ = fs::remove_file(&adapter_state);
|
||||
result.map(|_| ())
|
||||
}
|
||||
|
||||
fn log_files(prefix: &str) -> Result<(File, File)> {
|
||||
let root = logs_root()?;
|
||||
ensure_runtime_directory(&root)?;
|
||||
let stdout_path = root.join(format!("{prefix}.log"));
|
||||
let stderr_path = root.join(format!("{prefix}-error.log"));
|
||||
let stdout = OpenOptions::new()
|
||||
.create(true)
|
||||
.write(true)
|
||||
.truncate(true)
|
||||
.open(&stdout_path)
|
||||
.map_err(|source| io_err(&stdout_path, source))?;
|
||||
let stderr = OpenOptions::new()
|
||||
.create(true)
|
||||
.write(true)
|
||||
.truncate(true)
|
||||
.open(&stderr_path)
|
||||
.map_err(|source| io_err(&stderr_path, source))?;
|
||||
Ok((stdout, stderr))
|
||||
}
|
||||
|
||||
fn launch_watcher(
|
||||
node: &NodeRuntime,
|
||||
assets: &RuntimeAssets,
|
||||
port: u16,
|
||||
theme_dir: &Path,
|
||||
) -> Result<(u32, String)> {
|
||||
let (stdout, stderr) = log_files("injector")?;
|
||||
let mut command = Command::new(&node.path);
|
||||
command
|
||||
.arg(&assets.injector)
|
||||
.args(["--watch", "--port"])
|
||||
.arg(port.to_string())
|
||||
.arg("--theme-dir")
|
||||
.arg(theme_dir)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::from(stdout))
|
||||
.stderr(Stdio::from(stderr));
|
||||
configure_background_command(&mut command);
|
||||
let mut child = command
|
||||
.spawn()
|
||||
.map_err(|source| io_err(&assets.injector, source))?;
|
||||
let pid = child.id();
|
||||
thread::sleep(Duration::from_millis(250));
|
||||
if child
|
||||
.try_wait()
|
||||
.map_err(|source| io_err(&assets.injector, source))?
|
||||
.is_some()
|
||||
{
|
||||
return Err(CodexxError::Config(
|
||||
"皮肤注入器启动后立即退出,请检查运行日志".to_string(),
|
||||
));
|
||||
}
|
||||
let info = process_info(assets, pid)?;
|
||||
let started_at = info
|
||||
.started_at
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| CodexxError::Config("无法记录皮肤注入器启动时间".to_string()))?;
|
||||
let expected_node = node.path.to_string_lossy();
|
||||
let path_matches = info.path.as_deref().is_some_and(|path| {
|
||||
Path::new(path)
|
||||
.canonicalize()
|
||||
.is_ok_and(|actual| actual == node.path)
|
||||
});
|
||||
let command_matches = info.command_line.as_deref().is_some_and(|command| {
|
||||
command.contains(assets.injector.to_string_lossy().as_ref())
|
||||
&& command.contains("--watch")
|
||||
&& command.contains(&format!("--port {port}"))
|
||||
});
|
||||
if !info.alive
|
||||
|| (!path_matches
|
||||
&& !expected_node.eq_ignore_ascii_case(info.path.as_deref().unwrap_or("")))
|
||||
|| !command_matches
|
||||
{
|
||||
let _ = child.kill();
|
||||
return Err(CodexxError::Config(
|
||||
"Windows 皮肤注入器进程身份校验失败".to_string(),
|
||||
));
|
||||
}
|
||||
drop(child);
|
||||
Ok((pid, started_at))
|
||||
}
|
||||
|
||||
fn active_state(context: ActiveStateContext<'_>) -> RuntimeStateFile {
|
||||
let timestamp = now_rfc3339();
|
||||
RuntimeStateFile {
|
||||
schema_version: RUNTIME_STATE_SCHEMA,
|
||||
platform: "windows".to_string(),
|
||||
session: "active".to_string(),
|
||||
port: context.port,
|
||||
browser_id: context.browser_id,
|
||||
injector_pid: context.injector_pid,
|
||||
injector_started_at: context.injector_started_at,
|
||||
injector_path: context.assets.injector.to_string_lossy().to_string(),
|
||||
node_path: context.node.path.to_string_lossy().to_string(),
|
||||
node_version: context.node.version.clone(),
|
||||
codex_package_root: context.runtime.package_root.to_string_lossy().to_string(),
|
||||
codex_executable: context.runtime.executable.to_string_lossy().to_string(),
|
||||
codex_version: context.runtime.version.clone(),
|
||||
codex_package_full_name: context.runtime.package_full_name.clone(),
|
||||
codex_package_family_name: context.runtime.package_family_name.clone(),
|
||||
codex_app_user_model_id: context.runtime.app_user_model_id.clone(),
|
||||
theme_id: context.theme_id.to_string(),
|
||||
theme_dir: context.theme_dir.to_string_lossy().to_string(),
|
||||
created_at: timestamp.clone(),
|
||||
updated_at: timestamp,
|
||||
}
|
||||
}
|
||||
|
||||
fn restore_previous_after_failure(
|
||||
runtime: &CodexRuntime,
|
||||
node: &NodeRuntime,
|
||||
assets: &RuntimeAssets,
|
||||
previous: Option<&RuntimeStateFile>,
|
||||
) {
|
||||
let Some(previous) = previous else { return };
|
||||
let theme_dir = Path::new(&previous.theme_dir);
|
||||
let verified = verify_port(assets, previous.port).is_ok_and(|result| result.verified);
|
||||
if previous.session != "active"
|
||||
|| !theme_dir.is_dir()
|
||||
|| !verified
|
||||
|| node_runtime::run_injector_once(&node.path, assets, previous.port, theme_dir).is_err()
|
||||
{
|
||||
return;
|
||||
}
|
||||
if let Ok((pid, started_at)) = launch_watcher(node, assets, previous.port, theme_dir) {
|
||||
let restored = active_state(ActiveStateContext {
|
||||
runtime,
|
||||
node,
|
||||
assets,
|
||||
port: previous.port,
|
||||
browser_id: previous.browser_id.clone(),
|
||||
theme_id: &previous.theme_id,
|
||||
theme_dir,
|
||||
injector_pid: pid,
|
||||
injector_started_at: started_at,
|
||||
});
|
||||
let _ = write_runtime_state(&restored);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn apply_theme(
|
||||
source: &Path,
|
||||
theme_id: &str,
|
||||
restart_existing: bool,
|
||||
) -> Result<SkinRuntimeAction> {
|
||||
let assets = ensure_runtime_assets()?;
|
||||
let runtime = inspect_codex(&assets)?;
|
||||
let node = discover_node_runtime()?;
|
||||
let previous = read_runtime_state()?;
|
||||
if let Some(pid) = runtime.watcher_pids.iter().copied().find(|pid| {
|
||||
previous
|
||||
.as_ref()
|
||||
.is_none_or(|state| state.injector_pid != *pid)
|
||||
}) {
|
||||
return Err(CodexxError::Config(format!(
|
||||
"检测到外部 Codex 换肤注入器 PID {pid},请先在原工具中关闭皮肤"
|
||||
)));
|
||||
}
|
||||
if runtime.running && runtime.debug_port.is_none() && !restart_existing {
|
||||
return Ok(SkinRuntimeAction::RestartRequired(
|
||||
"Codex 需要重启一次才能打开仅限本机的换肤调试端口".to_string(),
|
||||
));
|
||||
}
|
||||
let snapshot = node_runtime::stage_theme_snapshot(
|
||||
&node.path,
|
||||
&assets,
|
||||
&snapshots_root()?,
|
||||
source,
|
||||
theme_id,
|
||||
)?;
|
||||
let mut launched_new = false;
|
||||
let apply_result = (|| -> Result<RuntimeStateFile> {
|
||||
if let Some(state) = previous.as_ref() {
|
||||
stop_recorded_injector(&assets, state)?;
|
||||
}
|
||||
let (port, browser_id) = if let Some(port) = runtime.debug_port {
|
||||
let browser_id = runtime.browser_id.clone().ok_or_else(|| {
|
||||
CodexxError::Config("无法验证当前 Windows Codex 调试会话".to_string())
|
||||
})?;
|
||||
(port, browser_id)
|
||||
} else {
|
||||
if runtime.running {
|
||||
stop_codex(&assets)?;
|
||||
}
|
||||
let preferred = previous
|
||||
.as_ref()
|
||||
.map_or(DEFAULT_CDP_PORT, |state| state.port);
|
||||
let port = select_port(&assets, preferred)?;
|
||||
let launch = launch_codex_with_cdp(&assets, port)?;
|
||||
launched_new = true;
|
||||
let _strategy = launch.strategy;
|
||||
(port, launch.browser_id)
|
||||
};
|
||||
node_runtime::run_injector_once(&node.path, &assets, port, &snapshot)?;
|
||||
let (pid, started_at) = launch_watcher(&node, &assets, port, &snapshot)?;
|
||||
let state = active_state(ActiveStateContext {
|
||||
runtime: &runtime,
|
||||
node: &node,
|
||||
assets: &assets,
|
||||
port,
|
||||
browser_id,
|
||||
theme_id,
|
||||
theme_dir: &snapshot,
|
||||
injector_pid: pid,
|
||||
injector_started_at: started_at,
|
||||
});
|
||||
if let Err(error) = write_runtime_state(&state) {
|
||||
let _ = stop_recorded_injector(&assets, &state);
|
||||
return Err(error);
|
||||
}
|
||||
Ok(state)
|
||||
})();
|
||||
match apply_result {
|
||||
Ok(_) => {
|
||||
if let Some(previous) = previous.as_ref() {
|
||||
cleanup_snapshot(&previous.theme_dir, &snapshot);
|
||||
}
|
||||
Ok(SkinRuntimeAction::Applied(
|
||||
"已应用 Windows Codex 皮肤".to_string(),
|
||||
))
|
||||
}
|
||||
Err(error) => {
|
||||
let _ = fs::remove_dir_all(&snapshot);
|
||||
restore_previous_after_failure(&runtime, &node, &assets, previous.as_ref());
|
||||
if launched_new && previous.is_none() {
|
||||
let _ = stop_codex(&assets);
|
||||
let _ = launch_codex_normally(&assets);
|
||||
}
|
||||
Err(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn pause_theme() -> Result<SkinRuntimeAction> {
|
||||
let assets = ensure_runtime_assets()?;
|
||||
let Some(mut state) = read_runtime_state()? else {
|
||||
return Ok(SkinRuntimeAction::Paused(
|
||||
"Codex 皮肤当前没有运行".to_string(),
|
||||
));
|
||||
};
|
||||
let node = discover_node_runtime()?;
|
||||
stop_recorded_injector(&assets, &state)?;
|
||||
if verify_port(&assets, state.port).is_ok_and(|result| result.verified) {
|
||||
node_runtime::remove_live_skin(
|
||||
&node.path,
|
||||
&assets,
|
||||
state.port,
|
||||
Path::new(&state.theme_dir),
|
||||
)?;
|
||||
}
|
||||
state.session = "paused".to_string();
|
||||
state.injector_pid = 0;
|
||||
state.injector_started_at.clear();
|
||||
state.updated_at = now_rfc3339();
|
||||
write_runtime_state(&state)?;
|
||||
Ok(SkinRuntimeAction::Paused(
|
||||
"已关闭 Codex 皮肤;Codex 保持打开".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
pub(super) fn restore_official(restart_existing: bool) -> Result<SkinRuntimeAction> {
|
||||
let assets = ensure_runtime_assets()?;
|
||||
let Some(state) = read_runtime_state()? else {
|
||||
return Ok(SkinRuntimeAction::Restored(
|
||||
"Codex 当前已使用官方外观".to_string(),
|
||||
));
|
||||
};
|
||||
let node = discover_node_runtime()?;
|
||||
let runtime = inspect_codex(&assets)?;
|
||||
let debug_ready = verify_port(&assets, state.port).is_ok_and(|result| result.verified);
|
||||
if runtime.running && (debug_ready || state.session == "active") && !restart_existing {
|
||||
return Ok(SkinRuntimeAction::RestartRequired(
|
||||
"完整恢复需要重启 Codex,以关闭本机调试端口并清除当前渲染状态".to_string(),
|
||||
));
|
||||
}
|
||||
stop_recorded_injector(&assets, &state)?;
|
||||
if debug_ready {
|
||||
node_runtime::remove_live_skin(
|
||||
&node.path,
|
||||
&assets,
|
||||
state.port,
|
||||
Path::new(&state.theme_dir),
|
||||
)?;
|
||||
}
|
||||
if runtime.running && (debug_ready || state.session == "active") {
|
||||
stop_codex(&assets)?;
|
||||
launch_codex_normally(&assets)?;
|
||||
}
|
||||
remove_runtime_state()?;
|
||||
cleanup_snapshot(&state.theme_dir, Path::new(""));
|
||||
Ok(SkinRuntimeAction::Restored(
|
||||
"已恢复 Windows Codex 官方外观并关闭换肤运行时".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
pub(super) fn runtime_status() -> SkinRuntimeStatus {
|
||||
let assets = match ensure_runtime_assets() {
|
||||
Ok(assets) => assets,
|
||||
Err(error) => {
|
||||
return SkinRuntimeStatus {
|
||||
supported: true,
|
||||
active: false,
|
||||
phase: "error".to_string(),
|
||||
port: None,
|
||||
theme_id: None,
|
||||
message: error.to_string(),
|
||||
}
|
||||
}
|
||||
};
|
||||
let state = match read_runtime_state() {
|
||||
Ok(Some(state)) => state,
|
||||
Ok(None) => {
|
||||
return SkinRuntimeStatus {
|
||||
supported: true,
|
||||
active: false,
|
||||
phase: "inactive".to_string(),
|
||||
port: None,
|
||||
theme_id: None,
|
||||
message: "尚未应用 Codex 皮肤".to_string(),
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
return SkinRuntimeStatus {
|
||||
supported: true,
|
||||
active: false,
|
||||
phase: "error".to_string(),
|
||||
port: None,
|
||||
theme_id: None,
|
||||
message: error.to_string(),
|
||||
}
|
||||
}
|
||||
};
|
||||
if state.session == "paused" {
|
||||
return SkinRuntimeStatus {
|
||||
supported: true,
|
||||
active: false,
|
||||
phase: "paused".to_string(),
|
||||
port: Some(state.port),
|
||||
theme_id: Some(state.theme_id),
|
||||
message: "皮肤已关闭,Codex 保持打开".to_string(),
|
||||
};
|
||||
}
|
||||
if let Err(error) = inspect_codex(&assets) {
|
||||
return SkinRuntimeStatus {
|
||||
supported: true,
|
||||
active: false,
|
||||
phase: "unavailable".to_string(),
|
||||
port: Some(state.port),
|
||||
theme_id: Some(state.theme_id),
|
||||
message: error.to_string(),
|
||||
};
|
||||
}
|
||||
let active = injector_active(&assets).unwrap_or(false)
|
||||
&& verify_port(&assets, state.port).is_ok_and(|result| {
|
||||
result.verified && result.browser_id.as_deref() == Some(state.browser_id.as_str())
|
||||
});
|
||||
SkinRuntimeStatus {
|
||||
supported: true,
|
||||
active,
|
||||
phase: if active { "active" } else { "stale" }.to_string(),
|
||||
port: Some(state.port),
|
||||
theme_id: Some(state.theme_id),
|
||||
message: if active {
|
||||
"Windows Codex 皮肤运行中".to_string()
|
||||
} else {
|
||||
"皮肤运行状态已失效,可重新应用主题".to_string()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn test_state() -> RuntimeStateFile {
|
||||
RuntimeStateFile {
|
||||
schema_version: 1,
|
||||
platform: "windows".to_string(),
|
||||
session: "active".to_string(),
|
||||
port: 9341,
|
||||
browser_id: "browser-1".to_string(),
|
||||
injector_pid: 42,
|
||||
injector_started_at: "2026-07-24T00:00:00.0000000Z".to_string(),
|
||||
injector_path: r"C:\Users\test\.codexx\runtime\injector.mjs".to_string(),
|
||||
node_path: r"C:\Program Files\Codex-X\skin-runtime\node\node.exe".to_string(),
|
||||
node_version: "v22.23.1".to_string(),
|
||||
codex_package_root: r"C:\Program Files\WindowsApps\OpenAI.Codex_1.0.0.0_x64"
|
||||
.to_string(),
|
||||
codex_executable:
|
||||
r"C:\Program Files\WindowsApps\OpenAI.Codex_1.0.0.0_x64\app\ChatGPT.exe".to_string(),
|
||||
codex_version: "1.0.0.0".to_string(),
|
||||
codex_package_full_name: "OpenAI.Codex_1.0.0.0_x64__test".to_string(),
|
||||
codex_package_family_name: "OpenAI.Codex_test".to_string(),
|
||||
codex_app_user_model_id: "OpenAI.Codex_test!App".to_string(),
|
||||
theme_id: "theme".to_string(),
|
||||
theme_dir: r"C:\Users\test\.codexx\runtime\themes\theme".to_string(),
|
||||
created_at: "2026-07-24T00:00:00Z".to_string(),
|
||||
updated_at: "2026-07-24T00:00:00Z".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_last_json_line_after_powershell_warnings() {
|
||||
let result: PortResult = parse_last_json(
|
||||
b"WARNING: package activation fallback\r\n{\"port\":9342}\r\n",
|
||||
"test",
|
||||
)
|
||||
.expect("parse adapter JSON");
|
||||
assert_eq!(result.port, 9342);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_state_uses_powershell_contract_field_names() {
|
||||
let value = serde_json::to_value(test_state()).expect("serialize state");
|
||||
assert_eq!(value["platform"], "windows");
|
||||
assert_eq!(value["injectorPid"], 42);
|
||||
assert_eq!(
|
||||
value["injectorPath"],
|
||||
r"C:\Users\test\.codexx\runtime\injector.mjs"
|
||||
);
|
||||
assert_eq!(
|
||||
value["nodePath"],
|
||||
r"C:\Program Files\Codex-X\skin-runtime\node\node.exe"
|
||||
);
|
||||
assert_eq!(value["browserId"], "browser-1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adapter_keeps_store_identity_and_loopback_guards() {
|
||||
let source = include_str!("../../resources/skin-runtime/windows/codexx-windows.ps1");
|
||||
assert!(source.contains("Get-DreamSkinCodexInstall"));
|
||||
assert!(source.contains("Start-DreamSkinCodexForDebugging"));
|
||||
assert!(source.contains("--remote-debugging-address=127.0.0.1"));
|
||||
assert!(source.contains("Get-DreamSkinVerifiedCdpIdentity"));
|
||||
assert!(!source.contains("takeown"));
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -1,16 +1,15 @@
|
||||
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,
|
||||
};
|
||||
use crate::providers::{
|
||||
detected_live_custom_provider, document_is_official, list_saved_providers_inner,
|
||||
official_auth_available, unique_saved_provider_id_for_live, SavedProvider,
|
||||
clear_active_provider_on_connection, detected_live_custom_provider, document_is_official,
|
||||
list_saved_providers_on_connection, matching_saved_provider_ids_for_live_on_connection,
|
||||
official_auth_available, open_store, reconcile_active_provider_on_connection, SavedProvider,
|
||||
};
|
||||
use crate::{auth_path, config_path, string_value};
|
||||
use serde::Serialize;
|
||||
@@ -33,7 +32,7 @@ struct ProviderSummary {
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct CodexState {
|
||||
codex_dir: String,
|
||||
pub(crate) codex_dir: String,
|
||||
config_path: String,
|
||||
auth_path: String,
|
||||
config_exists: bool,
|
||||
@@ -47,7 +46,8 @@ pub(crate) struct CodexState {
|
||||
pub(crate) instruction_injection_mode: Option<String>,
|
||||
pub(crate) instruction_template_key: Option<String>,
|
||||
agents_path: String,
|
||||
active_saved_provider_id: Option<String>,
|
||||
pub(crate) active_saved_provider_id: Option<String>,
|
||||
pub(crate) active_official_profile_id: Option<String>,
|
||||
providers: Vec<ProviderSummary>,
|
||||
pub(crate) config_text: String,
|
||||
auth_preview: Option<Value>,
|
||||
@@ -69,7 +69,13 @@ fn redacted_auth_preview(path: &Path) -> Result<Option<Value>> {
|
||||
return Ok(None);
|
||||
}
|
||||
let text = fs::read_to_string(path).map_err(|e| io_err(path, e))?;
|
||||
let mut value: Value = serde_json::from_str(&text).map_err(|e| json_err(path, e))?;
|
||||
// Keep the UI responsive when another tool or an interrupted write leaves
|
||||
// auth.json malformed. Mutating flows validate or replace auth explicitly;
|
||||
// the read-only state path should still report the rest of the config.
|
||||
let mut value: Value = match serde_json::from_str(&text) {
|
||||
Ok(value) => value,
|
||||
Err(_) => return Ok(None),
|
||||
};
|
||||
if let Some(obj) = value.as_object_mut() {
|
||||
for (key, val) in obj.iter_mut() {
|
||||
let lower = key.to_ascii_lowercase();
|
||||
@@ -155,15 +161,15 @@ fn normalized_provider_toml_for_match(text: &str) -> String {
|
||||
.to_string()
|
||||
}
|
||||
|
||||
pub(crate) fn active_saved_provider_id_from_config(
|
||||
fn matching_saved_provider_ids_from_config(
|
||||
config_text: &str,
|
||||
providers: &[SavedProvider],
|
||||
) -> Option<String> {
|
||||
) -> Vec<String> {
|
||||
let live = normalized_provider_toml_for_match(config_text);
|
||||
if live.is_empty() {
|
||||
return None;
|
||||
return Vec::new();
|
||||
}
|
||||
let matches = providers
|
||||
providers
|
||||
.iter()
|
||||
.filter(|provider| {
|
||||
provider
|
||||
@@ -171,10 +177,20 @@ pub(crate) fn active_saved_provider_id_from_config(
|
||||
.as_deref()
|
||||
.is_some_and(|toml| normalized_provider_toml_for_match(toml) == live)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
(matches.len() == 1).then(|| matches[0].id.clone())
|
||||
.map(|provider| provider.id.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn active_saved_provider_id_from_config(
|
||||
config_text: &str,
|
||||
providers: &[SavedProvider],
|
||||
) -> Option<String> {
|
||||
let matches = matching_saved_provider_ids_from_config(config_text, providers);
|
||||
(matches.len() == 1).then(|| matches[0].clone())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn build_state(codex_dir: PathBuf) -> Result<CodexState> {
|
||||
migrate_legacy_prompt_config(&codex_dir)?;
|
||||
build_state_after_migration(codex_dir)
|
||||
@@ -183,8 +199,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");
|
||||
@@ -207,13 +221,22 @@ pub(crate) fn build_state_after_migration(codex_dir: PathBuf) -> Result<CodexSta
|
||||
};
|
||||
let instruction_enabled = instruction_template_key.is_some();
|
||||
let providers = extract_providers(&doc, model_provider.as_deref());
|
||||
let saved_providers = list_saved_providers_inner()?;
|
||||
let conn = open_store()?;
|
||||
let saved_providers = list_saved_providers_on_connection(&conn)?;
|
||||
let active_saved_provider_id = if is_official_provider {
|
||||
clear_active_provider_on_connection(&conn, &codex_dir)?;
|
||||
None
|
||||
} else if let Some(live) = detected_live_custom_provider(&codex_dir)? {
|
||||
unique_saved_provider_id_for_live(&live, &saved_providers)
|
||||
let candidates = matching_saved_provider_ids_for_live_on_connection(
|
||||
&conn,
|
||||
&codex_dir,
|
||||
&live,
|
||||
&saved_providers,
|
||||
)?;
|
||||
reconcile_active_provider_on_connection(&conn, &codex_dir, &candidates)?
|
||||
} else {
|
||||
active_saved_provider_id_from_config(&text, &saved_providers)
|
||||
let candidates = matching_saved_provider_ids_from_config(&text, &saved_providers);
|
||||
reconcile_active_provider_on_connection(&conn, &codex_dir, &candidates)?
|
||||
};
|
||||
|
||||
Ok(CodexState {
|
||||
@@ -232,10 +255,16 @@ pub(crate) fn build_state_after_migration(codex_dir: PathBuf) -> Result<CodexSta
|
||||
instruction_template_key,
|
||||
agents_path: agents_path(&codex_dir).display().to_string(),
|
||||
active_saved_provider_id,
|
||||
active_official_profile_id: crate::providers::official_profiles::active_profile_id(
|
||||
&codex_dir,
|
||||
is_official_provider,
|
||||
)?,
|
||||
providers,
|
||||
config_text: text,
|
||||
auth_preview: redacted_auth_preview(&auth)?,
|
||||
auth_text: read_to_string_if_exists(&auth)?,
|
||||
last_backup: latest_backup()?,
|
||||
// Backup discovery can traverse years of history. Keep core state fast;
|
||||
// restore actions resolve history only when the user requests it.
|
||||
last_backup: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Codex-X",
|
||||
"version": "0.3.6",
|
||||
"version": "0.3.17",
|
||||
"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;
|
||||
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { CheckCircle2, Crown, Gem, KeyRound, Mail, ShieldCheck, Sparkles, UserRound } from "lucide-react";
|
||||
import { getOfficialPlan } from "../officialPlan";
|
||||
import "../styles/official-account-badge.css";
|
||||
|
||||
export type OfficialAccountBadgeProps = {
|
||||
lang: "zh" | "en";
|
||||
email?: string | null;
|
||||
hasAuth: boolean;
|
||||
canQueryQuota: boolean;
|
||||
planType?: string | null;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function OfficialAccountBadge({ lang, email, hasAuth, canQueryQuota, planType, className = "" }: OfficialAccountBadgeProps) {
|
||||
const signedOut = !hasAuth;
|
||||
const label = signedOut
|
||||
? (lang === "zh" ? "未登录" : "Not signed in")
|
||||
: !canQueryQuota
|
||||
? (lang === "zh" ? "已保存认证" : "Saved credentials")
|
||||
: email || (lang === "zh" ? "已登录" : "Signed in");
|
||||
const Icon = signedOut ? UserRound : !canQueryQuota ? KeyRound : email ? Mail : CheckCircle2;
|
||||
const state = signedOut ? "signed-out" : canQueryQuota ? "signed-in" : "saved";
|
||||
const plan = hasAuth && canQueryQuota ? getOfficialPlan(planType, lang) : null;
|
||||
const PlanIcon = plan?.tone === "pro20" ? Crown : plan?.tone === "pro5" ? Gem : plan?.tone === "enterprise" ? ShieldCheck : Sparkles;
|
||||
|
||||
return <span className={`cx-official-account ${className}`.trim()}>
|
||||
<span className={`cx-official-account-badge cx-official-account-badge--${state}`} title={label}>
|
||||
<Icon size={14} strokeWidth={1.8} aria-hidden="true" />
|
||||
<span>{label}</span>
|
||||
</span>
|
||||
{plan && <span className={`cx-official-plan cx-official-plan--${plan.tone}`} title={`${lang === "zh" ? "套餐" : "Plan"}:${plan.label}`}>
|
||||
{plan.tone !== "free" && plan.tone !== "neutral" && <PlanIcon size={12} aria-hidden="true" />}
|
||||
<span>{plan.label}</span>
|
||||
</span>}
|
||||
</span>;
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
import { useCallback, useEffect, useReducer, useRef, useState } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { AlertCircle, CheckCircle2, ChevronDown, Clock3, Gauge, Loader2, RefreshCw, Sparkles, Ticket } from "lucide-react";
|
||||
import { Button, ModalShell } from "./ui";
|
||||
import { OfficialAccountBadge } from "./OfficialAccountBadge";
|
||||
import type { OfficialQuotaLimit, OfficialQuotaSnapshot, OfficialQuotaWindow, OfficialResetCreditsSnapshot } from "../officialQuotaTypes";
|
||||
import { initialOfficialQuotaState, loadOfficialQuotaDetails, officialQuotaReducer } from "../officialQuotaState";
|
||||
import { getOfficialPlan } from "../officialPlan";
|
||||
import "../styles/official-quota.css";
|
||||
|
||||
type Language = "zh" | "en";
|
||||
type QuotaProfile = { id: string; name: string; email: string | null; planType?: string | null };
|
||||
|
||||
export type OfficialQuotaDialogProps = {
|
||||
lang: Language;
|
||||
configDir: string;
|
||||
profile: QuotaProfile | null;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
function getCopy(lang: Language) {
|
||||
return lang === "zh" ? {
|
||||
title: "官方 Codex 额度", close: "关闭", refresh: "刷新额度", refreshing: "查询中",
|
||||
loading: "正在查询此账号的可用额度…", loadingHint: "可以随时关闭窗口,稍后重新查询。",
|
||||
error: "暂时无法查询额度", previous: "刷新失败,当前保留上次查询结果。",
|
||||
accountUnknown: "未提供账号邮箱", planUnknown: "未提供套餐信息", remaining: "剩余额度", used: "已使用",
|
||||
resetUnknown: "未提供重置时间", resetDue: "已到重置时间,请刷新", percentUnknown: "未提供百分比",
|
||||
windowUnknown: "未知窗口", weekly: "每周额度", main: "通用额度", others: "其他额度",
|
||||
empty: "暂未返回通用额度", emptyHint: "服务未提供此账号的通用额度数据,可稍后刷新查询。",
|
||||
noWindow: "未提供额度窗口", noWindowHint: "服务未提供此项额度的百分比或重置时间。",
|
||||
unavailable: "当前不可用", reached: "额度已用完", available: "可用", checked: "查询于",
|
||||
codeReview: "代码审查", mismatch: "返回的额度不属于当前账号,请重新查询。",
|
||||
snapshotHint: "额度以最近一次查询结果为准。倒计时结束后请刷新确认。",
|
||||
resets: "可用重置", resetLoading: "查询中", resetError: "暂时无法查询重置次数",
|
||||
resetPrevious: "重置次数刷新失败,保留上次查询结果。", resetInvalid: "服务未返回有效的可用重置次数。",
|
||||
} : {
|
||||
title: "Official Codex quota", close: "Close", refresh: "Refresh quota", refreshing: "Checking",
|
||||
loading: "Checking this account’s available quota…", loadingHint: "You can close this window and check again later.",
|
||||
error: "Unable to check quota", previous: "Refresh failed. The last available results are shown.",
|
||||
accountUnknown: "Account email not provided", planUnknown: "Plan not provided", remaining: "Remaining", used: "Used",
|
||||
resetUnknown: "Reset time not provided", resetDue: "Reset time reached. Refresh to check.", percentUnknown: "Percentage not provided",
|
||||
windowUnknown: "Unknown window", weekly: "Weekly quota", main: "General quota", others: "Other quotas",
|
||||
empty: "General quota not returned", emptyHint: "The service did not provide this account’s general quota. Try refreshing later.",
|
||||
noWindow: "Quota window not provided", noWindowHint: "The service did not provide percentages or reset times for this quota.",
|
||||
unavailable: "Currently unavailable", reached: "Limit reached", available: "Available", checked: "Checked",
|
||||
codeReview: "Code review", mismatch: "The returned quota belongs to a different account. Please try again.",
|
||||
snapshotHint: "Quota reflects the last query. Refresh after the countdown ends to confirm availability.",
|
||||
resets: "Available resets", resetLoading: "Checking", resetError: "Unable to check available resets",
|
||||
resetPrevious: "Reset count refresh failed. The last result is shown.", resetInvalid: "The service did not return a valid available reset count.",
|
||||
};
|
||||
}
|
||||
|
||||
type QuotaCopy = ReturnType<typeof getCopy>;
|
||||
|
||||
function finitePercent(value: number | null): number | null {
|
||||
return value !== null && Number.isFinite(value) ? Math.min(100, Math.max(0, value)) : null;
|
||||
}
|
||||
|
||||
function formatPercent(value: number): string {
|
||||
return Number(value.toFixed(1)).toString();
|
||||
}
|
||||
|
||||
function formatWindow(seconds: number | null, lang: Language, copy: QuotaCopy): string {
|
||||
if (seconds === null || !Number.isFinite(seconds) || seconds <= 0) return copy.windowUnknown;
|
||||
if (seconds === 604_800) return copy.weekly;
|
||||
let rest = Math.floor(seconds);
|
||||
const values: string[] = [];
|
||||
const units: [number, string, string][] = [[86_400, "天", "d"], [3_600, "小时", "h"], [60, "分钟", "m"], [1, "秒", "s"]];
|
||||
for (const [size, zh, en] of units) {
|
||||
const value = Math.floor(rest / size);
|
||||
if (value) values.push(lang === "zh" ? `${value} ${zh}` : `${value}${en}`);
|
||||
rest %= size;
|
||||
}
|
||||
const duration = values.join(" ");
|
||||
return lang === "zh" ? `${duration}额度` : `${duration} quota`;
|
||||
}
|
||||
|
||||
function formatReset(resetsAt: string | null, now: number, lang: Language, copy: QuotaCopy): { relative: string; exact: string | null; overdue: boolean } {
|
||||
const time = resetsAt ? Date.parse(resetsAt) : Number.NaN;
|
||||
if (!Number.isFinite(time)) return { relative: copy.resetUnknown, exact: null, overdue: false };
|
||||
const exact = new Date(time).toLocaleString(lang === "zh" ? "zh-CN" : "en-US", {
|
||||
year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hour12: false,
|
||||
});
|
||||
if (time <= now) return { relative: copy.resetDue, exact, overdue: true };
|
||||
const minutes = Math.ceil((time - now) / 60_000);
|
||||
const days = Math.floor(minutes / 1_440);
|
||||
const hours = Math.floor(minutes % 1_440 / 60);
|
||||
const remainder = minutes % 60;
|
||||
let duration: string;
|
||||
if (lang === "zh") {
|
||||
duration = days ? `${days} 天${hours ? ` ${hours} 小时` : ""}` : hours ? `${hours} 小时${remainder ? ` ${remainder} 分钟` : ""}` : `${minutes} 分钟`;
|
||||
return { relative: `${duration}后重置`, exact, overdue: false };
|
||||
}
|
||||
duration = days ? `${days}d${hours ? ` ${hours}h` : ""}` : hours ? `${hours}h${remainder ? ` ${remainder}m` : ""}` : `${minutes}m`;
|
||||
return { relative: `Resets in ${duration}`, exact, overdue: false };
|
||||
}
|
||||
|
||||
function limitLabel(limit: OfficialQuotaLimit, copy: QuotaCopy): string {
|
||||
if (limit.id === "main") return copy.main;
|
||||
if (limit.id === "code_review" || limit.id === "code-review") return copy.codeReview;
|
||||
return limit.name?.trim() || limit.id;
|
||||
}
|
||||
|
||||
function QuotaWindow({ window, lang, copy, now, compact = false }: { window: OfficialQuotaWindow; lang: Language; copy: QuotaCopy; now: number; compact?: boolean }) {
|
||||
const remaining = finitePercent(window.remainingPercent);
|
||||
const used = finitePercent(window.usedPercent);
|
||||
const reset = formatReset(window.resetsAt, now, lang, copy);
|
||||
const label = formatWindow(window.windowSeconds, lang, copy);
|
||||
const low = remaining !== null && remaining <= 10;
|
||||
|
||||
return <article className={`cx-quota-window${compact ? " cx-quota-window--compact" : ""}${low ? " cx-quota-window--low" : ""}`}>
|
||||
<div className="cx-quota-window-summary">
|
||||
<div className="cx-quota-window-heading"><strong>{label}</strong></div>
|
||||
<div className="cx-quota-window-value"><span>{remaining === null ? "—" : formatPercent(remaining)}</span>{remaining !== null && <small>%</small>}</div>
|
||||
</div>
|
||||
<div className="cx-quota-window-progress">
|
||||
<span>{copy.remaining}</span>
|
||||
<div className={`cx-quota-meter${remaining === null ? " cx-quota-meter--unknown" : ""}`} role="progressbar" aria-label={`${label} · ${copy.remaining}`} aria-valuemin={0} aria-valuemax={100} aria-valuenow={remaining ?? undefined} aria-valuetext={remaining === null ? copy.percentUnknown : `${formatPercent(remaining)}%`}>
|
||||
{remaining !== null && <span style={{ width: `${remaining}%` }} />}
|
||||
</div>
|
||||
<div className="cx-quota-window-used" title={used === null ? copy.percentUnknown : undefined}>{copy.used} {used === null ? "—" : `${formatPercent(used)}%`}</div>
|
||||
</div>
|
||||
<div className={`cx-quota-reset${reset.overdue ? " cx-quota-reset--due" : ""}`}>
|
||||
<Clock3 size={12} aria-hidden="true" /><div><span>{reset.relative}</span>{reset.exact && <time dateTime={window.resetsAt!}>{reset.exact}</time>}</div>
|
||||
</div>
|
||||
</article>;
|
||||
}
|
||||
|
||||
function QuotaLimit({ limit, lang, copy, now, compact = false }: { limit: OfficialQuotaLimit; lang: Language; copy: QuotaCopy; now: number; compact?: boolean }) {
|
||||
const status = limit.limitReached === true ? copy.reached : limit.allowed === false ? copy.unavailable : limit.allowed === true ? copy.available : null;
|
||||
const windowOrder = (window: OfficialQuotaWindow) => window.windowSeconds !== null && Number.isFinite(window.windowSeconds) && window.windowSeconds > 0 ? window.windowSeconds : Number.POSITIVE_INFINITY;
|
||||
const windows = [...limit.windows].sort((a, b) => windowOrder(a) - windowOrder(b));
|
||||
return <section className={`cx-quota-limit${compact ? " cx-quota-limit--compact" : ""}`}>
|
||||
<div className="cx-quota-limit-heading"><h3>{limitLabel(limit, copy)}</h3>{status && <span className={`cx-quota-status${limit.limitReached === true || limit.allowed === false ? " cx-quota-status--unavailable" : ""}`}>{limit.allowed === true && limit.limitReached !== true && <CheckCircle2 size={11} aria-hidden="true" />}{status}</span>}</div>
|
||||
{windows.length > 0 ? <div className="cx-quota-windows">{windows.map((window) => <QuotaWindow key={window.id} window={window} lang={lang} copy={copy} now={now} compact={compact} />)}</div> : <div className="cx-quota-no-window"><strong>{copy.noWindow}</strong><p>{copy.noWindowHint}</p></div>}
|
||||
</section>;
|
||||
}
|
||||
|
||||
export function OfficialQuotaDialog({ lang, configDir, profile, onClose }: OfficialQuotaDialogProps) {
|
||||
const copy = getCopy(lang);
|
||||
const profileId = profile?.id ?? null;
|
||||
const queryKey = JSON.stringify([configDir, profileId]);
|
||||
const [state, dispatch] = useReducer(officialQuotaReducer, initialOfficialQuotaState);
|
||||
const [now, setNow] = useState(Date.now);
|
||||
const requestId = useRef(0);
|
||||
const currentKey = useRef(queryKey);
|
||||
currentKey.current = queryKey;
|
||||
const current = state.key === queryKey ? state : initialOfficialQuotaState;
|
||||
const { data, busy: quotaBusy, error } = current.quota;
|
||||
const { data: resetCredits, busy: resetBusy, error: resetError } = current.resetCredits;
|
||||
const busy = quotaBusy || resetBusy;
|
||||
|
||||
const load = useCallback(async (retainResult = false) => {
|
||||
if (!profileId) return;
|
||||
const request = ++requestId.current;
|
||||
dispatch({ type: "start", key: queryKey, requestId: request, retainResult });
|
||||
await loadOfficialQuotaDetails({
|
||||
key: queryKey, requestId: request, profileId,
|
||||
loadQuota: () => invoke<OfficialQuotaSnapshot>("get_official_profile_quota", { configDir: configDir || null, profileId }),
|
||||
loadResetCredits: () => invoke<OfficialResetCreditsSnapshot>("get_official_profile_reset_credits", { configDir: configDir || null, profileId }),
|
||||
dispatch,
|
||||
isCurrent: () => request === requestId.current && currentKey.current === queryKey,
|
||||
mismatchMessage: copy.mismatch, invalidCountMessage: copy.resetInvalid,
|
||||
});
|
||||
}, [configDir, profileId, queryKey, copy.mismatch, copy.resetInvalid]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!profileId) { dispatch({ type: "clear", requestId: ++requestId.current }); return; }
|
||||
void load();
|
||||
return () => { requestId.current += 1; };
|
||||
}, [profileId, load]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!profileId) return;
|
||||
setNow(Date.now());
|
||||
const timer = window.setInterval(() => setNow(Date.now()), 60_000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [profileId, configDir]);
|
||||
|
||||
useEffect(() => { if (data) setNow(Date.now()); }, [data]);
|
||||
|
||||
const close = () => {
|
||||
dispatch({ type: "clear", requestId: ++requestId.current });
|
||||
onClose();
|
||||
};
|
||||
const main = data?.limits.find((limit) => limit.id === "main");
|
||||
const others = data?.limits.filter((limit) => limit.id !== "main") ?? [];
|
||||
const email = data?.email || profile?.email;
|
||||
const checked = data?.checkedAt && Number.isFinite(Date.parse(data.checkedAt)) ? new Date(data.checkedAt).toLocaleString(lang === "zh" ? "zh-CN" : "en-US", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hour12: false }) : null;
|
||||
|
||||
return <ModalShell open={Boolean(profile)} onClose={close} title={copy.title} description={profile?.name} closeLabel={copy.close} size="md" className="cx-official-quota-dialog" bodyClassName="cx-official-quota-body" footer={
|
||||
<><Button variant="secondary" onClick={close}>{copy.close}</Button><Button variant="primary" icon={busy ? <Loader2 size={14} className="cx-quota-spin" /> : <RefreshCw size={14} />} disabled={busy} onClick={() => void load(true)}>{busy ? copy.refreshing : copy.refresh}</Button></>
|
||||
}>
|
||||
<div className="cx-quota-account"><div className="cx-quota-account-icon" aria-hidden="true"><Gauge size={21} strokeWidth={1.7} /></div><div className="cx-quota-account-copy"><div><Sparkles size={12} aria-hidden="true" /><strong>{getOfficialPlan(data?.planType ?? profile?.planType, lang)?.label || copy.planUnknown}</strong></div><OfficialAccountBadge lang={lang} email={email} hasAuth canQueryQuota className="cx-quota-account-email" /></div></div>
|
||||
<section className={`cx-quota-reset-credits${resetError ? " cx-quota-reset-credits--error" : ""}`} aria-label={copy.resets} aria-busy={resetBusy}>
|
||||
<div className="cx-quota-reset-credits-row">
|
||||
<span><Ticket size={16} aria-hidden="true" />{copy.resets}</span>
|
||||
<div aria-live="polite">
|
||||
{resetBusy && <Loader2 size={13} className="cx-quota-spin" aria-hidden="true" />}
|
||||
{resetCredits ? <strong>{resetCredits.availableCount}<small>{lang === "zh" ? "次" : resetCredits.availableCount === 1 ? "reset" : "resets"}</small></strong> : <span className="cx-quota-reset-credits-status">{resetBusy ? copy.resetLoading : "—"}</span>}
|
||||
</div>
|
||||
</div>
|
||||
{resetError && <p role="alert"><AlertCircle size={12} aria-hidden="true" /><span><strong>{resetCredits ? copy.resetPrevious : copy.resetError}</strong><span>{resetError}</span></span></p>}
|
||||
</section>
|
||||
{error && <div className="cx-quota-error" role="alert"><AlertCircle size={17} aria-hidden="true" /><div><strong>{copy.error}</strong>{data && <p>{copy.previous}</p>}<p>{error}</p></div></div>}
|
||||
{quotaBusy && !data && <div className="cx-quota-loading" role="status"><Loader2 size={24} className="cx-quota-spin" aria-hidden="true" /><strong>{copy.loading}</strong><p>{copy.loadingHint}</p><div className="cx-quota-loading-bars" aria-hidden="true"><span /><span /></div></div>}
|
||||
{data && <>
|
||||
{main ? <QuotaLimit limit={main} lang={lang} copy={copy} now={now} /> : <div className="cx-quota-empty"><Gauge size={26} aria-hidden="true" /><strong>{copy.empty}</strong><p>{copy.emptyHint}</p></div>}
|
||||
{others.length > 0 && <details className="cx-quota-other"><summary><span>{copy.others}<small>{others.length}</small></span><ChevronDown size={15} aria-hidden="true" /></summary><div>{others.map((limit) => <QuotaLimit key={limit.id} limit={limit} lang={lang} copy={copy} now={now} compact />)}</div></details>}
|
||||
<div className="cx-quota-footnote">{checked && <span><CheckCircle2 size={11} aria-hidden="true" />{copy.checked} {checked}</span>}<p>{copy.snapshotHint}</p></div>
|
||||
</>}
|
||||
</ModalShell>;
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
import { useEffect, useId, useMemo, useState } from "react";
|
||||
import { ChevronDown, Download, ListFilter, Plus, Trash2 } from "lucide-react";
|
||||
import type { ProviderModelMapping } from "../types";
|
||||
|
||||
type Language = "zh" | "en";
|
||||
type MappingError = { model?: string; contextWindow?: string };
|
||||
const MAX_MAPPINGS = 64;
|
||||
const MAX_CONTEXT_WINDOW = 10_000_000;
|
||||
|
||||
function getCopy(lang: Language) {
|
||||
return lang === "zh" ? {
|
||||
title: "模型映射", optional: "可选", subtitle: "让 Codex 的模型菜单显示这个供应商实际提供的模型。",
|
||||
hint: "适用于支持 Responses 接口的模型。保存并启用后,重启 Codex 更新模型菜单。已有会话会保留原来的模型选择。",
|
||||
displayName: "菜单显示名称", displayPlaceholder: "留空使用模型 ID", model: "实际模型 ID", modelPlaceholder: "填写供应商提供的模型 ID",
|
||||
context: "上下文窗口", contextPlaceholder: "可选,如 128000", contextHint: "单位为 Token;留空使用默认值,最大 10,000,000。",
|
||||
addCurrent: "添加当前模型", importModels: "导入已获取模型", addRow: "添加空行", remove: "删除映射",
|
||||
empty: "暂未设置模型映射", emptyHint: "留空表示不设置自定义模型菜单。可以添加当前模型,或先获取供应商的模型列表再导入。",
|
||||
noCurrent: "请先填写上方的模型", currentExists: "当前模型已添加", fetchFirst: "请先在上方获取模型列表", allImported: "已获取的模型均已添加",
|
||||
limit: "模型菜单最多支持 64 个模型(包含当前默认模型)。", reserved: "当前默认模型会自动保留在菜单中,并计入 64 个模型的上限。",
|
||||
missingModel: "请填写实际模型 ID", invalidModel: "模型 ID 不能包含控制字符", longModel: "模型 ID 最多 200 个字符",
|
||||
invalidContext: "请填写 1 至 10,000,000 的整数,或留空。", fixErrors: "请先修正标红的内容,再保存供应商。",
|
||||
rowLabel: (index: number) => `第 ${index + 1} 行`, imported: (count: number) => `已添加 ${count} 个模型。`,
|
||||
importLimited: (added: number, skipped: number) => `已添加 ${added} 个模型,达到 64 个上限,另有 ${skipped} 个未添加。`,
|
||||
invalidDefault: "上方的当前模型 ID 不符合要求,请先修改(最多 200 个字符,不能包含控制字符)。",
|
||||
} : {
|
||||
title: "Model mappings", optional: "Optional", subtitle: "Show this provider’s actual models in the Codex model menu.",
|
||||
hint: "For models that support the Responses API. Save and enable this provider, then restart Codex to update its model menu. Existing conversations keep their selected model.",
|
||||
displayName: "Menu display name", displayPlaceholder: "Defaults to the model ID", model: "Actual model ID", modelPlaceholder: "Enter the provider’s model ID",
|
||||
context: "Context window", contextPlaceholder: "Optional, e.g. 128000", contextHint: "In tokens. Leave blank for the default; maximum 10,000,000.",
|
||||
addCurrent: "Add current model", importModels: "Import fetched models", addRow: "Add row", remove: "Remove mapping",
|
||||
empty: "No model mappings yet", emptyHint: "Leave this empty to avoid setting a custom model menu. Add the current model, or fetch the provider’s models above and import them.",
|
||||
noCurrent: "Enter a model above first", currentExists: "The current model is already included", fetchFirst: "Fetch the model list above first", allImported: "All fetched models are already included",
|
||||
limit: "The model menu supports up to 64 models, including the current default model.", reserved: "The current default model is kept in the menu automatically and counts toward the 64-model limit.",
|
||||
missingModel: "Enter the actual model ID", invalidModel: "Model IDs cannot contain control characters", longModel: "Model IDs cannot exceed 200 characters",
|
||||
invalidContext: "Enter a whole number from 1 to 10,000,000, or leave blank.", fixErrors: "Correct the highlighted fields before saving this provider.",
|
||||
rowLabel: (index: number) => `Row ${index + 1}`, imported: (count: number) => `Added ${count} model(s).`,
|
||||
importLimited: (added: number, skipped: number) => `Added ${added} model(s). The 64-model limit was reached; ${skipped} were not added.`,
|
||||
invalidDefault: "Correct the current model ID above first: at most 200 characters, without control characters.",
|
||||
};
|
||||
}
|
||||
|
||||
function hasInvalidModelId(value: string): boolean {
|
||||
return Array.from(value).length > 200 || /[\u0000-\u001f\u007f-\u009f]/.test(value);
|
||||
}
|
||||
|
||||
function menuSize(rows: readonly ProviderModelMapping[], currentModel: string): number {
|
||||
const current = currentModel.trim();
|
||||
return rows.length + (rows.length > 0 && current && !rows.some((row) => row.model.trim() === current) ? 1 : 0);
|
||||
}
|
||||
|
||||
export function validateProviderModelMappings(rows: readonly ProviderModelMapping[], currentModel: string, lang: Language) {
|
||||
const copy = getCopy(lang);
|
||||
const counts = new Map<string, number>();
|
||||
for (const row of rows) {
|
||||
const id = row.model.trim();
|
||||
if (id) counts.set(id, (counts.get(id) ?? 0) + 1);
|
||||
}
|
||||
const errors: MappingError[] = rows.map((row) => {
|
||||
const id = row.model.trim();
|
||||
const error: MappingError = {};
|
||||
if (!id) error.model = copy.missingModel;
|
||||
else if (/[\u0000-\u001f\u007f-\u009f]/.test(id)) error.model = copy.invalidModel;
|
||||
else if (Array.from(id).length > 200) error.model = copy.longModel;
|
||||
else if ((counts.get(id) ?? 0) > 1) error.model = lang === "zh" ? "此模型 ID 已添加,请删除重复行。" : "This model ID is already included. Remove the duplicate row.";
|
||||
if (row.contextWindow !== null && (!Number.isSafeInteger(row.contextWindow) || row.contextWindow <= 0 || row.contextWindow > MAX_CONTEXT_WINDOW)) error.contextWindow = copy.invalidContext;
|
||||
return error;
|
||||
});
|
||||
const size = menuSize(rows, currentModel);
|
||||
const defaultInvalid = rows.length > 0 && hasInvalidModelId(currentModel.trim());
|
||||
return { errors, size, valid: size <= MAX_MAPPINGS && !defaultInvalid && errors.every((error) => !error.model && !error.contextWindow), defaultInvalid };
|
||||
}
|
||||
|
||||
export function ProviderModelMappings({ lang, rows, currentModel, availableModels, disabled, onChange }: {
|
||||
lang: Language;
|
||||
rows: readonly ProviderModelMapping[];
|
||||
currentModel: string;
|
||||
availableModels: readonly string[];
|
||||
disabled: boolean;
|
||||
onChange: (rows: ProviderModelMapping[]) => void;
|
||||
}) {
|
||||
const copy = getCopy(lang);
|
||||
const id = useId();
|
||||
const [expanded, setExpanded] = useState(rows.length > 0);
|
||||
const [notice, setNotice] = useState("");
|
||||
// Keep incomplete numeric input visible. Its parent value is the invalid
|
||||
// sentinel 0, so both the form and save validation reject it instead of
|
||||
// silently serializing it as an omitted optional value.
|
||||
const [contextDrafts, setContextDrafts] = useState<Record<number, { value: number | null; text: string }>>({});
|
||||
const validation = validateProviderModelMappings(rows, currentModel, lang);
|
||||
const current = currentModel.trim();
|
||||
const currentIncluded = Boolean(current) && rows.some((row) => row.model.trim() === current);
|
||||
const candidates = useMemo(() => {
|
||||
const existing = new Set(rows.map((row) => row.model.trim()));
|
||||
return Array.from(new Set(availableModels.map((model) => model.trim()).filter(Boolean))).filter((model) => !existing.has(model));
|
||||
}, [availableModels, rows]);
|
||||
const canAppend = (model: string) => menuSize([...rows, { model, displayName: model, contextWindow: null }], current) <= MAX_MAPPINGS;
|
||||
const canAddCurrent = Boolean(current) && !currentIncluded && !hasInvalidModelId(current) && canAppend(current);
|
||||
const canImport = candidates.some(canAppend);
|
||||
const reserved = rows.length > 0 && Boolean(current) && !currentIncluded;
|
||||
|
||||
useEffect(() => { if (rows.length > 0) setExpanded(true); }, [rows.length]);
|
||||
|
||||
const update = (index: number, patch: Partial<ProviderModelMapping>) => {
|
||||
setNotice("");
|
||||
onChange(rows.map((row, position) => position === index ? { ...row, ...patch } : row));
|
||||
};
|
||||
const add = (model: string) => {
|
||||
if (disabled || !canAppend(model)) return;
|
||||
setNotice("");
|
||||
setExpanded(true);
|
||||
onChange([...rows, { model, displayName: model, contextWindow: null }]);
|
||||
};
|
||||
const remove = (index: number) => {
|
||||
if (disabled) return;
|
||||
setContextDrafts((previous) => Object.fromEntries(Object.entries(previous).filter(([key]) => Number(key) !== index).map(([key, value]) => [Number(key) > index ? Number(key) - 1 : Number(key), value])));
|
||||
setNotice("");
|
||||
onChange(rows.filter((_, position) => position !== index));
|
||||
};
|
||||
const importModels = () => {
|
||||
if (disabled) return;
|
||||
let next = [...rows];
|
||||
let added = 0;
|
||||
let skipped = 0;
|
||||
for (const model of candidates) {
|
||||
const proposed = [...next, { model, displayName: model, contextWindow: null }];
|
||||
if (menuSize(proposed, current) > MAX_MAPPINGS) { skipped += 1; continue; }
|
||||
next = proposed;
|
||||
added += 1;
|
||||
}
|
||||
if (added) onChange(next);
|
||||
setExpanded(true);
|
||||
setNotice(skipped ? copy.importLimited(added, skipped) : copy.imported(added));
|
||||
};
|
||||
|
||||
return <details className="cx-provider-mappings" open={expanded} onToggle={(event) => setExpanded(event.currentTarget.open)}>
|
||||
<summary><ListFilter size={17} aria-hidden="true" /><div><strong>{copy.title}<span>{copy.optional}</span></strong><p>{copy.subtitle}</p></div><span className={`cx-provider-mappings-count${!validation.valid ? " cx-provider-mappings-count--invalid" : ""}`} title={copy.limit}>{validation.size} / {MAX_MAPPINGS}</span><ChevronDown size={16} className="cx-provider-mappings-chevron" aria-hidden="true" /></summary>
|
||||
<div className="cx-provider-mappings-body">
|
||||
<p className="cx-provider-mappings-hint">{copy.hint}</p>
|
||||
<div className="cx-provider-mappings-toolbar">
|
||||
<button type="button" className="cx-providers-button cx-providers-button--secondary cx-providers-button--small" onClick={() => add(current)} disabled={disabled || !canAddCurrent} title={currentIncluded ? copy.currentExists : !current ? copy.noCurrent : hasInvalidModelId(current) ? copy.invalidDefault : !canAddCurrent ? copy.limit : undefined}><Plus size={14} aria-hidden="true" />{copy.addCurrent}</button>
|
||||
<button type="button" className="cx-providers-button cx-providers-button--secondary cx-providers-button--small" onClick={importModels} disabled={disabled || !canImport} title={!availableModels.length ? copy.fetchFirst : !candidates.length ? copy.allImported : !canImport ? copy.limit : undefined}><Download size={14} aria-hidden="true" />{copy.importModels}</button>
|
||||
<button type="button" className="cx-providers-button cx-providers-button--secondary cx-providers-button--small" onClick={() => add("")} disabled={disabled || !canAppend("")} title={!canAppend("") ? copy.limit : undefined}><Plus size={14} aria-hidden="true" />{copy.addRow}</button>
|
||||
</div>
|
||||
{notice && <p className="cx-provider-mappings-notice" role="status">{notice}</p>}
|
||||
{rows.length === 0 ? <div className="cx-provider-mappings-empty"><ListFilter size={22} aria-hidden="true" /><strong>{copy.empty}</strong><p>{copy.emptyHint}</p></div> : <>
|
||||
<div className="cx-provider-mappings-labels" aria-hidden="true"><span>{copy.displayName}</span><span>{copy.model}</span><span>{copy.context}</span><span /></div>
|
||||
<div className="cx-provider-mappings-rows">
|
||||
{rows.map((row, index) => {
|
||||
const error = validation.errors[index];
|
||||
const contextDraft = contextDrafts[index];
|
||||
const contextText = contextDraft && Object.is(contextDraft.value, row.contextWindow) ? contextDraft.text : row.contextWindow === null ? "" : String(row.contextWindow);
|
||||
return <div className="cx-provider-mapping-row" key={index} role="group" aria-label={copy.rowLabel(index)}>
|
||||
<label className="cx-provider-mapping-field"><span>{copy.displayName}</span><input value={row.displayName} placeholder={row.model.trim() || copy.displayPlaceholder} aria-label={`${copy.displayName} · ${copy.rowLabel(index)}`} onChange={(event) => update(index, { displayName: event.currentTarget.value })} disabled={disabled} /></label>
|
||||
<label className="cx-provider-mapping-field"><span>{copy.model}</span><input value={row.model} placeholder={copy.modelPlaceholder} aria-label={`${copy.model} · ${copy.rowLabel(index)}`} aria-invalid={Boolean(error.model)} aria-describedby={error.model ? `${id}-${index}-model-error` : undefined} onChange={(event) => update(index, { model: event.currentTarget.value })} disabled={disabled} spellCheck={false} />{error.model && <small id={`${id}-${index}-model-error`}>{error.model}</small>}</label>
|
||||
<label className="cx-provider-mapping-field"><span>{copy.context}</span><input value={contextText} inputMode="numeric" placeholder={copy.contextPlaceholder} aria-label={`${copy.context} · ${copy.rowLabel(index)}`} aria-invalid={Boolean(error.contextWindow)} aria-describedby={error.contextWindow ? `${id}-${index}-context-error` : `${id}-context-hint`} onChange={(event) => {
|
||||
const text = event.currentTarget.value;
|
||||
const trimmed = text.trim();
|
||||
const number = Number(trimmed);
|
||||
const value = !trimmed ? null : /^\d+$/.test(trimmed) && Number.isSafeInteger(number) && number > 0 && number <= MAX_CONTEXT_WINDOW ? number : 0;
|
||||
setContextDrafts((previous) => ({ ...previous, [index]: { text, value } }));
|
||||
update(index, { contextWindow: value });
|
||||
}} disabled={disabled} />{error.contextWindow && <small id={`${id}-${index}-context-error`}>{error.contextWindow}</small>}</label>
|
||||
<button type="button" className="cx-providers-icon-button cx-providers-icon-button--danger cx-provider-mapping-remove" onClick={() => remove(index)} disabled={disabled} title={`${copy.remove} · ${copy.rowLabel(index)}`} aria-label={`${copy.remove} · ${copy.rowLabel(index)}`}><Trash2 size={14} aria-hidden="true" /></button>
|
||||
</div>;
|
||||
})}
|
||||
</div>
|
||||
<p className="cx-provider-mappings-field-hint" id={`${id}-context-hint`}>{copy.contextHint}</p>
|
||||
{reserved && <p className="cx-provider-mappings-field-hint">{copy.reserved}</p>}
|
||||
{!validation.valid && <p className="cx-provider-mappings-error" role="status">{validation.size > MAX_MAPPINGS ? copy.limit : validation.defaultInvalid ? copy.invalidDefault : copy.fixErrors}</p>}
|
||||
</>}
|
||||
</div>
|
||||
</details>;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { useId } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { Check, SlidersHorizontal } from "lucide-react";
|
||||
import "../styles/provider-preset-picker.css";
|
||||
|
||||
export type ProviderPresetBrand = "custom" | "openai" | "deepseek" | "minimax" | "mimo" | "qwen" | "kimi" | "glm" | "doubao";
|
||||
|
||||
export type ProviderPresetChoice = {
|
||||
id: string;
|
||||
name: string;
|
||||
nameEn?: string;
|
||||
brand: ProviderPresetBrand;
|
||||
subtitle?: string;
|
||||
subtitleEn?: string;
|
||||
};
|
||||
|
||||
function PresetMark({ brand }: { brand: ProviderPresetBrand }) {
|
||||
const logo = brand === "openai" ? "/openai.svg" : `/provider-logos/${brand}.svg`;
|
||||
return <span className={`cx-preset-mark cx-preset-mark--${brand}`} aria-hidden="true">
|
||||
{brand === "custom" ? <SlidersHorizontal size={19} strokeWidth={1.8} /> : brand === "doubao" ? "豆" : <span className="cx-preset-brand-logo" style={{ WebkitMaskImage: `url("${logo}")`, maskImage: `url("${logo}")` }} />}
|
||||
</span>;
|
||||
}
|
||||
|
||||
export function ProviderPresetPicker({ lang, presets, selectedId, disabled = false, onSelect, children }: {
|
||||
lang: "zh" | "en";
|
||||
presets: readonly ProviderPresetChoice[];
|
||||
selectedId: string;
|
||||
disabled?: boolean;
|
||||
onSelect: (id: string) => void;
|
||||
children?: ReactNode;
|
||||
}) {
|
||||
const id = useId();
|
||||
const title = lang === "zh" ? "预设供应商" : "Provider presets";
|
||||
|
||||
return <section className="cx-preset-picker" aria-label={title}>
|
||||
<fieldset className="cx-preset-fieldset" disabled={disabled} aria-label={title}>
|
||||
<div className="cx-preset-grid">
|
||||
{presets.map((preset) => {
|
||||
const name = lang === "en" ? preset.nameEn ?? preset.name : preset.name;
|
||||
return <label className="cx-preset-option" key={preset.id}>
|
||||
<input className="cx-preset-radio" type="radio" name={`${id}-provider-preset`} value={preset.id} checked={preset.id === selectedId} onChange={() => onSelect(preset.id)} aria-label={name} />
|
||||
<span className="cx-preset-card">
|
||||
<PresetMark brand={preset.brand} />
|
||||
<span className="cx-preset-copy"><strong>{name}</strong></span>
|
||||
<span className="cx-preset-selected" aria-hidden="true"><Check size={13} strokeWidth={2.8} /></span>
|
||||
</span>
|
||||
</label>;
|
||||
})}
|
||||
</div>
|
||||
</fieldset>
|
||||
{children && <div className="cx-preset-details">{children}</div>}
|
||||
</section>;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
export type OfficialPlanTone = "pro20" | "pro5" | "plus" | "team" | "enterprise" | "edu" | "free" | "neutral";
|
||||
export type OfficialPlan = { label: string; tone: OfficialPlanTone };
|
||||
|
||||
// Display aliases requested for the account UI; unknown plans remain neutral.
|
||||
export function getOfficialPlan(planType: string | null | undefined, lang: "zh" | "en"): OfficialPlan | null {
|
||||
if (typeof planType !== "string" || planType.length > 64 || /[\u0000-\u001f\u007f-\u009f]/.test(planType)) return null;
|
||||
const raw = planType.trim();
|
||||
if (!raw) return null;
|
||||
const key = raw.toLowerCase().replace(/[_\s]+/g, "-");
|
||||
if (["pro", "pro20x", "pro-20x"].includes(key)) return { label: "Pro 20x", tone: "pro20" };
|
||||
if (["prolite", "pro-lite", "pro5x", "pro-5x"].includes(key)) return { label: "Pro 5x", tone: "pro5" };
|
||||
const known: Record<string, OfficialPlan> = {
|
||||
plus: { label: "Plus", tone: "plus" },
|
||||
team: { label: "Team", tone: "team" },
|
||||
business: { label: "Business", tone: "team" },
|
||||
enterprise: { label: "Enterprise", tone: "enterprise" },
|
||||
edu: { label: "Edu", tone: "edu" },
|
||||
free: { label: lang === "zh" ? "免费版" : "Free", tone: "free" },
|
||||
go: { label: "Go", tone: "neutral" },
|
||||
};
|
||||
return Object.prototype.hasOwnProperty.call(known, key) ? known[key] : { label: raw, tone: "neutral" };
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { OfficialProfileSummary } from "./types";
|
||||
|
||||
type Options = {
|
||||
read: () => Promise<OfficialProfileSummary[]>;
|
||||
apply: (profiles: OfficialProfileSummary[]) => void;
|
||||
revision: () => number;
|
||||
canRead: () => boolean;
|
||||
isVisible: () => boolean;
|
||||
intervalMs?: number;
|
||||
schedule?: (callback: () => void, delay: number) => () => void;
|
||||
};
|
||||
|
||||
// Only refreshes local display metadata. No full state reload, draft writes,
|
||||
// network quota requests, or shared loading indicators belong in this monitor.
|
||||
export function createOfficialProfileMonitor(options: Options) {
|
||||
const schedule = options.schedule ?? ((callback, delay) => {
|
||||
const timer = setTimeout(callback, delay);
|
||||
return () => clearTimeout(timer);
|
||||
});
|
||||
let stopped = false;
|
||||
let inFlight = false;
|
||||
let wakePending = false;
|
||||
let cancelTimer: (() => void) | undefined;
|
||||
|
||||
function next() {
|
||||
cancelTimer?.();
|
||||
cancelTimer = undefined;
|
||||
if (!stopped && options.isVisible()) {
|
||||
cancelTimer = schedule(wake, options.intervalMs ?? 2_000);
|
||||
}
|
||||
}
|
||||
|
||||
async function read() {
|
||||
if (stopped || !options.isVisible()) return;
|
||||
if (!options.canRead()) { next(); return; }
|
||||
inFlight = true;
|
||||
const revision = options.revision();
|
||||
try {
|
||||
const profiles = await options.read();
|
||||
if (!stopped && !wakePending && options.isVisible() && options.canRead()
|
||||
&& options.revision() === revision) {
|
||||
options.apply(profiles);
|
||||
}
|
||||
} catch {
|
||||
// A temporary file/IPC failure must not blank the list or interrupt work.
|
||||
} finally {
|
||||
inFlight = false;
|
||||
if (wakePending) {
|
||||
wakePending = false;
|
||||
wake();
|
||||
} else {
|
||||
next();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function wake() {
|
||||
cancelTimer?.();
|
||||
cancelTimer = undefined;
|
||||
if (stopped) return;
|
||||
if (inFlight) { wakePending = true; return; }
|
||||
void read();
|
||||
}
|
||||
|
||||
return {
|
||||
wake,
|
||||
stop() {
|
||||
stopped = true;
|
||||
wakePending = false;
|
||||
cancelTimer?.();
|
||||
cancelTimer = undefined;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { OfficialQuotaSnapshot, OfficialResetCreditsSnapshot } from "./officialQuotaTypes";
|
||||
|
||||
type QueryResult<T> = { data: T | null; busy: boolean; error: string };
|
||||
export type OfficialQuotaState = {
|
||||
key: string | null;
|
||||
requestId: number;
|
||||
quota: QueryResult<OfficialQuotaSnapshot>;
|
||||
resetCredits: QueryResult<OfficialResetCreditsSnapshot>;
|
||||
};
|
||||
|
||||
const emptyResult = () => ({ data: null, busy: false, error: "" });
|
||||
export const initialOfficialQuotaState: OfficialQuotaState = {
|
||||
key: null, requestId: 0, quota: emptyResult(), resetCredits: emptyResult(),
|
||||
};
|
||||
|
||||
type RequestIdentity = { key: string; requestId: number };
|
||||
export type OfficialQuotaAction =
|
||||
| ({ type: "start"; retainResult: boolean } & RequestIdentity)
|
||||
| { type: "clear"; requestId: number }
|
||||
| ({ type: "quota-success"; data: OfficialQuotaSnapshot } & RequestIdentity)
|
||||
| ({ type: "reset-success"; data: OfficialResetCreditsSnapshot } & RequestIdentity)
|
||||
| ({ type: "quota-error" | "reset-error"; error: string } & RequestIdentity);
|
||||
|
||||
export function officialQuotaReducer(state: OfficialQuotaState, action: OfficialQuotaAction): OfficialQuotaState {
|
||||
if (action.type === "clear") {
|
||||
return action.requestId < state.requestId ? state : { ...initialOfficialQuotaState, requestId: action.requestId };
|
||||
}
|
||||
if (action.type === "start") {
|
||||
if (action.requestId < state.requestId) return state;
|
||||
const retain = action.retainResult && action.key === state.key;
|
||||
return {
|
||||
key: action.key, requestId: action.requestId,
|
||||
quota: { data: retain ? state.quota.data : null, busy: true, error: "" },
|
||||
resetCredits: { data: retain ? state.resetCredits.data : null, busy: true, error: "" },
|
||||
};
|
||||
}
|
||||
if (action.key !== state.key || action.requestId !== state.requestId) return state;
|
||||
if (action.type === "quota-success") return { ...state, quota: { data: action.data, busy: false, error: "" } };
|
||||
if (action.type === "reset-success") return { ...state, resetCredits: { data: action.data, busy: false, error: "" } };
|
||||
if (action.type === "quota-error") return { ...state, quota: { ...state.quota, busy: false, error: action.error } };
|
||||
return { ...state, resetCredits: { ...state.resetCredits, busy: false, error: action.error } };
|
||||
}
|
||||
|
||||
type QuotaDetailsRequest = RequestIdentity & {
|
||||
profileId: string;
|
||||
loadQuota: () => Promise<OfficialQuotaSnapshot>;
|
||||
loadResetCredits: () => Promise<OfficialResetCreditsSnapshot>;
|
||||
dispatch: (action: OfficialQuotaAction) => void;
|
||||
isCurrent: () => boolean;
|
||||
mismatchMessage: string;
|
||||
invalidCountMessage: string;
|
||||
};
|
||||
|
||||
// Start both native reads in the same turn. Each publishes its own result;
|
||||
// waiting for the slower endpoint must never delay the other endpoint's UI.
|
||||
export async function loadOfficialQuotaDetails(request: QuotaDetailsRequest): Promise<void> {
|
||||
const { key, requestId, profileId, dispatch, isCurrent } = request;
|
||||
const quota = (async () => {
|
||||
try {
|
||||
const data = await request.loadQuota();
|
||||
if (!isCurrent()) return;
|
||||
if (data.profileId !== profileId) throw new Error(request.mismatchMessage);
|
||||
dispatch({ type: "quota-success", key, requestId, data });
|
||||
} catch (cause) {
|
||||
if (isCurrent()) dispatch({ type: "quota-error", key, requestId, error: cause instanceof Error ? cause.message : String(cause) });
|
||||
}
|
||||
})();
|
||||
const resetCredits = (async () => {
|
||||
try {
|
||||
const data = await request.loadResetCredits();
|
||||
if (!isCurrent()) return;
|
||||
if (data.profileId !== profileId) throw new Error(request.mismatchMessage);
|
||||
if (!Number.isSafeInteger(data.availableCount) || data.availableCount < 0) throw new Error(request.invalidCountMessage);
|
||||
dispatch({ type: "reset-success", key, requestId, data });
|
||||
} catch (cause) {
|
||||
if (isCurrent()) dispatch({ type: "reset-error", key, requestId, error: cause instanceof Error ? cause.message : String(cause) });
|
||||
}
|
||||
})();
|
||||
await Promise.all([quota, resetCredits]);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
export type OfficialQuotaWindow = {
|
||||
id: string;
|
||||
windowSeconds: number | null;
|
||||
usedPercent: number | null;
|
||||
remainingPercent: number | null;
|
||||
resetsAt: string | null;
|
||||
};
|
||||
|
||||
export type OfficialQuotaLimit = {
|
||||
id: string;
|
||||
name: string | null;
|
||||
allowed: boolean | null;
|
||||
limitReached: boolean | null;
|
||||
windows: OfficialQuotaWindow[];
|
||||
};
|
||||
|
||||
export type OfficialQuotaSnapshot = {
|
||||
profileId: string;
|
||||
email: string | null;
|
||||
planType: string | null;
|
||||
limits: OfficialQuotaLimit[];
|
||||
checkedAt: string;
|
||||
};
|
||||
|
||||
export type OfficialResetCreditsSnapshot = {
|
||||
profileId: string;
|
||||
availableCount: number;
|
||||
checkedAt: string;
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -1,25 +1,33 @@
|
||||
import { useId, useState } from "react";
|
||||
import { useEffect, useId, useRef, useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import {
|
||||
Activity,
|
||||
AlertTriangle,
|
||||
ArrowLeft,
|
||||
CheckCircle2,
|
||||
Copy,
|
||||
Download,
|
||||
Eye,
|
||||
EyeOff,
|
||||
FilePlus2,
|
||||
Gauge,
|
||||
Loader2,
|
||||
PencilLine,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
RotateCcw,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import type { Ref } from "react";
|
||||
import { PageTransition } from "../components/PageTransition";
|
||||
import { OfficialQuotaDialog } from "../components/OfficialQuotaDialog";
|
||||
import { OfficialAccountBadge } from "../components/OfficialAccountBadge";
|
||||
import { ProviderModelMappings, validateProviderModelMappings } from "../components/ProviderModelMappings";
|
||||
import { ProviderPresetPicker } from "../components/ProviderPresetPicker";
|
||||
import { PROVIDER_PRESETS, getProviderPreset, getProviderPresetVariant } from "../providerPresets";
|
||||
import { Button, Checkbox, ModalShell } from "../components/ui";
|
||||
import type { ProviderMode } from "../types";
|
||||
import type { ProviderMode, ProviderModelMapping } from "../types";
|
||||
import "../styles/providers-page.css";
|
||||
|
||||
export type ProviderRowSource = "official" | "local" | "detected";
|
||||
@@ -34,8 +42,14 @@ export type ProviderRow = {
|
||||
wireApi: string;
|
||||
requiresOpenaiAuth: boolean;
|
||||
isCurrent: boolean;
|
||||
isDefaultOfficial?: boolean;
|
||||
email?: string | null;
|
||||
planType?: string | null;
|
||||
hasAuth?: boolean;
|
||||
canQueryQuota?: boolean;
|
||||
sourceLabel?: string;
|
||||
editable?: boolean;
|
||||
duplicable?: boolean;
|
||||
deletable?: boolean;
|
||||
testable?: boolean;
|
||||
testingKey?: string;
|
||||
@@ -52,8 +66,10 @@ export type ProviderFormValue = {
|
||||
};
|
||||
|
||||
export type OfficialFormValue = {
|
||||
providerName: string;
|
||||
model: string;
|
||||
authJson: string;
|
||||
configText: string;
|
||||
};
|
||||
|
||||
export type ProviderCopy = {
|
||||
@@ -67,6 +83,7 @@ export type ProviderCopy = {
|
||||
enableLabel: string;
|
||||
testLabel: string;
|
||||
editLabel: string;
|
||||
duplicateLabel: string;
|
||||
removeLabel: string;
|
||||
deleteTitle: string;
|
||||
deleteDescription: (providerName: string) => string;
|
||||
@@ -81,8 +98,9 @@ export type ProviderCopy = {
|
||||
authPathLabel: string;
|
||||
officialCurrentLabel: string;
|
||||
officialAuthLabel: string;
|
||||
officialTomlLabel: string;
|
||||
officialSaveLabel: string;
|
||||
restoreOfficialLabel: string;
|
||||
loadCcSwitchOfficialLabel: string;
|
||||
resetOfficialLabel: string;
|
||||
resetOfficialTitle: string;
|
||||
resetOfficialDescription: string;
|
||||
@@ -124,6 +142,7 @@ export type ProviderOfficialInfo = {
|
||||
|
||||
export type ProvidersPageProps = {
|
||||
lang: "zh" | "en";
|
||||
configDir: string;
|
||||
copy: ProviderCopy;
|
||||
mode: ProviderMode;
|
||||
providerRows: readonly ProviderRow[];
|
||||
@@ -131,9 +150,17 @@ export type ProvidersPageProps = {
|
||||
testingId: string;
|
||||
actionBusy?: string;
|
||||
editingProviderId: string | null;
|
||||
creatingProvider: boolean;
|
||||
selectedPresetId: string;
|
||||
selectedPresetVariantId: string;
|
||||
providerForm: ProviderFormValue;
|
||||
providerModelMappings: readonly ProviderModelMapping[];
|
||||
onProviderModelMappingsChange: (rows: ProviderModelMapping[]) => void;
|
||||
officialForm: OfficialFormValue;
|
||||
officialProfileIsDefault: boolean;
|
||||
canLoadCurrentOfficial: boolean;
|
||||
officialAuthRef?: Ref<HTMLTextAreaElement>;
|
||||
officialTomlRef?: Ref<HTMLTextAreaElement>;
|
||||
officialInfo: ProviderOfficialInfo;
|
||||
providerAuthPreview: ReactNode;
|
||||
providerTomlDraft: string;
|
||||
@@ -143,15 +170,21 @@ export type ProvidersPageProps = {
|
||||
fetchingModels: boolean;
|
||||
onImportCcSwitch: () => void;
|
||||
onAddProvider: () => void;
|
||||
onRestoreOfficial: () => void;
|
||||
onPresetSelect: (id: string) => void;
|
||||
onPresetVariantSelect: (id: string) => void;
|
||||
onLoadCcSwitchOfficial: () => void;
|
||||
onLoadCurrentOfficial: () => void;
|
||||
onResetOfficial: () => void;
|
||||
onEnableProvider: (row: ProviderRow) => void;
|
||||
onTestProvider: (row: ProviderRow) => void;
|
||||
onEditProvider: (row: ProviderRow) => void;
|
||||
onDuplicateProvider: (row: ProviderRow) => void;
|
||||
onDeleteProvider: (row: ProviderRow) => Promise<boolean>;
|
||||
onCancelMode: () => void;
|
||||
onOfficialNameChange: (value: string) => void;
|
||||
onOfficialModelChange: (value: string) => void;
|
||||
onOfficialAuthChange: (value: string) => void;
|
||||
onOfficialConfigChange: (value: string) => void;
|
||||
onSaveOfficial: () => void;
|
||||
onApiKeyChange: (value: string) => void;
|
||||
onBaseUrlChange: (value: string) => void;
|
||||
@@ -181,6 +214,136 @@ function Field({ label, children, className }: FieldProps) {
|
||||
);
|
||||
}
|
||||
|
||||
type ContextWindowValues = {
|
||||
contextWindow: number | null;
|
||||
compactTokenLimit: number | null;
|
||||
};
|
||||
|
||||
type ContextWindowConfig = ContextWindowValues & {
|
||||
configText: string;
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
function ContextWindowControl({
|
||||
lang,
|
||||
configText,
|
||||
disabled,
|
||||
onConfigChange,
|
||||
onBusyChange,
|
||||
}: {
|
||||
lang: "zh" | "en";
|
||||
configText: string;
|
||||
disabled: boolean;
|
||||
onConfigChange: (text: string) => void;
|
||||
onBusyChange: (busy: boolean) => void;
|
||||
}) {
|
||||
const [settings, setSettings] = useState<ContextWindowConfig | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const [changing, setChanging] = useState(false);
|
||||
const currentText = useRef(configText);
|
||||
const requestId = useRef(0);
|
||||
const previousValues = useRef<ContextWindowValues | null>(null);
|
||||
currentText.current = configText;
|
||||
|
||||
useEffect(() => {
|
||||
const id = ++requestId.current;
|
||||
let mounted = true;
|
||||
setError("");
|
||||
const timer = setTimeout(() => {
|
||||
void invoke<ContextWindowConfig>("update_codex_context_window", {
|
||||
configText,
|
||||
enabled: null,
|
||||
previousValues: null,
|
||||
}).then((result) => {
|
||||
if (mounted && requestId.current === id && currentText.current === configText) {
|
||||
setSettings(result);
|
||||
}
|
||||
}).catch((cause: unknown) => {
|
||||
if (mounted && requestId.current === id && currentText.current === configText) {
|
||||
setSettings(null);
|
||||
setError(String(cause));
|
||||
}
|
||||
});
|
||||
}, 100);
|
||||
return () => {
|
||||
mounted = false;
|
||||
++requestId.current;
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [configText]);
|
||||
|
||||
const toggle = async (enabled: boolean) => {
|
||||
if (disabled || changing || !settings || settings.configText !== configText) return;
|
||||
const originalText = configText;
|
||||
const id = ++requestId.current;
|
||||
setChanging(true);
|
||||
onBusyChange(true);
|
||||
setError("");
|
||||
try {
|
||||
const result = await invoke<ContextWindowConfig>("update_codex_context_window", {
|
||||
configText: originalText,
|
||||
enabled,
|
||||
previousValues: enabled ? null : previousValues.current,
|
||||
});
|
||||
if (requestId.current !== id || currentText.current !== originalText) return;
|
||||
previousValues.current = enabled
|
||||
? { contextWindow: settings.contextWindow, compactTokenLimit: settings.compactTokenLimit }
|
||||
: null;
|
||||
setSettings(result);
|
||||
onConfigChange(result.configText);
|
||||
} catch (cause: unknown) {
|
||||
if (requestId.current === id && currentText.current === originalText) setError(String(cause));
|
||||
} finally {
|
||||
setChanging(false);
|
||||
onBusyChange(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="cx-providers-context-control">
|
||||
<Checkbox
|
||||
className="cx-providers-checkbox cx-providers-context-checkbox"
|
||||
label={lang === "zh" ? "开启 1M 上下文窗口" : "Enable 1M context window"}
|
||||
checked={Boolean(settings?.enabled)}
|
||||
onCheckedChange={(enabled) => void toggle(enabled)}
|
||||
disabled={disabled || changing || !settings || settings.configText !== configText}
|
||||
/>
|
||||
{error ? (
|
||||
<span className="cx-providers-context-error" role="status" title={error}>
|
||||
{lang === "zh" ? "请先修正 config.toml 的格式或上下文字段" : "Check the TOML syntax and context settings first"}
|
||||
</span>
|
||||
) : settings?.enabled && settings.configText === configText ? (
|
||||
<span className="cx-providers-context-hint">
|
||||
{settings.compactTokenLimit !== null
|
||||
? lang === "zh"
|
||||
? `自动压缩阈值:${settings.compactTokenLimit.toLocaleString("zh-CN")} tokens`
|
||||
: `Auto-compact at ${settings.compactTokenLimit.toLocaleString("en-US")} tokens`
|
||||
: lang === "zh" ? "自动压缩使用 Codex 默认值" : "Codex determines the compaction limit"}
|
||||
</span>
|
||||
) : (
|
||||
<span className="cx-providers-context-hint">
|
||||
{lang === "zh" ? "保存后生效,需模型支持" : "Applies on save; requires model support"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProviderPresetSection({ lang, creatingProvider, selectedPresetId, selectedPresetVariantId, onPresetSelect, onPresetVariantSelect, disabled }: Pick<ProvidersPageProps, "lang" | "creatingProvider" | "selectedPresetId" | "selectedPresetVariantId" | "onPresetSelect" | "onPresetVariantSelect"> & { disabled: boolean }) {
|
||||
if (!creatingProvider) return null;
|
||||
const preset = getProviderPreset(selectedPresetId);
|
||||
const variant = getProviderPresetVariant(selectedPresetId, selectedPresetVariantId);
|
||||
return <ProviderPresetPicker lang={lang} presets={PROVIDER_PRESETS} selectedId={selectedPresetId} onSelect={onPresetSelect} disabled={disabled}>
|
||||
{variant && <div className="cx-preset-variant-panel">
|
||||
{preset && preset.variants.length > 1 && <div className="cx-preset-variants" role="group" aria-label={lang === "zh" ? "接入方式" : "API service"}>
|
||||
<span>{lang === "zh" ? "接入方式" : "API service"}</span>
|
||||
{preset.variants.map((item) => <button key={item.id} type="button" aria-pressed={item.id === variant.id} disabled={disabled} onClick={() => onPresetVariantSelect(item.id)}>{lang === "zh" ? item.label : item.labelEn}</button>)}
|
||||
</div>}
|
||||
<p>{lang === "zh" ? variant.note : variant.noteEn}</p>
|
||||
</div>}
|
||||
</ProviderPresetPicker>;
|
||||
}
|
||||
|
||||
function ProviderAvatar({ row }: { row: ProviderRow }) {
|
||||
const initial = row.providerName.trim().slice(0, 1).toUpperCase() || "?";
|
||||
const className = [
|
||||
@@ -224,6 +387,8 @@ function ActionIconButton({
|
||||
}
|
||||
|
||||
function ListPage({
|
||||
lang,
|
||||
configDir,
|
||||
copy,
|
||||
providerRows,
|
||||
loading,
|
||||
@@ -231,14 +396,23 @@ function ListPage({
|
||||
actionBusy,
|
||||
onImportCcSwitch,
|
||||
onAddProvider,
|
||||
onRestoreOfficial,
|
||||
onEnableProvider,
|
||||
onTestProvider,
|
||||
onEditProvider,
|
||||
onDuplicateProvider,
|
||||
onDeleteProvider,
|
||||
}: Pick<ProvidersPageProps, "copy" | "providerRows" | "loading" | "testingId" | "actionBusy" | "onImportCcSwitch" | "onAddProvider" | "onRestoreOfficial" | "onEnableProvider" | "onTestProvider" | "onEditProvider" | "onDeleteProvider">) {
|
||||
}: Pick<ProvidersPageProps, "lang" | "configDir" | "copy" | "providerRows" | "loading" | "testingId" | "actionBusy" | "onImportCcSwitch" | "onAddProvider" | "onEnableProvider" | "onTestProvider" | "onEditProvider" | "onDuplicateProvider" | "onDeleteProvider">) {
|
||||
const [providerToDelete, setProviderToDelete] = useState<ProviderRow | null>(null);
|
||||
const [quotaSelection, setQuotaSelection] = useState<{ id: string; configDir: string; email: string | null } | null>(null);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const providerActionsBusy = loading || Boolean(actionBusy);
|
||||
const quotaProfile = quotaSelection?.configDir === configDir
|
||||
? providerRows.find((row) => row.source === "official" && row.id === quotaSelection.id && row.canQueryQuota && (row.email ?? null) === quotaSelection.email)
|
||||
: undefined;
|
||||
|
||||
useEffect(() => {
|
||||
if (quotaSelection && !quotaProfile) setQuotaSelection(null);
|
||||
}, [quotaSelection, quotaProfile]);
|
||||
|
||||
const closeDeleteDialog = () => {
|
||||
if (!deleting) setProviderToDelete(null);
|
||||
@@ -267,12 +441,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>
|
||||
@@ -285,37 +459,45 @@ function ListPage({
|
||||
) : providerRows.map((row) => {
|
||||
const testingKey = row.testingKey || `${row.source}-${row.id}`;
|
||||
const isTesting = testingId === testingKey;
|
||||
const isProtectedOfficial = row.source === "official" && row.isDefaultOfficial === true;
|
||||
return (
|
||||
<article className={`cx-providers-row${row.isCurrent ? " cx-providers-row--current" : ""}`} key={`${row.source}-${row.id}-${row.baseUrl}`} role="listitem">
|
||||
<article className={`cx-providers-row${row.isCurrent ? " cx-providers-row--current" : ""}${row.source === "official" ? " cx-providers-row--official" : ""}`} key={`${row.source}-${row.id}-${row.baseUrl}`} role="listitem">
|
||||
<ProviderAvatar row={row} />
|
||||
<div className="cx-providers-row-main">
|
||||
<div className="cx-providers-row-title">
|
||||
<strong>{row.providerName}</strong>
|
||||
{row.sourceLabel && (
|
||||
<span className={`cx-providers-source-badge${row.source === "official" ? " cx-providers-source-badge--official" : ""}`}>
|
||||
{row.sourceLabel}
|
||||
</span>
|
||||
)}
|
||||
<div className="cx-providers-row-content">
|
||||
<div className="cx-providers-row-main">
|
||||
<div className="cx-providers-row-title">
|
||||
<strong title={row.providerName}>{row.providerName}</strong>
|
||||
{row.sourceLabel && (
|
||||
<span className={`cx-providers-source-badge${row.source === "official" ? " cx-providers-source-badge--official" : ""}`}>
|
||||
{row.sourceLabel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<code title={row.baseUrl || copy.noBaseUrlLabel}>{row.baseUrl || copy.noBaseUrlLabel}</code>
|
||||
{row.source !== "official" && row.meta && <div className="cx-providers-row-meta">{row.meta}</div>}
|
||||
</div>
|
||||
<code title={row.baseUrl || copy.noBaseUrlLabel}>{row.baseUrl || copy.noBaseUrlLabel}</code>
|
||||
{row.meta && <div className="cx-providers-row-meta">{row.meta}</div>}
|
||||
</div>
|
||||
{row.source === "official" && <OfficialAccountBadge lang={lang} email={row.email} planType={row.planType} hasAuth={Boolean(row.hasAuth)} canQueryQuota={Boolean(row.canQueryQuota)} className="cx-providers-login-badge" />}
|
||||
<div className="cx-providers-row-actions">
|
||||
{row.isCurrent && <span className="cx-providers-current-badge"><span aria-hidden="true" />{copy.currentLabel}</span>}
|
||||
<button
|
||||
type="button"
|
||||
className="cx-providers-button cx-providers-button--small cx-providers-button--secondary"
|
||||
onClick={() => onEnableProvider(row)}
|
||||
disabled={loading || row.isCurrent}
|
||||
disabled={providerActionsBusy || row.isCurrent}
|
||||
>
|
||||
{copy.enableLabel}
|
||||
</button>
|
||||
{row.source === "official" && (
|
||||
<ActionIconButton
|
||||
icon={RotateCcw}
|
||||
label={copy.restoreOfficialLabel}
|
||||
onClick={onRestoreOfficial}
|
||||
disabled={loading}
|
||||
icon={Gauge}
|
||||
label={row.canQueryQuota
|
||||
? (lang === "zh" ? "查看额度" : "View quota")
|
||||
: row.hasAuth
|
||||
? (lang === "zh" ? "查看额度:此认证不支持订阅额度查询" : "View quota: these credentials do not support subscription quota queries")
|
||||
: (lang === "zh" ? "查看额度:请先登录官方 Codex" : "View quota: sign in to official Codex first")}
|
||||
onClick={() => setQuotaSelection({ id: row.id, configDir, email: row.email ?? null })}
|
||||
disabled={providerActionsBusy || !row.canQueryQuota}
|
||||
/>
|
||||
)}
|
||||
{row.testable !== false && (
|
||||
@@ -323,14 +505,23 @@ 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 />
|
||||
{row.duplicable && (
|
||||
<ActionIconButton icon={Copy} label={copy.duplicateLabel} onClick={() => onDuplicateProvider(row)} disabled={providerActionsBusy} />
|
||||
)}
|
||||
{(row.deletable || isProtectedOfficial) && (
|
||||
<ActionIconButton
|
||||
icon={Trash2}
|
||||
label={isProtectedOfficial ? (lang === "zh" ? "默认官方配置不可删除" : "The default official profile cannot be deleted") : copy.removeLabel}
|
||||
onClick={() => { if (!isProtectedOfficial) setProviderToDelete(row); }}
|
||||
disabled={providerActionsBusy || isProtectedOfficial}
|
||||
danger={!isProtectedOfficial}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
@@ -338,6 +529,13 @@ function ListPage({
|
||||
})}
|
||||
</div>
|
||||
|
||||
<OfficialQuotaDialog
|
||||
lang={lang}
|
||||
configDir={configDir}
|
||||
profile={quotaProfile ? { id: quotaProfile.id, name: quotaProfile.providerName, email: quotaProfile.email ?? null, planType: quotaProfile.planType } : null}
|
||||
onClose={() => setQuotaSelection(null)}
|
||||
/>
|
||||
|
||||
<ModalShell
|
||||
open={Boolean(providerToDelete)}
|
||||
onClose={closeDeleteDialog}
|
||||
@@ -374,7 +572,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 +580,7 @@ function ModeHeader({ eyebrow, title, description, cancelLabel, onCancel }: { ey
|
||||
<h2>{title}</h2>
|
||||
<p>{description}</p>
|
||||
</div>
|
||||
<button type="button" className="cx-providers-button cx-providers-button--secondary" onClick={onCancel}>
|
||||
<button type="button" className="cx-providers-button cx-providers-button--secondary" onClick={onCancel} disabled={disabled}>
|
||||
<ArrowLeft size={15} aria-hidden="true" />
|
||||
{cancelLabel}
|
||||
</button>
|
||||
@@ -391,35 +589,81 @@ function ModeHeader({ eyebrow, title, description, cancelLabel, onCancel }: { ey
|
||||
}
|
||||
|
||||
function OfficialForm({
|
||||
lang,
|
||||
copy,
|
||||
creatingProvider,
|
||||
selectedPresetId,
|
||||
selectedPresetVariantId,
|
||||
onPresetSelect,
|
||||
onPresetVariantSelect,
|
||||
officialForm,
|
||||
officialProfileIsDefault,
|
||||
canLoadCurrentOfficial,
|
||||
officialAuthRef,
|
||||
officialTomlRef,
|
||||
officialInfo,
|
||||
loading,
|
||||
actionBusy,
|
||||
onCancelMode,
|
||||
onOfficialNameChange,
|
||||
onOfficialModelChange,
|
||||
onOfficialAuthChange,
|
||||
onOfficialConfigChange,
|
||||
onSaveOfficial,
|
||||
onRestoreOfficial,
|
||||
onLoadCcSwitchOfficial,
|
||||
onLoadCurrentOfficial,
|
||||
onResetOfficial,
|
||||
}: Pick<ProvidersPageProps, "copy" | "officialForm" | "officialAuthRef" | "officialInfo" | "loading" | "onCancelMode" | "onOfficialModelChange" | "onOfficialAuthChange" | "onSaveOfficial" | "onRestoreOfficial" | "onResetOfficial">) {
|
||||
}: Pick<ProvidersPageProps, "lang" | "copy" | "creatingProvider" | "selectedPresetId" | "selectedPresetVariantId" | "onPresetSelect" | "onPresetVariantSelect" | "officialForm" | "officialProfileIsDefault" | "canLoadCurrentOfficial" | "officialAuthRef" | "officialTomlRef" | "officialInfo" | "loading" | "actionBusy" | "onCancelMode" | "onOfficialNameChange" | "onOfficialModelChange" | "onOfficialAuthChange" | "onOfficialConfigChange" | "onSaveOfficial" | "onLoadCcSwitchOfficial" | "onLoadCurrentOfficial" | "onResetOfficial">) {
|
||||
const [resetConfirmOpen, setResetConfirmOpen] = useState(false);
|
||||
const [contextWindowBusy, setContextWindowBusy] = useState(false);
|
||||
const loadingCcSwitch = actionBusy === "loadCcSwitchOfficial";
|
||||
const loadingCurrent = actionBusy === "loadCurrentOfficial";
|
||||
const formBusy = loading || actionBusy === "loadOfficialDraft" || loadingCcSwitch || loadingCurrent || contextWindowBusy;
|
||||
const showDefaultActions = officialProfileIsDefault && !creatingProvider;
|
||||
const profileHint = lang === "zh"
|
||||
? "认证内容可留空;保存并启用后,在 Codex 中完成登录。复制官方供应商会保留原认证,并可修改名称。"
|
||||
: "Authentication may be left empty. Save and enable this provider, then sign in through Codex. A copied official provider keeps its authentication and can be renamed.";
|
||||
|
||||
const confirmReset = () => {
|
||||
if (!showDefaultActions || formBusy) return;
|
||||
setResetConfirmOpen(false);
|
||||
onResetOfficial();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<ModeHeader eyebrow={copy.officialEyebrow} title={copy.officialTitle} description={copy.officialHint} cancelLabel={copy.cancelLabel} onCancel={onCancelMode} />
|
||||
<ModeHeader
|
||||
eyebrow={copy.officialEyebrow}
|
||||
title={creatingProvider ? copy.formAddTitle : copy.officialTitle}
|
||||
description={showDefaultActions ? `${copy.officialHint} ${profileHint}` : profileHint}
|
||||
cancelLabel={copy.cancelLabel}
|
||||
onCancel={onCancelMode}
|
||||
disabled={formBusy}
|
||||
/>
|
||||
<ProviderPresetSection lang={lang} creatingProvider={creatingProvider} selectedPresetId={selectedPresetId} selectedPresetVariantId={selectedPresetVariantId} onPresetSelect={onPresetSelect} onPresetVariantSelect={onPresetVariantSelect} disabled={formBusy} />
|
||||
<div className="cx-providers-info-grid">
|
||||
<div><span>{copy.officialUrlLabel}</span><code>{officialInfo.officialUrl}</code></div>
|
||||
<div><span>{copy.authPathLabel}</span><code>{officialInfo.authPath}</code></div>
|
||||
<div><span>{copy.officialCurrentLabel}</span><code>{officialInfo.current}</code></div>
|
||||
</div>
|
||||
<div className="cx-providers-form-grid cx-providers-form-grid--single">
|
||||
<Field label={copy.modelLabel}><input value={officialForm.model} onChange={(event) => onOfficialModelChange(event.target.value)} /></Field>
|
||||
<Field label={copy.nameLabel}><input value={officialForm.providerName} onChange={(event) => onOfficialNameChange(event.target.value)} disabled={formBusy} /></Field>
|
||||
<Field label={copy.modelLabel}><input value={officialForm.model} onChange={(event) => onOfficialModelChange(event.target.value)} disabled={formBusy} /></Field>
|
||||
</div>
|
||||
<div className="cx-providers-editor-field">
|
||||
<div className="cx-providers-context-heading">
|
||||
<span>{copy.officialTomlLabel}</span>
|
||||
<ContextWindowControl lang={lang} configText={officialForm.configText} onConfigChange={onOfficialConfigChange} disabled={formBusy} onBusyChange={setContextWindowBusy} />
|
||||
</div>
|
||||
<textarea
|
||||
ref={officialTomlRef}
|
||||
className="cx-providers-code-editor cx-providers-toml-editor"
|
||||
value={officialForm.configText}
|
||||
aria-label={copy.officialTomlLabel}
|
||||
onChange={(event) => onOfficialConfigChange(event.target.value)}
|
||||
disabled={formBusy}
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
<Field label={copy.officialAuthLabel} className="cx-providers-editor-field">
|
||||
<textarea
|
||||
@@ -427,21 +671,33 @@ function OfficialForm({
|
||||
className="cx-providers-code-editor cx-providers-auth-editor"
|
||||
value={officialForm.authJson}
|
||||
onChange={(event) => onOfficialAuthChange(event.target.value)}
|
||||
disabled={formBusy}
|
||||
wrap="soft"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</Field>
|
||||
<div className="cx-providers-form-actions cx-providers-form-actions--save cx-providers-official-actions">
|
||||
<button type="button" className="cx-providers-button cx-providers-button--secondary" onClick={onRestoreOfficial} disabled={loading}>
|
||||
<RotateCcw size={15} aria-hidden="true" />{copy.restoreOfficialLabel}
|
||||
<button type="button" className="cx-providers-button cx-providers-button--secondary" onClick={onLoadCurrentOfficial} disabled={formBusy || !canLoadCurrentOfficial}>
|
||||
{loadingCurrent
|
||||
? <Loader2 size={15} className="cx-providers-spin" aria-hidden="true" />
|
||||
: <Download size={15} aria-hidden="true" />}
|
||||
{lang === "zh" ? "读取当前官方登录" : "Load current official login"}
|
||||
</button>
|
||||
<button type="button" className="cx-providers-button cx-providers-button--secondary" onClick={() => setResetConfirmOpen(true)} disabled={loading}>
|
||||
<FilePlus2 size={15} aria-hidden="true" />{copy.resetOfficialLabel}
|
||||
<button type="button" className="cx-providers-button cx-providers-button--secondary" onClick={onLoadCcSwitchOfficial} disabled={formBusy}>
|
||||
{loadingCcSwitch
|
||||
? <Loader2 size={15} className="cx-providers-spin" aria-hidden="true" />
|
||||
: <Download size={15} aria-hidden="true" />}
|
||||
{copy.loadCcSwitchOfficialLabel}
|
||||
</button>
|
||||
<button type="button" className="cx-providers-button cx-providers-button--primary" onClick={onSaveOfficial} disabled={loading}><CheckCircle2 size={15} aria-hidden="true" />{copy.officialSaveLabel}</button>
|
||||
{showDefaultActions && (
|
||||
<button type="button" className="cx-providers-button cx-providers-button--secondary" onClick={() => setResetConfirmOpen(true)} disabled={formBusy}>
|
||||
<FilePlus2 size={15} aria-hidden="true" />{copy.resetOfficialLabel}
|
||||
</button>
|
||||
)}
|
||||
<button type="button" className="cx-providers-button cx-providers-button--primary" onClick={onSaveOfficial} disabled={formBusy}><CheckCircle2 size={15} aria-hidden="true" />{copy.officialSaveLabel}</button>
|
||||
</div>
|
||||
<ModalShell
|
||||
open={resetConfirmOpen}
|
||||
open={resetConfirmOpen && showDefaultActions}
|
||||
onClose={() => setResetConfirmOpen(false)}
|
||||
title={copy.resetOfficialTitle}
|
||||
description={copy.resetOfficialDescription}
|
||||
@@ -456,7 +712,7 @@ function OfficialForm({
|
||||
>
|
||||
<div className="cx-provider-delete-warning">
|
||||
<span aria-hidden="true"><AlertTriangle size={22} /></span>
|
||||
<strong>auth.json</strong>
|
||||
<strong>config.toml + auth.json</strong>
|
||||
</div>
|
||||
</ModalShell>
|
||||
</>
|
||||
@@ -464,8 +720,16 @@ function OfficialForm({
|
||||
}
|
||||
|
||||
function ProviderForm({
|
||||
lang,
|
||||
copy,
|
||||
creatingProvider,
|
||||
selectedPresetId,
|
||||
selectedPresetVariantId,
|
||||
onPresetSelect,
|
||||
onPresetVariantSelect,
|
||||
providerForm,
|
||||
providerModelMappings,
|
||||
onProviderModelMappingsChange,
|
||||
loading,
|
||||
editingProviderId,
|
||||
providerAuthPreview,
|
||||
@@ -486,9 +750,12 @@ function ProviderForm({
|
||||
onProviderTomlDraftChange,
|
||||
onResetProviderToml,
|
||||
onSaveProvider,
|
||||
}: Pick<ProvidersPageProps, "copy" | "providerForm" | "loading" | "editingProviderId" | "providerAuthPreview" | "providerTomlDraft" | "providerTomlRef" | "apiKeyVisible" | "availableModels" | "fetchingModels" | "onCancelMode" | "onApiKeyChange" | "onBaseUrlChange" | "onProviderNameChange" | "onProviderModelChange" | "onFetchModels" | "onWireApiChange" | "onRequiresAuthChange" | "onToggleApiKeyVisibility" | "onProviderTomlDraftChange" | "onResetProviderToml" | "onSaveProvider">) {
|
||||
}: Pick<ProvidersPageProps, "lang" | "copy" | "creatingProvider" | "selectedPresetId" | "selectedPresetVariantId" | "onPresetSelect" | "onPresetVariantSelect" | "providerForm" | "providerModelMappings" | "onProviderModelMappingsChange" | "loading" | "editingProviderId" | "providerAuthPreview" | "providerTomlDraft" | "providerTomlRef" | "apiKeyVisible" | "availableModels" | "fetchingModels" | "onCancelMode" | "onApiKeyChange" | "onBaseUrlChange" | "onProviderNameChange" | "onProviderModelChange" | "onFetchModels" | "onWireApiChange" | "onRequiresAuthChange" | "onToggleApiKeyVisibility" | "onProviderTomlDraftChange" | "onResetProviderToml" | "onSaveProvider">) {
|
||||
const modelListId = useId();
|
||||
const [contextWindowBusy, setContextWindowBusy] = useState(false);
|
||||
const canFetchModels = Boolean(providerForm.baseUrl.trim() && providerForm.apiKey.trim());
|
||||
const formBusy = loading || fetchingModels || contextWindowBusy;
|
||||
const mappingsValid = validateProviderModelMappings(providerModelMappings, providerForm.model, lang).valid;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -498,7 +765,9 @@ function ProviderForm({
|
||||
description={copy.formHint}
|
||||
cancelLabel={copy.cancelLabel}
|
||||
onCancel={onCancelMode}
|
||||
disabled={formBusy}
|
||||
/>
|
||||
<ProviderPresetSection lang={lang} creatingProvider={creatingProvider} selectedPresetId={selectedPresetId} selectedPresetVariantId={selectedPresetVariantId} onPresetSelect={onPresetSelect} onPresetVariantSelect={onPresetVariantSelect} disabled={formBusy} />
|
||||
|
||||
<section className="cx-providers-form-section">
|
||||
<div className="cx-providers-section-heading">
|
||||
@@ -512,14 +781,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 +797,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 +822,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 +834,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,10 +844,21 @@ function ProviderForm({
|
||||
checked={providerForm.requiresOpenaiAuth}
|
||||
onCheckedChange={onRequiresAuthChange}
|
||||
label={copy.requiresAuthLabel}
|
||||
disabled={formBusy}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<ProviderModelMappings
|
||||
key={editingProviderId ?? "new-provider"}
|
||||
lang={lang}
|
||||
rows={providerModelMappings}
|
||||
currentModel={providerForm.model}
|
||||
availableModels={availableModels}
|
||||
disabled={formBusy}
|
||||
onChange={onProviderModelMappingsChange}
|
||||
/>
|
||||
|
||||
<section className="cx-providers-form-section">
|
||||
<div className="cx-providers-section-heading"><div><h3>{copy.authPreviewTitle}</h3><p>{copy.authPreviewDescription}</p></div></div>
|
||||
<div className="cx-providers-preview">{providerAuthPreview}</div>
|
||||
@@ -584,13 +867,17 @@ function ProviderForm({
|
||||
<section className="cx-providers-form-section">
|
||||
<div className="cx-providers-section-heading cx-providers-section-heading--with-action">
|
||||
<div><h3>{copy.tomlTitle}</h3><p>{copy.tomlDescription}</p></div>
|
||||
<button type="button" className="cx-providers-button cx-providers-button--secondary cx-providers-button--small" onClick={onResetProviderToml}><RefreshCw size={14} aria-hidden="true" />{copy.resetTomlLabel}</button>
|
||||
<div className="cx-providers-context-actions">
|
||||
<ContextWindowControl lang={lang} configText={providerTomlDraft} onConfigChange={onProviderTomlDraftChange} disabled={formBusy} onBusyChange={setContextWindowBusy} />
|
||||
<button type="button" className="cx-providers-button cx-providers-button--secondary cx-providers-button--small" onClick={onResetProviderToml} disabled={formBusy}><RefreshCw size={14} aria-hidden="true" />{copy.resetTomlLabel}</button>
|
||||
</div>
|
||||
</div>
|
||||
<textarea ref={providerTomlRef} className="cx-providers-code-editor cx-providers-toml-editor" value={providerTomlDraft} onChange={(event) => onProviderTomlDraftChange(event.target.value)} spellCheck={false} />
|
||||
<textarea ref={providerTomlRef} className="cx-providers-code-editor cx-providers-toml-editor" aria-label={copy.tomlTitle} value={providerTomlDraft} onChange={(event) => onProviderTomlDraftChange(event.target.value)} disabled={formBusy} spellCheck={false} />
|
||||
</section>
|
||||
|
||||
<div className="cx-providers-form-actions cx-providers-form-actions--save">
|
||||
<button type="button" className="cx-providers-button cx-providers-button--primary" onClick={onSaveProvider} disabled={loading}>
|
||||
{!mappingsValid && <span className="cx-provider-mappings-save-hint">{lang === "zh" ? "请先修正模型映射中的错误。" : "Correct the model mapping errors before saving."}</span>}
|
||||
<button type="button" className="cx-providers-button cx-providers-button--primary" onClick={onSaveProvider} disabled={formBusy || !mappingsValid}>
|
||||
{loading ? <Loader2 size={15} className="cx-providers-spin" aria-hidden="true" /> : <CheckCircle2 size={15} aria-hidden="true" />}
|
||||
{loading ? copy.savingLabel : copy.saveLabel}
|
||||
</button>
|
||||
@@ -600,8 +887,12 @@ function ProviderForm({
|
||||
}
|
||||
|
||||
export function ProvidersPage(props: ProvidersPageProps) {
|
||||
const pageKey = props.creatingProvider && props.mode !== "list"
|
||||
? "providers:add"
|
||||
: `providers:${props.mode}`;
|
||||
|
||||
return (
|
||||
<PageTransition pageKey={`providers:${props.mode}`}>
|
||||
<PageTransition pageKey={pageKey}>
|
||||
<section className={`cx-providers cx-page cx-providers--${props.mode}`}>
|
||||
{props.mode === "list" && <ListPage {...props} />}
|
||||
{props.mode === "official" && <OfficialForm {...props} />}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useId } from "react";
|
||||
import { useId, useState } from "react";
|
||||
import type { ChangeEvent, RefObject } from "react";
|
||||
import {
|
||||
AlertCircle,
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
FolderInput,
|
||||
Loader2,
|
||||
PackageOpen,
|
||||
PencilLine,
|
||||
PlugZap,
|
||||
RefreshCw,
|
||||
Sparkles,
|
||||
@@ -14,11 +15,12 @@ import {
|
||||
} from "lucide-react";
|
||||
|
||||
import { PageTransition } from "../components/PageTransition";
|
||||
import { Button, ModalShell, StatusBadge, Toggle, cx } from "../components/ui";
|
||||
import { Button, IconButton, ModalShell, StatusBadge, Toggle, cx } from "../components/ui";
|
||||
import type { Lang, ManagedMcpServer, ManagedSkill, SkillsMcpImportPreview, SkillsMcpState } from "../types";
|
||||
import "../styles/skills-prompts-pages.css";
|
||||
|
||||
export type SkillsMcpTab = "mcp" | "skills";
|
||||
export type SkillsMcpNoteKind = "mcp" | "skill";
|
||||
|
||||
type MaybeAsyncAction = () => void | Promise<void>;
|
||||
|
||||
@@ -27,6 +29,7 @@ export type SkillsMcpPageProps = {
|
||||
state: SkillsMcpState | null;
|
||||
activeTab: SkillsMcpTab;
|
||||
actionBusy: string;
|
||||
noteBusyKey: string;
|
||||
importOpen: boolean;
|
||||
importPreview: SkillsMcpImportPreview | null;
|
||||
zipInputRef: RefObject<HTMLInputElement>;
|
||||
@@ -40,6 +43,14 @@ export type SkillsMcpPageProps = {
|
||||
onCheckUpdates: MaybeAsyncAction;
|
||||
onToggleSkill: (id: string, enabled: boolean) => void | Promise<void>;
|
||||
onToggleMcp: (id: string, enabled: boolean) => void | Promise<void>;
|
||||
onSaveNote: (itemKind: SkillsMcpNoteKind, id: string, note: string) => Promise<boolean>;
|
||||
};
|
||||
|
||||
type NoteTarget = {
|
||||
itemKind: SkillsMcpNoteKind;
|
||||
id: string;
|
||||
name: string;
|
||||
note: string;
|
||||
};
|
||||
|
||||
type SkillsMcpCopy = ReturnType<typeof getCopy>;
|
||||
@@ -65,6 +76,16 @@ function getCopy(lang: Lang) {
|
||||
enableSkill: "启用 Skill",
|
||||
disableSkill: "禁用 Skill",
|
||||
updateStatus: "更新状态",
|
||||
addNote: "添加备注",
|
||||
editNote: "编辑备注",
|
||||
noteTitle: "自定义备注",
|
||||
noteDescription: (name: string) => `为“${name}”添加便于识别的说明。`,
|
||||
noteLabel: "备注内容",
|
||||
notePlaceholder: "例如:用于查询项目文档,依赖本地 Node.js",
|
||||
noteEmptyHint: "留空保存将清除现有备注",
|
||||
noteCount: (count: number) => `${count} / 1000`,
|
||||
saveNote: "保存备注",
|
||||
savingNote: "保存中",
|
||||
importTitle: "确认导入已有内容",
|
||||
importDescription: "以下内容来自本机现有配置。导入后可在此统一启用或禁用。",
|
||||
noImportItems: "没有发现可导入的已有 Skills / MCP。",
|
||||
@@ -94,6 +115,16 @@ function getCopy(lang: Lang) {
|
||||
enableSkill: "Enable Skill",
|
||||
disableSkill: "Disable Skill",
|
||||
updateStatus: "Update status",
|
||||
addNote: "Add note",
|
||||
editNote: "Edit note",
|
||||
noteTitle: "Custom note",
|
||||
noteDescription: (name: string) => `Add a description that helps you recognize “${name}”.`,
|
||||
noteLabel: "Note",
|
||||
notePlaceholder: "For example: searches project docs; requires local Node.js",
|
||||
noteEmptyHint: "Save an empty note to remove it",
|
||||
noteCount: (count: number) => `${count} / 1000`,
|
||||
saveNote: "Save note",
|
||||
savingNote: "Saving",
|
||||
importTitle: "Confirm import",
|
||||
importDescription: "These items were found in the existing local configuration. Import them to manage their state here.",
|
||||
noImportItems: "No existing Skills / MCP items were found.",
|
||||
@@ -116,22 +147,33 @@ function McpRow({
|
||||
disabled,
|
||||
copy,
|
||||
onToggle,
|
||||
onEditNote,
|
||||
}: {
|
||||
server: ManagedMcpServer;
|
||||
busy: boolean;
|
||||
disabled: boolean;
|
||||
copy: SkillsMcpCopy;
|
||||
onToggle: SkillsMcpPageProps["onToggleMcp"];
|
||||
onEditNote: (target: NoteTarget) => void;
|
||||
}) {
|
||||
const name = server.name || server.id;
|
||||
const details = [server.summary, server.transport, server.source].filter(Boolean).join("\n");
|
||||
const details = [server.note, server.summary, server.transport, server.source].filter(Boolean).join("\n");
|
||||
return (
|
||||
<article className="cx-skills-row">
|
||||
<div className="cx-skills-row-copy">
|
||||
<div className={cx("cx-skills-row-copy", Boolean(server.note) && "cx-skills-row-copy--noted")}>
|
||||
<strong title={details ? `${name}\n${details}` : name}>{name}</strong>
|
||||
{server.note && <span className="cx-skills-row-note" title={server.note}>{server.note}</span>}
|
||||
</div>
|
||||
<div className="cx-skills-row-control">
|
||||
{busy && <Loader2 size={15} className="cx-skills-spin" aria-hidden="true" />}
|
||||
<IconButton
|
||||
icon={<PencilLine size={15} strokeWidth={1.9} />}
|
||||
label={server.note ? copy.editNote : copy.addNote}
|
||||
size="sm"
|
||||
className="cx-skills-note-button"
|
||||
onClick={() => onEditNote({ itemKind: "mcp", id: server.id, name, note: server.note || "" })}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Toggle
|
||||
checked={server.enabled}
|
||||
onCheckedChange={(enabled) => void onToggle(server.id, enabled)}
|
||||
@@ -149,12 +191,14 @@ function SkillRow({
|
||||
disabled,
|
||||
copy,
|
||||
onToggle,
|
||||
onEditNote,
|
||||
}: {
|
||||
skill: ManagedSkill;
|
||||
busy: boolean;
|
||||
disabled: boolean;
|
||||
copy: SkillsMcpCopy;
|
||||
onToggle: SkillsMcpPageProps["onToggleSkill"];
|
||||
onEditNote: (target: NoteTarget) => void;
|
||||
}) {
|
||||
const name = skill.name || skill.directory;
|
||||
const updateTone = skill.updateStatus.includes("失败") || skill.updateStatus.toLowerCase().includes("fail")
|
||||
@@ -164,20 +208,29 @@ function SkillRow({
|
||||
: "neutral";
|
||||
|
||||
const showUpdateStatus = Boolean(skill.updateStatus && skill.updateStatus !== "未检查");
|
||||
const details = [skill.description, skill.source].filter(Boolean).join("\n");
|
||||
const details = [skill.note, skill.description, skill.source].filter(Boolean).join("\n");
|
||||
|
||||
return (
|
||||
<article className="cx-skills-row">
|
||||
<div className="cx-skills-row-copy">
|
||||
<div className={cx("cx-skills-row-copy", Boolean(skill.note) && "cx-skills-row-copy--noted")}>
|
||||
<strong title={details ? `${name}\n${details}` : name}>{name}</strong>
|
||||
{skill.note && <span className="cx-skills-row-note" title={skill.note}>{skill.note}</span>}
|
||||
{showUpdateStatus && (
|
||||
<StatusBadge tone={updateTone} dot={false} title={copy.updateStatus}>
|
||||
<StatusBadge tone={updateTone} dot={false} title={`${copy.updateStatus}: ${skill.updateStatus}`}>
|
||||
{skill.updateStatus}
|
||||
</StatusBadge>
|
||||
)}
|
||||
</div>
|
||||
<div className="cx-skills-row-control">
|
||||
{busy && <Loader2 size={15} className="cx-skills-spin" aria-hidden="true" />}
|
||||
<IconButton
|
||||
icon={<PencilLine size={15} strokeWidth={1.9} />}
|
||||
label={skill.note ? copy.editNote : copy.addNote}
|
||||
size="sm"
|
||||
className="cx-skills-note-button"
|
||||
onClick={() => onEditNote({ itemKind: "skill", id: skill.id, name, note: skill.note || "" })}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Toggle
|
||||
checked={skill.enabled}
|
||||
onCheckedChange={(enabled) => void onToggle(skill.id, enabled)}
|
||||
@@ -255,6 +308,7 @@ export function SkillsMcpPage({
|
||||
state,
|
||||
activeTab,
|
||||
actionBusy,
|
||||
noteBusyKey,
|
||||
importOpen,
|
||||
importPreview,
|
||||
zipInputRef,
|
||||
@@ -268,12 +322,34 @@ export function SkillsMcpPage({
|
||||
onCheckUpdates,
|
||||
onToggleSkill,
|
||||
onToggleMcp,
|
||||
onSaveNote,
|
||||
}: SkillsMcpPageProps) {
|
||||
const copy = getCopy(lang);
|
||||
const tabId = useId();
|
||||
const anyBusy = Boolean(actionBusy);
|
||||
const importBusy = actionBusy === "importExistingSkillsMcp";
|
||||
const activeItems = activeTab === "mcp" ? state?.mcpServers ?? [] : state?.skills ?? [];
|
||||
const [noteTarget, setNoteTarget] = useState<NoteTarget | null>(null);
|
||||
const [noteDraft, setNoteDraft] = useState("");
|
||||
const noteBusy = noteTarget
|
||||
? noteBusyKey === `note:${noteTarget.itemKind}:${noteTarget.id}`
|
||||
: false;
|
||||
const openNoteEditor = (target: NoteTarget) => {
|
||||
setNoteTarget(target);
|
||||
setNoteDraft(target.note);
|
||||
};
|
||||
const closeNoteEditor = () => {
|
||||
if (!noteBusy) setNoteTarget(null);
|
||||
};
|
||||
const updateNoteDraft = (value: string) => {
|
||||
setNoteDraft(Array.from(value).slice(0, 1000).join(""));
|
||||
};
|
||||
const saveNote = async () => {
|
||||
if (!noteTarget || noteBusy) return;
|
||||
if (await onSaveNote(noteTarget.itemKind, noteTarget.id, noteDraft)) {
|
||||
setNoteTarget(null);
|
||||
}
|
||||
};
|
||||
const handleZipChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
void onInstallZip(event.currentTarget.files?.[0]);
|
||||
};
|
||||
@@ -395,6 +471,7 @@ export function SkillsMcpPage({
|
||||
disabled={anyBusy}
|
||||
copy={copy}
|
||||
onToggle={onToggleMcp}
|
||||
onEditNote={openNoteEditor}
|
||||
/>
|
||||
))
|
||||
) : state.skills.length === 0 ? (
|
||||
@@ -407,6 +484,7 @@ export function SkillsMcpPage({
|
||||
disabled={anyBusy}
|
||||
copy={copy}
|
||||
onToggle={onToggleSkill}
|
||||
onEditNote={openNoteEditor}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -453,6 +531,48 @@ export function SkillsMcpPage({
|
||||
>
|
||||
<ImportPreviewContent preview={importPreview} copy={copy} />
|
||||
</ModalShell>
|
||||
|
||||
<ModalShell
|
||||
open={Boolean(noteTarget)}
|
||||
onClose={closeNoteEditor}
|
||||
title={copy.noteTitle}
|
||||
description={noteTarget ? copy.noteDescription(noteTarget.name) : undefined}
|
||||
size="sm"
|
||||
closeOnBackdrop={!noteBusy}
|
||||
closeOnEscape={!noteBusy}
|
||||
showCloseButton={!noteBusy}
|
||||
className="cx-skills-note-modal"
|
||||
footer={(
|
||||
<>
|
||||
<Button variant="secondary" onClick={closeNoteEditor} disabled={noteBusy}>
|
||||
{copy.cancel}
|
||||
</Button>
|
||||
<Button
|
||||
icon={noteBusy ? <Loader2 className="cx-skills-spin" /> : <PencilLine />}
|
||||
onClick={() => void saveNote()}
|
||||
disabled={noteBusy}
|
||||
>
|
||||
{noteBusy ? copy.savingNote : copy.saveNote}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<label className="cx-skills-note-field">
|
||||
<span>{copy.noteLabel}</span>
|
||||
<textarea
|
||||
value={noteDraft}
|
||||
onChange={(event) => updateNoteDraft(event.target.value)}
|
||||
placeholder={copy.notePlaceholder}
|
||||
rows={5}
|
||||
disabled={noteBusy}
|
||||
data-initial-focus
|
||||
/>
|
||||
</label>
|
||||
<div className="cx-skills-note-meta">
|
||||
<span>{copy.noteEmptyHint}</span>
|
||||
<span>{copy.noteCount(Array.from(noteDraft).length)}</span>
|
||||
</div>
|
||||
</ModalShell>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,475 +0,0 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import type { CSSProperties, ChangeEvent, RefObject } from "react";
|
||||
import { convertFileSrc } from "@tauri-apps/api/core";
|
||||
import {
|
||||
Check,
|
||||
Download,
|
||||
ImagePlus,
|
||||
Loader2,
|
||||
Palette,
|
||||
Pencil,
|
||||
Play,
|
||||
Power,
|
||||
RefreshCw,
|
||||
Settings2,
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
|
||||
import { PromptCategoryManager } from "../components/PromptCategoryManager";
|
||||
import type { PromptCategoryItem } from "../components/PromptCategoryManager";
|
||||
import { PageTransition } from "../components/PageTransition";
|
||||
import { SkinThemeEditor } from "../components/SkinThemeEditor";
|
||||
import { Button, IconButton, StatusBadge, cx } from "../components/ui";
|
||||
import { useManagedCategories } from "../promptCategories";
|
||||
import type { ManagedCategoryOptions } from "../promptCategories";
|
||||
import type { Lang, SkinCenterState, SkinThemeSummary } from "../types";
|
||||
import "../styles/skins-page.css";
|
||||
|
||||
type MaybeAsyncAction = () => void | Promise<void>;
|
||||
|
||||
const SKIN_CATEGORY_OPTIONS: ManagedCategoryOptions = {
|
||||
storageKey: "codexx.skinCategories.v1",
|
||||
defaultCategories: (lang) => lang === "zh"
|
||||
? [
|
||||
{ id: "anime", name: "动漫" },
|
||||
{ id: "scenery", name: "风景" },
|
||||
{ id: "minimal", name: "简约" },
|
||||
]
|
||||
: [
|
||||
{ id: "anime", name: "Anime" },
|
||||
{ id: "scenery", name: "Scenery" },
|
||||
{ id: "minimal", name: "Minimal" },
|
||||
],
|
||||
};
|
||||
|
||||
export type SkinsPageProps = {
|
||||
lang: Lang;
|
||||
state: SkinCenterState | null;
|
||||
actionBusy: string;
|
||||
pauseBusy: boolean;
|
||||
zipInputRef: RefObject<HTMLInputElement>;
|
||||
imageInputRef: RefObject<HTMLInputElement>;
|
||||
onLoad: MaybeAsyncAction;
|
||||
onImportZip: (file?: File | null) => Promise<string | null>;
|
||||
onCreateFromImage: (file?: File | null) => Promise<string | null>;
|
||||
onUpdateThemeSettings: (id: string, name: string, tagline: string, surfaceOpacity: number) => Promise<boolean>;
|
||||
onEnableTheme: (id: string) => void | Promise<void>;
|
||||
onExportTheme: (id: string) => void | Promise<void>;
|
||||
onPauseTheme: MaybeAsyncAction;
|
||||
};
|
||||
|
||||
function getCopy(lang: Lang) {
|
||||
return lang === "zh"
|
||||
? {
|
||||
eyebrow: "SKIN CENTER",
|
||||
title: "皮肤中心",
|
||||
description: "在 Codex-X 中管理主题包,并直接应用到官方 Codex Desktop。",
|
||||
refresh: "刷新",
|
||||
refreshingButton: "刷新中",
|
||||
refreshing: "正在刷新主题库...",
|
||||
importZip: "导入主题包",
|
||||
createFromImage: "从图片创建皮肤",
|
||||
creatingFromImage: "正在创建",
|
||||
pause: "关闭皮肤",
|
||||
pausing: "关闭中",
|
||||
loading: "正在读取皮肤库...",
|
||||
builtin: "内置",
|
||||
imported: "导入",
|
||||
selected: "已选择",
|
||||
applied: "已启用",
|
||||
apply: "启用",
|
||||
export: "导出",
|
||||
edit: "编辑",
|
||||
applying: "启用中",
|
||||
exporting: "导出中",
|
||||
current: "当前皮肤",
|
||||
runtime: "Codex 运行状态",
|
||||
noCurrent: "尚未启用皮肤",
|
||||
noThemes: "还没有皮肤。选择一张图片即可创建。",
|
||||
manageCategories: "分类管理",
|
||||
emptyCategory: "该分类下暂无皮肤",
|
||||
statusReady: "主题库已就绪",
|
||||
selectTheme: (name: string) => `选择皮肤:${name}`,
|
||||
}
|
||||
: {
|
||||
eyebrow: "SKIN CENTER",
|
||||
title: "Skin Center",
|
||||
description: "Manage theme packs in Codex-X and apply them directly to the official Codex Desktop app.",
|
||||
refresh: "Refresh",
|
||||
refreshingButton: "Refreshing",
|
||||
refreshing: "Refreshing skin library...",
|
||||
importZip: "Import theme ZIP",
|
||||
createFromImage: "Create from image",
|
||||
creatingFromImage: "Creating",
|
||||
pause: "Turn off skin",
|
||||
pausing: "Turning off",
|
||||
loading: "Loading skin library...",
|
||||
builtin: "Built-in",
|
||||
imported: "Imported",
|
||||
selected: "Selected",
|
||||
applied: "Enabled",
|
||||
apply: "Enable",
|
||||
export: "Export",
|
||||
edit: "Edit",
|
||||
applying: "Enabling",
|
||||
exporting: "Exporting",
|
||||
current: "Current skin",
|
||||
runtime: "Codex runtime",
|
||||
noCurrent: "No skin enabled yet",
|
||||
noThemes: "No skins yet. Choose an image to create one.",
|
||||
manageCategories: "Manage categories",
|
||||
emptyCategory: "No skins in this category",
|
||||
statusReady: "Theme library ready",
|
||||
selectTheme: (name: string) => `Select skin: ${name}`,
|
||||
};
|
||||
}
|
||||
|
||||
function run(action: MaybeAsyncAction) {
|
||||
void action();
|
||||
}
|
||||
|
||||
function sourceLabel(theme: SkinThemeSummary, copy: ReturnType<typeof getCopy>) {
|
||||
return theme.source === "builtin" ? copy.builtin : copy.imported;
|
||||
}
|
||||
|
||||
function ThemeCard({
|
||||
theme,
|
||||
copy,
|
||||
runtimeActive,
|
||||
activeThemeId,
|
||||
selected,
|
||||
onSelect,
|
||||
}: {
|
||||
theme: SkinThemeSummary;
|
||||
copy: ReturnType<typeof getCopy>;
|
||||
runtimeActive: boolean;
|
||||
activeThemeId?: string | null;
|
||||
selected: boolean;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
const applied = runtimeActive && activeThemeId === theme.id;
|
||||
const focusX = Math.round((theme.art.focusX ?? 0.5) * 100);
|
||||
const focusY = Math.round((theme.art.focusY ?? 0.5) * 100);
|
||||
const safeArea = theme.art.safeArea === "left" || theme.art.safeArea === "right" || theme.art.safeArea === "center"
|
||||
? theme.art.safeArea
|
||||
: "center";
|
||||
const style = {
|
||||
"--skin-image": `url("${convertFileSrc(theme.imagePath || `${theme.directory}/${theme.image}`)}")`,
|
||||
"--skin-bg": theme.colors.background,
|
||||
"--skin-panel": theme.colors.panel,
|
||||
"--skin-accent": theme.colors.accent,
|
||||
"--skin-secondary": theme.colors.secondary,
|
||||
"--skin-highlight": theme.colors.highlight,
|
||||
"--skin-text": theme.colors.text,
|
||||
"--skin-muted": theme.colors.muted,
|
||||
"--skin-line": theme.colors.line,
|
||||
"--skin-focus-x": `${focusX}%`,
|
||||
"--skin-focus-y": `${focusY}%`,
|
||||
} as CSSProperties;
|
||||
|
||||
return (
|
||||
<article
|
||||
className={cx(
|
||||
"cx-skins-card",
|
||||
(theme.enabled || applied) && "cx-skins-card--enabled",
|
||||
selected && "cx-skins-card--selected",
|
||||
)}
|
||||
style={style}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-pressed={selected}
|
||||
aria-label={copy.selectTheme(theme.name)}
|
||||
onClick={onSelect}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
onSelect();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className={cx("cx-skins-preview", theme.adaptive && "cx-skins-preview--adaptive")} aria-hidden="true">
|
||||
<div className={cx("cx-skins-preview-app", `cx-skins-preview-app--${safeArea}`)}>
|
||||
<aside className="cx-skins-preview-sidebar">
|
||||
<div className="cx-skins-preview-brand"><i /><span /></div>
|
||||
<div className="cx-skins-preview-nav">
|
||||
<span /><span className="is-active" /><span /><span />
|
||||
</div>
|
||||
<div className="cx-skins-preview-profile" />
|
||||
</aside>
|
||||
<div className="cx-skins-preview-main">
|
||||
<div className="cx-skins-preview-topbar"><span /><i /></div>
|
||||
<div className="cx-skins-preview-chat">
|
||||
<div className="cx-skins-preview-message"><i /><span /></div>
|
||||
<div className="cx-skins-preview-message cx-skins-preview-message--user"><span /></div>
|
||||
</div>
|
||||
<div className="cx-skins-preview-composer"><span /><i /></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="cx-skins-card-body">
|
||||
<div className="cx-skins-card-title">
|
||||
<div>
|
||||
<strong>{theme.name}</strong>
|
||||
<span>{theme.tagline}</span>
|
||||
</div>
|
||||
<StatusBadge tone={applied ? "success" : theme.enabled ? "info" : "neutral"} dot={applied || theme.enabled}>
|
||||
{applied ? copy.applied : theme.enabled ? copy.selected : sourceLabel(theme, copy)}
|
||||
</StatusBadge>
|
||||
</div>
|
||||
<p aria-hidden={!theme.quote}>{theme.quote || "\u00a0"}</p>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
export function SkinsPage({
|
||||
lang,
|
||||
state,
|
||||
actionBusy,
|
||||
pauseBusy,
|
||||
zipInputRef,
|
||||
imageInputRef,
|
||||
onLoad,
|
||||
onImportZip,
|
||||
onCreateFromImage,
|
||||
onUpdateThemeSettings,
|
||||
onEnableTheme,
|
||||
onExportTheme,
|
||||
onPauseTheme,
|
||||
}: SkinsPageProps) {
|
||||
const copy = getCopy(lang);
|
||||
const themes = state?.themes ?? [];
|
||||
const current = themes.find((theme) => theme.enabled);
|
||||
const loading = actionBusy === "loadSkins";
|
||||
const controlsBusy = Boolean(actionBusy) || pauseBusy;
|
||||
const runtime = state?.runtime;
|
||||
const [categoryManagerOpen, setCategoryManagerOpen] = useState(false);
|
||||
const [editorOpen, setEditorOpen] = useState(false);
|
||||
const [selectedThemeId, setSelectedThemeId] = useState<string | null>(null);
|
||||
const skinCategories = useManagedCategories(lang, SKIN_CATEGORY_OPTIONS);
|
||||
const categoryItems = useMemo<PromptCategoryItem[]>(() => themes.map((theme) => ({
|
||||
key: `skin:${theme.id.trim().toLowerCase()}`,
|
||||
title: theme.name,
|
||||
})), [themes]);
|
||||
const visibleThemes = themes.filter((theme) =>
|
||||
skinCategories.categoryForPrompt(`skin:${theme.id.trim().toLowerCase()}`) === skinCategories.activeCategoryId);
|
||||
const selectedTheme = themes.find((theme) => theme.id === selectedThemeId);
|
||||
const selectedThemeApplied = Boolean(
|
||||
selectedTheme && runtime?.active && runtime.themeId === selectedTheme.id,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedThemeId && !themes.some((theme) => theme.id === selectedThemeId)) {
|
||||
setSelectedThemeId(null);
|
||||
}
|
||||
}, [selectedThemeId, themes]);
|
||||
|
||||
const selectCreatedTheme = (themeId: string | null) => {
|
||||
if (!themeId) return;
|
||||
const categoryId = skinCategories.categoryForPrompt(`skin:${themeId.trim().toLowerCase()}`);
|
||||
skinCategories.setActiveCategoryId(categoryId);
|
||||
setSelectedThemeId(themeId);
|
||||
};
|
||||
|
||||
const handleZipChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
void onImportZip(event.currentTarget.files?.[0] ?? null).then(selectCreatedTheme);
|
||||
};
|
||||
|
||||
const handleImageChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
void onCreateFromImage(event.currentTarget.files?.[0] ?? null).then(selectCreatedTheme);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className={cx("cx-skins-page", loading && "cx-skins-page--refreshing")} aria-busy={loading}>
|
||||
<PromptCategoryManager
|
||||
open={categoryManagerOpen}
|
||||
lang={lang}
|
||||
categories={skinCategories.categories}
|
||||
prompts={categoryItems}
|
||||
categoryForPrompt={skinCategories.categoryForPrompt}
|
||||
onClose={() => setCategoryManagerOpen(false)}
|
||||
onAddCategory={skinCategories.addCategory}
|
||||
onRenameCategory={skinCategories.renameCategory}
|
||||
onDeleteCategory={skinCategories.deleteCategory}
|
||||
onMovePrompt={skinCategories.movePrompt}
|
||||
subject="skin"
|
||||
/>
|
||||
<SkinThemeEditor
|
||||
lang={lang}
|
||||
open={editorOpen}
|
||||
theme={selectedTheme || null}
|
||||
busy={Boolean(selectedTheme && actionBusy === `skinEdit:${selectedTheme.id}`)}
|
||||
onClose={() => setEditorOpen(false)}
|
||||
onSave={onUpdateThemeSettings}
|
||||
/>
|
||||
<input
|
||||
ref={zipInputRef}
|
||||
className="cx-skins-file-input"
|
||||
type="file"
|
||||
accept=".zip,application/zip"
|
||||
onChange={handleZipChange}
|
||||
/>
|
||||
<input
|
||||
ref={imageInputRef}
|
||||
className="cx-skins-file-input"
|
||||
type="file"
|
||||
accept=".png,.jpg,.jpeg,.webp,image/png,image/jpeg,image/webp"
|
||||
onChange={handleImageChange}
|
||||
/>
|
||||
|
||||
<header className="cx-skins-header">
|
||||
<div className="cx-skins-heading">
|
||||
<p><Palette size={15} aria-hidden="true" />{copy.eyebrow}</p>
|
||||
<h2>{copy.title}</h2>
|
||||
<span>{copy.description}</span>
|
||||
</div>
|
||||
<div className="cx-skins-actions">
|
||||
{selectedTheme && (
|
||||
<>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => setEditorOpen(true)}
|
||||
disabled={controlsBusy}
|
||||
icon={<Pencil size={15} />}
|
||||
>
|
||||
{copy.edit}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => run(() => onEnableTheme(selectedTheme.id))}
|
||||
disabled={controlsBusy || selectedThemeApplied}
|
||||
icon={actionBusy === `skin:${selectedTheme.id}`
|
||||
? <Loader2 className="cx-skins-spin" size={15} />
|
||||
: selectedThemeApplied ? <Check size={15} /> : <Play size={15} />}
|
||||
>
|
||||
{actionBusy === `skin:${selectedTheme.id}`
|
||||
? copy.applying
|
||||
: selectedThemeApplied ? copy.applied : copy.apply}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => run(() => onExportTheme(selectedTheme.id))}
|
||||
disabled={controlsBusy}
|
||||
icon={actionBusy === `skinExport:${selectedTheme.id}`
|
||||
? <Loader2 className="cx-skins-spin" size={15} />
|
||||
: <Download size={15} />}
|
||||
>
|
||||
{actionBusy === `skinExport:${selectedTheme.id}` ? copy.exporting : copy.export}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => run(onPauseTheme)}
|
||||
disabled={pauseBusy}
|
||||
icon={pauseBusy ? <Loader2 className="cx-skins-spin" size={15} /> : <Power size={15} />}
|
||||
>
|
||||
{pauseBusy ? copy.pausing : copy.pause}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => zipInputRef.current?.click()}
|
||||
disabled={controlsBusy || !state}
|
||||
icon={actionBusy === "importSkinZip" ? <Loader2 className="cx-skins-spin" size={15} /> : <Upload size={15} />}
|
||||
>
|
||||
{copy.importZip}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => imageInputRef.current?.click()}
|
||||
disabled={controlsBusy || !state}
|
||||
icon={actionBusy === "createSkinFromImage"
|
||||
? <Loader2 className="cx-skins-spin" size={15} />
|
||||
: <ImagePlus size={15} />}
|
||||
>
|
||||
{actionBusy === "createSkinFromImage" ? copy.creatingFromImage : copy.createFromImage}
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="cx-skins-status" aria-label={copy.statusReady}>
|
||||
<div>
|
||||
<span>{copy.current}</span>
|
||||
<strong>{current?.name || copy.noCurrent}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>{copy.runtime}</span>
|
||||
<strong title={runtime?.message || ""}>{runtime?.message || "-"}</strong>
|
||||
</div>
|
||||
<IconButton
|
||||
className="cx-skins-refresh-button"
|
||||
icon={loading ? <Loader2 className="cx-skins-spin" /> : <RefreshCw />}
|
||||
label={loading ? copy.refreshingButton : copy.refresh}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => run(onLoad)}
|
||||
disabled={controlsBusy}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{loading && state && (
|
||||
<div className="cx-skins-refresh-status" role="status" aria-live="polite">
|
||||
<Loader2 className="cx-skins-spin" size={15} aria-hidden="true" />
|
||||
<span>{copy.refreshing}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{themes.length > 0 && (
|
||||
<div className="cx-prompt-category-toolbar cx-skins-category-toolbar">
|
||||
<div className="cx-skills-tabs cx-prompt-category-tabs" role="group" aria-label={copy.manageCategories}>
|
||||
{skinCategories.categories.map((category) => (
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={category.id === skinCategories.activeCategoryId}
|
||||
className={cx(
|
||||
"cx-skills-tab",
|
||||
"cx-prompt-category-tab",
|
||||
category.id === skinCategories.activeCategoryId && "cx-skills-tab--active",
|
||||
)}
|
||||
key={category.id}
|
||||
onClick={() => {
|
||||
setSelectedThemeId(null);
|
||||
skinCategories.setActiveCategoryId(category.id);
|
||||
}}
|
||||
>
|
||||
{category.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button type="button" className="cx-prompt-category-manage" onClick={() => setCategoryManagerOpen(true)}>
|
||||
<Settings2 size={16} aria-hidden="true" />{copy.manageCategories}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<PageTransition pageKey={`skins-category:${skinCategories.activeCategoryId}`}>
|
||||
{themes.length === 0 ? (
|
||||
<div className="cx-skins-empty">
|
||||
<Palette size={24} strokeWidth={1.7} aria-hidden="true" />
|
||||
<span>{loading ? copy.loading : copy.noThemes}</span>
|
||||
</div>
|
||||
) : visibleThemes.length === 0 ? (
|
||||
<div className="cx-skins-empty cx-skins-empty--category">
|
||||
<Palette size={24} strokeWidth={1.7} aria-hidden="true" />
|
||||
<span>{copy.emptyCategory}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className={cx("cx-skins-grid", `cx-skins-grid--${Math.min(visibleThemes.length, 4)}`)}>
|
||||
{visibleThemes.map((theme) => (
|
||||
<ThemeCard
|
||||
key={theme.id}
|
||||
theme={theme}
|
||||
copy={copy}
|
||||
runtimeActive={Boolean(runtime?.active)}
|
||||
activeThemeId={runtime?.themeId}
|
||||
selected={theme.id === selectedThemeId}
|
||||
onSelect={() => setSelectedThemeId(theme.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</PageTransition>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
import { useCallback, useEffect, useId, useMemo, useReducer, useRef, useState } from "react";
|
||||
import type { KeyboardEvent, ReactNode } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import {
|
||||
Activity, AlertCircle, ArrowDownLeft, ArrowUpRight, BarChart3,
|
||||
CalendarDays, Check, Database, Loader2, MessageSquare, RefreshCw,
|
||||
} from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import type { UsageDay, UsageModel, UsageRange, UsageStatistics } from "../usageTypes";
|
||||
import { initialUsageLoadState, sameUsageQuery, usageLoadReducer } from "../usageStatisticsState";
|
||||
import "../styles/usage-statistics.css";
|
||||
|
||||
type Language = "zh" | "en";
|
||||
|
||||
export type UsageStatisticsPageProps = {
|
||||
lang: Language;
|
||||
configDir: string;
|
||||
active?: boolean;
|
||||
};
|
||||
|
||||
export function formatUsageTokens(value: number): string {
|
||||
if (!Number.isFinite(value) || value <= 0) return "0";
|
||||
const units: [number, string][] = [[1_000_000_000, "B"], [1_000_000, "M"], [1_000, "K"]];
|
||||
for (const [divisor, unit] of units) {
|
||||
if (value >= divisor) {
|
||||
const scaled = value / divisor;
|
||||
return `${Number(scaled.toFixed(scaled < 10 ? 2 : 1))}${unit}`;
|
||||
}
|
||||
}
|
||||
return Math.round(value).toString();
|
||||
}
|
||||
|
||||
const exact = (value: number, lang: Language) => value.toLocaleString(lang === "zh" ? "zh-CN" : "en-US");
|
||||
const ratio = (part: number, total: number) => total > 0 ? Math.min(100, Math.max(0, part / total * 100)) : 0;
|
||||
const percentage = (value: number) => `${Number(value.toFixed(1))}%`;
|
||||
|
||||
function modelLabel(model: string, lang: Language): string {
|
||||
if (model === "unknown") return lang === "zh" ? "未知模型" : "Unknown model";
|
||||
if (model === "multiple") return lang === "zh" ? "多个模型" : "Multiple models";
|
||||
return model;
|
||||
}
|
||||
|
||||
function sessionTitleLabel(title: string, lang: Language): string {
|
||||
return title && title !== "未命名会话" ? title : lang === "zh" ? "未命名会话" : "Untitled session";
|
||||
}
|
||||
|
||||
function dateLabel(date: string, lang: Language, includeYear = false): string {
|
||||
const value = new Date(date.length === 10 ? `${date}T12:00:00` : date);
|
||||
if (Number.isNaN(value.getTime())) return date;
|
||||
return value.toLocaleDateString(lang === "zh" ? "zh-CN" : "en-US", {
|
||||
...(includeYear ? { year: "numeric" as const } : {}), month: "short", day: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
function timestampLabel(date: string, lang: Language): string {
|
||||
const value = new Date(date);
|
||||
return Number.isNaN(value.getTime()) ? date : value.toLocaleString(lang === "zh" ? "zh-CN" : "en-US", {
|
||||
month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hour12: false,
|
||||
});
|
||||
}
|
||||
|
||||
function copyFor(lang: Language) {
|
||||
return lang === "zh" ? {
|
||||
title: "Token 用量统计",
|
||||
description: "查看 Codex 的 Token 使用情况,了解用量趋势与模型分布。",
|
||||
range: "统计范围", ranges: { today: "今天", "7d": "近 7 天", "30d": "近 30 天", all: "全部" },
|
||||
model: "模型", allModels: "全部模型", refresh: "刷新用量", refreshing: "更新中", retry: "重试",
|
||||
total: "总 Token 用量", totalHint: "输入 + 输出", input: "输入 Token", output: "输出 Token",
|
||||
freshInput: "未缓存输入", cache: "缓存输入", cached: "已缓存", cacheRate: "缓存命中率",
|
||||
inputHint: "包含缓存输入", outputHint: "包含推理 Token", reasoning: "推理 Token",
|
||||
sessions: "活跃会话", sessionsHint: "主会话数量,含子代理产生的用量", tokens: "Token",
|
||||
trend: "用量趋势", trendHint: "按日查看输入、缓存和输出的构成", peak: "最高用量日",
|
||||
legendLabel: "Token 构成", chartLabel: "每日 Token 用量;左右方向键切换日期",
|
||||
breakdown: "每日明细", date: "日期", totalColumn: "合计", modelTitle: "模型分布",
|
||||
modelHint: "按 Token 用量排序", modelCount: "个模型", modelFilter: "筛选此模型",
|
||||
recent: "最近会话", recentHint: "展示最近 10 个有用量的主会话;子代理用量合并到所属主会话,总量包含全部符合条件的记录。",
|
||||
session: "会话", lastActive: "最近活动", noData: "这里将记录你的 Codex 用量",
|
||||
noDataDescription: "当前范围内还没有可统计的会话记录。使用 Codex 后点击刷新,或试试更大的日期范围。",
|
||||
showAll: "查看全部时间", loading: "正在整理本地会话用量…",
|
||||
error: "暂时无法读取用量", stale: "刷新失败,下面保留上次读取的结果。",
|
||||
updatingResults: "正在更新用量,保留当前结果。", showingResults: "当前显示",
|
||||
loadingResults: "正在加载", previousResults: "下方暂时显示", failedResults: "未能加载",
|
||||
partial: "部分记录未计入", partialHint: "部分本地记录不完整,当前结果仅包含可读取的用量。",
|
||||
source: "本地会话记录", updated: "更新于", notes: "缓存输入已包含在输入 Token 中,推理 Token 已包含在输出 Token 中。历史记录缺失的用量无法补算。",
|
||||
scanned: "已检查", files: "份记录", filtered: "当前模型", activity: "使用概览",
|
||||
noModels: "暂无模型用量", zeroDay: "当天无用量",
|
||||
} : {
|
||||
title: "Token usage statistics",
|
||||
description: "Explore your Codex usage, daily activity, and model distribution.",
|
||||
range: "Date range", ranges: { today: "Today", "7d": "7 days", "30d": "30 days", all: "All time" },
|
||||
model: "Model", allModels: "All models", refresh: "Refresh usage", refreshing: "Updating", retry: "Retry",
|
||||
total: "Total token usage", totalHint: "Input + output", input: "Input tokens", output: "Output tokens",
|
||||
freshInput: "Uncached input", cache: "Cached input", cached: "cached", cacheRate: "Cache hit rate",
|
||||
inputHint: "Includes cached input", outputHint: "Includes reasoning tokens", reasoning: "Reasoning tokens",
|
||||
sessions: "Active sessions", sessionsHint: "Main conversations, including subagent usage", tokens: "tokens",
|
||||
trend: "Usage over time", trendHint: "Daily input, cache, and output breakdown", peak: "Peak day",
|
||||
legendLabel: "Token breakdown", chartLabel: "Daily token usage; use left and right arrows to explore dates",
|
||||
breakdown: "Daily details", date: "Date", totalColumn: "Total", modelTitle: "Model distribution",
|
||||
modelHint: "Ranked by token usage", modelCount: "models", modelFilter: "Filter by this model",
|
||||
recent: "Recent sessions", recentHint: "Showing the 10 most recent main conversations. Subagent usage is included in its parent conversation; totals include all matching records.",
|
||||
session: "Session", lastActive: "Last active", noData: "Your Codex usage will appear here",
|
||||
noDataDescription: "No usage records in this range yet. Refresh after using Codex, or try a broader date range.",
|
||||
showAll: "View all time", loading: "Reading local session usage…",
|
||||
error: "Unable to load usage", stale: "Refresh failed. The last available results are shown below.",
|
||||
updatingResults: "Updating usage. Current results stay visible.", showingResults: "Showing",
|
||||
loadingResults: "Loading", previousResults: "Results below", failedResults: "Unable to load",
|
||||
partial: "Some records were excluded", partialHint: "Some local records are incomplete. These results include readable usage only.",
|
||||
source: "Local session records", updated: "Updated", notes: "Cached input is included in input tokens; reasoning is included in output tokens. Usage cannot be recovered from missing historical records.",
|
||||
scanned: "Checked", files: "records", filtered: "Selected model", activity: "Usage overview",
|
||||
noModels: "No model usage yet", zeroDay: "No usage this day",
|
||||
};
|
||||
}
|
||||
|
||||
type UsageCopy = ReturnType<typeof copyFor>;
|
||||
|
||||
function TokenValue({ value, lang, className = "" }: { value: number; lang: Language; className?: string }) {
|
||||
return <span className={`cx-usage-number ${className}`} title={exact(value, lang)} aria-label={exact(value, lang)}>{formatUsageTokens(value)}</span>;
|
||||
}
|
||||
|
||||
function Metric({ icon: Icon, label, children, hint, accent }: {
|
||||
icon: LucideIcon; label: string; children: ReactNode; hint: ReactNode; accent: string;
|
||||
}) {
|
||||
return <article className={`cx-usage-metric cx-usage-metric--${accent}`}>
|
||||
<div className="cx-usage-metric-label"><Icon size={15} aria-hidden="true" /><span>{label}</span></div>
|
||||
<div className="cx-usage-metric-value">{children}</div>
|
||||
<p>{hint}</p>
|
||||
</article>;
|
||||
}
|
||||
|
||||
function Legend({ copy }: { copy: UsageCopy }) {
|
||||
return <div className="cx-usage-legend" aria-label={copy.legendLabel}>
|
||||
<span><i className="cx-usage-swatch--input" />{copy.freshInput}</span>
|
||||
<span><i className="cx-usage-swatch--cache" />{copy.cache}</span>
|
||||
<span><i className="cx-usage-swatch--output" />{copy.output}</span>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function UsageTrend({ days, lang, copy }: { days: UsageDay[]; lang: Language; copy: UsageCopy }) {
|
||||
const chartId = useId();
|
||||
const [selectedDate, setSelectedDate] = useState<string | null>(null);
|
||||
const barsRef = useRef<(SVGGElement | null)[]>([]);
|
||||
const peakIndex = days.reduce((winner, day, index) => day.totalTokens > (days[winner]?.totalTokens ?? -1) ? index : winner, 0);
|
||||
const selectedIndex = Math.max(0, selectedDate ? days.findIndex((day) => day.date === selectedDate) : peakIndex);
|
||||
const selected = days[selectedIndex];
|
||||
const chartWidth = Math.max(600, days.length * 13 + 56);
|
||||
const chartHeight = 218;
|
||||
const baseline = 181;
|
||||
const top = 15;
|
||||
const left = 48;
|
||||
const drawableWidth = chartWidth - left - 12;
|
||||
const max = Math.max(...days.map((day) => day.inputTokens + day.outputTokens), 1);
|
||||
const ceiling = max <= 5 ? 5 : Math.ceil(max / 10 ** Math.floor(Math.log10(max))) * 10 ** Math.floor(Math.log10(max));
|
||||
const step = drawableWidth / Math.max(days.length, 1);
|
||||
const width = Math.min(38, step * 0.62);
|
||||
const spansYears = days[0]?.date.slice(0, 4) !== days[days.length - 1]?.date.slice(0, 4);
|
||||
const scale = (value: number) => value / ceiling * (baseline - top);
|
||||
const keyDown = (event: KeyboardEvent<SVGGElement>, index: number) => {
|
||||
let next = index;
|
||||
if (event.key === "Enter" || event.key === " ") next = index;
|
||||
else if (event.key === "ArrowLeft") next = Math.max(0, index - 1);
|
||||
else if (event.key === "ArrowRight") next = Math.min(days.length - 1, index + 1);
|
||||
else if (event.key === "Home") next = 0;
|
||||
else if (event.key === "End") next = days.length - 1;
|
||||
else return;
|
||||
event.preventDefault();
|
||||
setSelectedDate(days[next].date);
|
||||
barsRef.current[next]?.focus();
|
||||
};
|
||||
|
||||
return <section className="cx-usage-card cx-usage-trend">
|
||||
<div className="cx-usage-card-heading"><div><h3>{copy.trend}</h3><p>{copy.trendHint}</p></div><BarChart3 size={18} aria-hidden="true" /></div>
|
||||
<Legend copy={copy} />
|
||||
{selected && <div className="cx-usage-day-summary" id={`${chartId}-selection`} aria-live="polite" aria-atomic="true">
|
||||
<div><strong>{dateLabel(selected.date, lang, true)}</strong>{selectedIndex === peakIndex && selected.totalTokens > 0 && <span>{copy.peak}</span>}</div>
|
||||
<div><TokenValue value={selected.totalTokens} lang={lang} /><small>{copy.tokens}</small></div>
|
||||
<p>{copy.input} {exact(selected.inputTokens, lang)} · {copy.cache} {exact(selected.cachedInputTokens, lang)} · {copy.output} {exact(selected.outputTokens, lang)}</p>
|
||||
</div>}
|
||||
<div className="cx-usage-chart-scroll">
|
||||
<svg className="cx-usage-chart" viewBox={`0 0 ${chartWidth} ${chartHeight}`} style={{ minWidth: Math.max(440, days.length * 11 + 56) }} role="group" aria-label={copy.chartLabel}>
|
||||
{[0, 0.5, 1].map((fraction) => <g key={fraction} aria-hidden="true">
|
||||
<line x1={left} x2={chartWidth - 12} y1={baseline - scale(ceiling * fraction)} y2={baseline - scale(ceiling * fraction)} className="cx-usage-gridline" />
|
||||
<text x={left - 9} y={baseline - scale(ceiling * fraction) + 3} textAnchor="end" className="cx-usage-axis">{formatUsageTokens(ceiling * fraction)}</text>
|
||||
</g>)}
|
||||
{days.map((day, index) => {
|
||||
const cached = Math.min(day.cachedInputTokens, day.inputTokens);
|
||||
const fresh = Math.max(0, day.inputTokens - cached);
|
||||
const inputHeight = scale(fresh);
|
||||
const cachedHeight = scale(cached);
|
||||
const outputHeight = scale(day.outputTokens);
|
||||
const x = left + step * index + (step - width) / 2;
|
||||
const showLabel = days.length <= 8 || index === days.length - 1 || index % Math.ceil(days.length / 7) === 0;
|
||||
const label = `${dateLabel(day.date, lang, true)}: ${copy.input} ${exact(day.inputTokens, lang)}, ${copy.cache} ${exact(cached, lang)}, ${copy.output} ${exact(day.outputTokens, lang)}, ${copy.totalColumn} ${exact(day.totalTokens, lang)}`;
|
||||
return <g key={day.date} ref={(element) => { barsRef.current[index] = element; }} className={`cx-usage-day${index === selectedIndex ? " cx-usage-day--selected" : ""}`} role="button" tabIndex={index === selectedIndex ? 0 : -1} aria-label={label} aria-pressed={index === selectedIndex} onMouseEnter={() => setSelectedDate(day.date)} onFocus={() => setSelectedDate(day.date)} onClick={() => setSelectedDate(day.date)} onKeyDown={(event) => keyDown(event, index)}>
|
||||
<title>{label}</title>
|
||||
<rect x={left + step * index} y={top - 4} width={step} height={baseline - top + 10} rx={4} className="cx-usage-chart-hit" />
|
||||
<rect x={x} y={baseline - inputHeight} width={width} height={inputHeight} className="cx-usage-swatch--input" />
|
||||
<rect x={x} y={baseline - inputHeight - cachedHeight} width={width} height={cachedHeight} className="cx-usage-swatch--cache" />
|
||||
<rect x={x} y={baseline - inputHeight - cachedHeight - outputHeight} width={width} height={outputHeight} className="cx-usage-swatch--output" />
|
||||
{day.totalTokens === 0 && <rect x={x} y={baseline - 2} width={width} height={2} rx={1} className="cx-usage-zero-bar" />}
|
||||
{showLabel && <text x={x + width / 2} y={baseline + 22} textAnchor="middle" className="cx-usage-axis" aria-hidden="true">{dateLabel(day.date, lang, spansYears)}</text>}
|
||||
</g>;
|
||||
})}
|
||||
</svg>
|
||||
</div>
|
||||
<details className="cx-usage-daily-details"><summary>{copy.breakdown}<span>{days.length}</span></summary>
|
||||
<div className="cx-usage-table-scroll"><table className="cx-usage-table"><caption className="cx-usage-sr-only">{copy.breakdown}</caption><thead><tr><th>{copy.date}</th><th>{copy.input}</th><th>{copy.cache}</th><th>{copy.output}</th><th>{copy.totalColumn}</th></tr></thead><tbody>
|
||||
{days.map((day) => <tr key={day.date}><th scope="row">{dateLabel(day.date, lang, true)}</th><td>{exact(day.inputTokens, lang)}</td><td>{exact(day.cachedInputTokens, lang)}</td><td>{exact(day.outputTokens, lang)}</td><td>{exact(day.totalTokens, lang)}</td></tr>)}
|
||||
</tbody></table></div>
|
||||
</details>
|
||||
</section>;
|
||||
}
|
||||
|
||||
function ModelDistribution({ models, lang, copy, onModel }: { models: UsageModel[]; lang: Language; copy: UsageCopy; onModel: (model: string) => void }) {
|
||||
const rows = [...models].sort((a, b) => b.totalTokens - a.totalTokens);
|
||||
const total = rows.reduce((sum, model) => sum + model.totalTokens, 0);
|
||||
const circumference = 2 * Math.PI * 58;
|
||||
let cumulative = 0;
|
||||
return <section className="cx-usage-card cx-usage-models">
|
||||
<div className="cx-usage-card-heading"><div><h3>{copy.modelTitle}</h3><p>{copy.modelHint}</p></div></div>
|
||||
<div className="cx-usage-donut-wrap"><svg className="cx-usage-donut" viewBox="0 0 160 160" role="img" aria-label={`${copy.modelTitle}: ${rows.map((row) => `${modelLabel(row.model, lang)} ${percentage(ratio(row.totalTokens, total))}`).join(", ")}`}>
|
||||
<circle cx="80" cy="80" r="58" fill="none" className="cx-usage-donut-track" strokeWidth="15" />
|
||||
{rows.map((row, index) => {
|
||||
const fraction = total > 0 ? row.totalTokens / total : 0;
|
||||
const offset = cumulative;
|
||||
cumulative += fraction;
|
||||
return <circle key={row.model} cx="80" cy="80" r="58" fill="none" strokeWidth="15" strokeDasharray={`${Math.max(0, fraction * circumference - (rows.length > 1 ? 3 : 0))} ${circumference}`} strokeDashoffset={-offset * circumference} transform="rotate(-90 80 80)" className={`cx-usage-model-color-${index % 5}`}><title>{modelLabel(row.model, lang)}: {exact(row.totalTokens, lang)} {copy.tokens}</title></circle>;
|
||||
})}
|
||||
</svg><div className="cx-usage-donut-label"><strong>{rows.length}</strong><span>{copy.modelCount}</span></div></div>
|
||||
<div className="cx-usage-model-list">
|
||||
{rows.length === 0 && <p className="cx-usage-muted">{copy.noModels}</p>}
|
||||
{rows.map((row, index) => <button key={row.model} type="button" className="cx-usage-model-row" onClick={() => onModel(row.model)} title={`${copy.modelFilter}: ${modelLabel(row.model, lang)}`}>
|
||||
<span className={`cx-usage-model-dot cx-usage-model-color-${index % 5}`} aria-hidden="true" />
|
||||
<span className="cx-usage-model-name">{modelLabel(row.model, lang)}</span>
|
||||
<span className="cx-usage-model-amount"><TokenValue value={row.totalTokens} lang={lang} /><small>{percentage(ratio(row.totalTokens, total))}</small></span>
|
||||
</button>)}
|
||||
</div>
|
||||
</section>;
|
||||
}
|
||||
|
||||
export function UsageStatisticsPage({ lang, configDir, active = true }: UsageStatisticsPageProps) {
|
||||
const copy = copyFor(lang);
|
||||
const [range, setRange] = useState<UsageRange>("7d");
|
||||
const [model, setModel] = useState("");
|
||||
const [loadState, dispatch] = useReducer(usageLoadReducer, initialUsageLoadState);
|
||||
const requestRef = useRef(0);
|
||||
const previousDir = useRef(configDir);
|
||||
const query = useMemo(() => ({ configDir, range, model }), [configDir, range, model]);
|
||||
const record = loadState.record?.query.configDir === configDir ? loadState.record : null;
|
||||
const data = record?.data ?? null;
|
||||
const currentRequest = sameUsageQuery(loadState.query, query);
|
||||
const busy = active && (!currentRequest || loadState.busy);
|
||||
const error = currentRequest ? loadState.error : "";
|
||||
const displayingPrevious = record !== null && !sameUsageQuery(record.query, query);
|
||||
const queryLabel = (value: typeof query) => `${copy.ranges[value.range]} · ${value.model ? modelLabel(value.model, lang) : copy.allModels}`;
|
||||
const statusText = displayingPrevious
|
||||
? `${error ? copy.failedResults : copy.loadingResults} ${queryLabel(query)} · ${copy.previousResults} ${queryLabel(record.query)}`
|
||||
: busy ? copy.updatingResults : `${copy.showingResults} ${queryLabel(query)}`;
|
||||
const load = useCallback(async (forceRefresh = false) => {
|
||||
if (!active) return;
|
||||
const request = ++requestRef.current;
|
||||
dispatch({ type: "start", requestId: request, query });
|
||||
try {
|
||||
const next = await invoke<UsageStatistics>("get_usage_statistics", { configDir, range, model: model || null, forceRefresh });
|
||||
dispatch({ type: "success", requestId: request, data: next });
|
||||
} catch (cause) {
|
||||
dispatch({ type: "failure", requestId: request, error: cause instanceof Error ? cause.message : String(cause) });
|
||||
}
|
||||
}, [active, configDir, range, model, query]);
|
||||
useEffect(() => {
|
||||
if (previousDir.current !== configDir) {
|
||||
previousDir.current = configDir;
|
||||
if (model) {
|
||||
setModel("");
|
||||
return;
|
||||
}
|
||||
}
|
||||
void load();
|
||||
return () => { dispatch({ type: "cancel", requestId: ++requestRef.current }); };
|
||||
}, [configDir, model, load]);
|
||||
const modelOptions = useMemo(() => {
|
||||
const available = record?.data.availableModels ?? [];
|
||||
return Array.from(new Set([...available, ...(model ? [model] : [])])).sort();
|
||||
}, [record, configDir, model]);
|
||||
const partial = data && (data.coverage.skippedFiles > 0 || data.coverage.truncated || data.coverage.warnings.length > 0);
|
||||
const hasUsage = data && data.totals.totalTokens > 0;
|
||||
const cacheRate = data ? ratio(data.totals.cachedInputTokens, data.totals.inputTokens) : 0;
|
||||
|
||||
return <div className="cx-usage" aria-busy={busy}>
|
||||
<header className="cx-usage-heading"><div><h3>{copy.title}</h3><p>{copy.description}</p></div><span className="cx-usage-local-badge"><Database size={12} aria-hidden="true" />{copy.source}</span></header>
|
||||
<div className="cx-usage-toolbar">
|
||||
<div className="cx-usage-range" role="group" aria-label={copy.range}>
|
||||
{(Object.keys(copy.ranges) as UsageRange[]).map((value) => <button key={value} type="button" aria-pressed={range === value} className={range === value ? "cx-usage-range-active" : ""} onClick={() => setRange(value)}>{copy.ranges[value]}</button>)}
|
||||
</div>
|
||||
<div className="cx-usage-toolbar-actions"><label className="cx-usage-model-select"><span className="cx-usage-sr-only">{copy.model}</span><select value={model} onChange={(event) => setModel(event.currentTarget.value)} aria-label={copy.model}><option value="">{copy.allModels}</option>{modelOptions.map((value) => <option key={value} value={value}>{modelLabel(value, lang)}</option>)}</select></label>
|
||||
<button type="button" className="cx-page-button cx-page-button--secondary cx-usage-refresh" disabled={busy} onClick={() => void load(true)}><RefreshCw size={14} className={busy ? "cx-page-spin" : ""} aria-hidden="true" />{busy ? copy.refreshing : copy.refresh}</button>
|
||||
</div>
|
||||
<div className="cx-usage-query-status" role="status" aria-live="polite" aria-atomic="true">
|
||||
{busy && data && <Loader2 size={12} className="cx-page-spin" aria-hidden="true" />}
|
||||
<span>{data ? statusText : "\u00a0"}</span>
|
||||
</div>
|
||||
</div>
|
||||
{error && <div className="cx-usage-notice cx-usage-notice--error" role="alert"><AlertCircle size={18} aria-hidden="true" /><div><strong>{copy.error}</strong>{data && <p>{copy.stale}</p>}<p>{error}</p></div><button type="button" onClick={() => void load(true)} disabled={busy}>{copy.retry}</button></div>}
|
||||
{!data && !error && <div className="cx-usage-loading" role="status"><Loader2 size={27} className="cx-page-spin" aria-hidden="true" /><p>{copy.loading}</p><div className="cx-usage-loading-cards" aria-hidden="true"><span /><span /><span /></div></div>}
|
||||
{partial && <div className="cx-usage-notice cx-usage-notice--warning" role="status"><AlertCircle size={18} aria-hidden="true" /><div><strong>{copy.partial}</strong><p>{copy.partialHint}</p>{data.coverage.warnings.length > 0 && <details><summary>{lang === "zh" ? "查看详情" : "View details"}</summary><ul>{data.coverage.warnings.map((warning, index) => <li key={`${index}-${warning}`}>{warning}</li>)}</ul></details>}</div></div>}
|
||||
{data && !hasUsage && <section className="cx-usage-empty"><div className="cx-usage-empty-visual" aria-hidden="true"><span /><span /><span /><span /><BarChart3 size={23} /></div><h3>{copy.noData}</h3><p>{copy.noDataDescription}</p>{(range !== "all" || model) && <button type="button" className="cx-page-button cx-page-button--secondary" onClick={() => { setRange("all"); setModel(""); }}>{copy.showAll}</button>}</section>}
|
||||
{data && hasUsage && <>
|
||||
<section className="cx-usage-summary" aria-label={copy.activity}>
|
||||
<article className="cx-usage-total">
|
||||
<div className="cx-usage-total-heading"><span><Activity size={16} aria-hidden="true" />{copy.total}</span><span className="cx-usage-period-label">{copy.ranges[record?.query.range ?? range]}</span></div>
|
||||
<div className="cx-usage-total-value"><TokenValue value={data.totals.totalTokens} lang={lang} /><span>{copy.tokens}</span></div>
|
||||
<div className="cx-usage-total-caption"><span>{exact(data.totals.totalTokens, lang)} {copy.tokens}</span><span>{copy.totalHint}</span></div>
|
||||
<div className="cx-usage-composition" aria-hidden="true"><span className="cx-usage-swatch--input" style={{ width: `${ratio(data.totals.inputTokens - data.totals.cachedInputTokens, data.totals.totalTokens)}%` }} /><span className="cx-usage-swatch--cache" style={{ width: `${ratio(data.totals.cachedInputTokens, data.totals.totalTokens)}%` }} /><span className="cx-usage-swatch--output" style={{ width: `${ratio(data.totals.outputTokens, data.totals.totalTokens)}%` }} /></div>
|
||||
<div className="cx-usage-total-footer"><CalendarDays size={12} aria-hidden="true" /><span>{data.rangeStart ? `${dateLabel(data.rangeStart, lang, true)} – ${dateLabel(data.rangeEnd, lang, true)}` : copy.ranges.all}</span></div>
|
||||
</article>
|
||||
<div className="cx-usage-metrics">
|
||||
<Metric icon={ArrowDownLeft} label={copy.input} hint={copy.inputHint} accent="input"><TokenValue value={data.totals.inputTokens} lang={lang} /></Metric>
|
||||
<Metric icon={ArrowUpRight} label={copy.output} hint={<>{copy.reasoning} <TokenValue value={data.totals.reasoningTokens} lang={lang} /></>} accent="output"><TokenValue value={data.totals.outputTokens} lang={lang} /></Metric>
|
||||
<Metric icon={Database} label={copy.cacheRate} hint={<><TokenValue value={data.totals.cachedInputTokens} lang={lang} /> {copy.cached}</>} accent="cache"><span className="cx-usage-number">{percentage(cacheRate)}</span></Metric>
|
||||
<Metric icon={MessageSquare} label={copy.sessions} hint={copy.sessionsHint} accent="sessions"><TokenValue value={data.totals.sessionCount} lang={lang} /></Metric>
|
||||
</div>
|
||||
</section>
|
||||
<div className="cx-usage-charts"><UsageTrend days={data.days} lang={lang} copy={copy} /><ModelDistribution models={data.models} lang={lang} copy={copy} onModel={setModel} /></div>
|
||||
<section className="cx-usage-card cx-usage-sessions"><div className="cx-usage-card-heading"><div><h3>{copy.recent}<span className="cx-usage-count">{data.sessions.length}</span></h3><p>{copy.recentHint}</p></div><MessageSquare size={18} aria-hidden="true" /></div>
|
||||
<div className="cx-usage-table-scroll"><table className="cx-usage-table"><caption className="cx-usage-sr-only">{copy.recent}</caption><thead><tr><th>{copy.session}</th><th>{copy.model}</th><th>{copy.input}</th><th>{copy.output}</th><th>{copy.totalColumn}</th><th>{copy.lastActive}</th></tr></thead><tbody>{data.sessions.map((session) => <tr key={`${session.id}-${session.model}`}><th scope="row"><span className="cx-usage-session-name" title={sessionTitleLabel(session.title, lang)}>{sessionTitleLabel(session.title, lang)}</span></th><td><span className="cx-usage-session-model" title={modelLabel(session.model, lang)}>{modelLabel(session.model, lang)}</span></td><td><TokenValue value={session.inputTokens} lang={lang} /></td><td><TokenValue value={session.outputTokens} lang={lang} /></td><td className="cx-usage-cell-total"><TokenValue value={session.totalTokens} lang={lang} /></td><td className="cx-usage-cell-date">{timestampLabel(session.lastActiveAt, lang)}</td></tr>)}</tbody></table></div>
|
||||
</section>
|
||||
</>}
|
||||
{data && <footer className="cx-usage-footer"><div><Check size={12} aria-hidden="true" /><span>{copy.updated} {timestampLabel(data.refreshedAt, lang)} · {data.timezone} · {copy.scanned} {exact(data.coverage.scannedFiles, lang)} {copy.files}</span></div><p>{copy.notes}</p></footer>}
|
||||
</div>;
|
||||
}
|
||||
@@ -1,14 +1,21 @@
|
||||
import { createContext, useContext, useId, useRef, useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import {
|
||||
CheckCircle2,
|
||||
BarChart3,
|
||||
Download,
|
||||
ExternalLink,
|
||||
Globe2,
|
||||
Loader2,
|
||||
Power,
|
||||
RefreshCw,
|
||||
Sparkles,
|
||||
SlidersHorizontal,
|
||||
} from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { PageTransition } from "../components/PageTransition";
|
||||
import { Button, ModalShell } from "../components/ui";
|
||||
import { UsageStatisticsPage } from "./UsageStatisticsPage";
|
||||
import "../styles/utility-pages.css";
|
||||
|
||||
export type UtilityLanguage = "zh" | "en";
|
||||
@@ -84,16 +91,39 @@ export type SettingsCopy = {
|
||||
recheckTitle: ReactNode;
|
||||
recheckDescription: ReactNode;
|
||||
recheckLabel: ReactNode;
|
||||
restartTitle: ReactNode;
|
||||
restartDescription: ReactNode;
|
||||
restartLabel: ReactNode;
|
||||
restartTargetLabel: ReactNode;
|
||||
restartConfirmTitle: ReactNode;
|
||||
restartConfirmDescription: ReactNode;
|
||||
restartCancelLabel: string;
|
||||
restartConfirmLabel: ReactNode;
|
||||
restartingLabel: ReactNode;
|
||||
};
|
||||
|
||||
export type SettingsPageProps = {
|
||||
lang: UtilityLanguage;
|
||||
configDir: string;
|
||||
active?: boolean;
|
||||
copy: SettingsCopy;
|
||||
onLanguageChange: (lang: UtilityLanguage) => void;
|
||||
onRecheck: () => void;
|
||||
onRestartCodex: () => Promise<boolean>;
|
||||
recheckBusy?: boolean;
|
||||
restartBusy?: boolean;
|
||||
};
|
||||
|
||||
// PageTransition keeps the previous content during its exit animation. Context
|
||||
// still propagates the current activity state so requests stop immediately,
|
||||
// while the retained usage component keeps its filters between tabs.
|
||||
const SettingsUsageActiveContext = createContext(false);
|
||||
|
||||
function SettingsUsagePanel({ lang, configDir }: Pick<SettingsPageProps, "lang" | "configDir">) {
|
||||
const active = useContext(SettingsUsageActiveContext);
|
||||
return <UsageStatisticsPage lang={lang} configDir={configDir} active={active} />;
|
||||
}
|
||||
|
||||
type SettingRowProps = {
|
||||
icon: LucideIcon;
|
||||
title: ReactNode;
|
||||
@@ -118,65 +148,152 @@ function SettingRow({ icon: Icon, title, description, action }: SettingRowProps)
|
||||
|
||||
export function SettingsPage({
|
||||
lang,
|
||||
configDir,
|
||||
active = true,
|
||||
copy,
|
||||
onLanguageChange,
|
||||
onRecheck,
|
||||
onRestartCodex,
|
||||
recheckBusy = false,
|
||||
restartBusy = false,
|
||||
}: SettingsPageProps) {
|
||||
const [tab, setTab] = useState<"general" | "usage">("general");
|
||||
const [usageOpened, setUsageOpened] = useState(false);
|
||||
const tabId = useId();
|
||||
const tabRefs = useRef<(HTMLButtonElement | null)[]>([]);
|
||||
const [restartConfirmOpen, setRestartConfirmOpen] = useState(false);
|
||||
const closeRestartConfirm = () => {
|
||||
if (!restartBusy) setRestartConfirmOpen(false);
|
||||
};
|
||||
const confirmRestart = async () => {
|
||||
if (restartBusy) return;
|
||||
if (await onRestartCodex()) setRestartConfirmOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="cx-utility cx-page cx-page--settings">
|
||||
<PageHeader eyebrow={copy.eyebrow} title={copy.title} />
|
||||
<div className="cx-page-settings-list">
|
||||
<SettingRow
|
||||
icon={Globe2}
|
||||
title={copy.languageTitle}
|
||||
description={copy.languageDescription}
|
||||
action={(
|
||||
<div className="cx-page-segmented" role="group" aria-label={String(copy.languageTitle)}>
|
||||
<button
|
||||
type="button"
|
||||
className={lang === "zh" ? "cx-page-segmented-button cx-page-segmented-button--active" : "cx-page-segmented-button"}
|
||||
onClick={() => onLanguageChange("zh")}
|
||||
aria-pressed={lang === "zh"}
|
||||
>
|
||||
{copy.chineseLabel}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={lang === "en" ? "cx-page-segmented-button cx-page-segmented-button--active" : "cx-page-segmented-button"}
|
||||
onClick={() => onLanguageChange("en")}
|
||||
aria-pressed={lang === "en"}
|
||||
>
|
||||
{copy.englishLabel}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
|
||||
<SettingRow
|
||||
icon={Sparkles}
|
||||
title={copy.productTitle}
|
||||
description={copy.productDescription}
|
||||
action={<span className="cx-page-value-pill">{copy.productValue}</span>}
|
||||
/>
|
||||
|
||||
<SettingRow
|
||||
icon={CheckCircle2}
|
||||
title={copy.recheckTitle}
|
||||
description={copy.recheckDescription}
|
||||
action={(
|
||||
<button
|
||||
type="button"
|
||||
className="cx-page-button cx-page-button--secondary"
|
||||
onClick={onRecheck}
|
||||
disabled={recheckBusy}
|
||||
>
|
||||
{recheckBusy && <Loader2 size={15} className="cx-page-spin" aria-hidden="true" />}
|
||||
{copy.recheckLabel}
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
<div className="cx-settings-tabs" role="tablist" aria-label={lang === "zh" ? "设置页面" : "Settings pages"}>
|
||||
{(["general", "usage"] as const).map((value, index) => {
|
||||
const Icon = value === "general" ? SlidersHorizontal : BarChart3;
|
||||
const select = () => { setTab(value); if (value === "usage") setUsageOpened(true); };
|
||||
return <button key={value} ref={(element) => { tabRefs.current[index] = element; }} type="button" role="tab" id={`${tabId}-${value}-tab`} aria-controls={`${tabId}-${value}-panel`} aria-selected={tab === value} tabIndex={tab === value ? 0 : -1} className="cx-settings-tab" onClick={select} onKeyDown={(event) => {
|
||||
if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) return;
|
||||
event.preventDefault();
|
||||
const next = event.key === "Home" ? 0 : event.key === "End" ? 1 : 1 - index;
|
||||
tabRefs.current[next]?.click();
|
||||
tabRefs.current[next]?.focus();
|
||||
}}><Icon size={14} aria-hidden="true" />{value === "general" ? (lang === "zh" ? "通用设置" : "General") : (lang === "zh" ? "用量统计" : "Usage statistics")}</button>;
|
||||
})}
|
||||
</div>
|
||||
<SettingsUsageActiveContext.Provider value={active && tab === "usage"}>
|
||||
<PageTransition pageKey={`settings:${tab}`}>
|
||||
<div className="cx-settings-panel cx-page-settings-list" role="tabpanel" id={`${tabId}-general-panel`} aria-labelledby={`${tabId}-general-tab`} hidden={tab !== "general"}>
|
||||
<SettingRow
|
||||
icon={Globe2}
|
||||
title={copy.languageTitle}
|
||||
description={copy.languageDescription}
|
||||
action={(
|
||||
<div className="cx-page-segmented" role="group" aria-label={String(copy.languageTitle)}>
|
||||
<button
|
||||
type="button"
|
||||
className={lang === "zh" ? "cx-page-segmented-button cx-page-segmented-button--active" : "cx-page-segmented-button"}
|
||||
onClick={() => onLanguageChange("zh")}
|
||||
aria-pressed={lang === "zh"}
|
||||
>
|
||||
{copy.chineseLabel}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={lang === "en" ? "cx-page-segmented-button cx-page-segmented-button--active" : "cx-page-segmented-button"}
|
||||
onClick={() => onLanguageChange("en")}
|
||||
aria-pressed={lang === "en"}
|
||||
>
|
||||
{copy.englishLabel}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
|
||||
<SettingRow
|
||||
icon={Sparkles}
|
||||
title={copy.productTitle}
|
||||
description={copy.productDescription}
|
||||
action={<span className="cx-page-value-pill">{copy.productValue}</span>}
|
||||
/>
|
||||
|
||||
<SettingRow
|
||||
icon={CheckCircle2}
|
||||
title={copy.recheckTitle}
|
||||
description={copy.recheckDescription}
|
||||
action={(
|
||||
<button
|
||||
type="button"
|
||||
className="cx-page-button cx-page-button--secondary"
|
||||
onClick={onRecheck}
|
||||
disabled={recheckBusy}
|
||||
>
|
||||
{recheckBusy && <Loader2 size={15} className="cx-page-spin" aria-hidden="true" />}
|
||||
{copy.recheckLabel}
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
|
||||
<SettingRow
|
||||
icon={Power}
|
||||
title={copy.restartTitle}
|
||||
description={copy.restartDescription}
|
||||
action={(
|
||||
<button
|
||||
type="button"
|
||||
className="cx-page-button cx-page-button--secondary"
|
||||
onClick={() => setRestartConfirmOpen(true)}
|
||||
disabled={restartBusy}
|
||||
>
|
||||
{restartBusy ? <Loader2 size={15} className="cx-page-spin" aria-hidden="true" /> : <Power size={15} aria-hidden="true" />}
|
||||
{restartBusy ? copy.restartingLabel : copy.restartLabel}
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="cx-settings-panel" role="tabpanel" id={`${tabId}-usage-panel`} aria-labelledby={`${tabId}-usage-tab`} hidden={tab !== "usage"}>
|
||||
{usageOpened && <SettingsUsagePanel lang={lang} configDir={configDir} />}
|
||||
</div>
|
||||
</PageTransition>
|
||||
</SettingsUsageActiveContext.Provider>
|
||||
|
||||
<ModalShell
|
||||
open={restartConfirmOpen}
|
||||
onClose={closeRestartConfirm}
|
||||
title={copy.restartConfirmTitle}
|
||||
description={copy.restartConfirmDescription}
|
||||
size="sm"
|
||||
closeLabel={copy.restartCancelLabel}
|
||||
closeOnBackdrop={!restartBusy}
|
||||
closeOnEscape={!restartBusy}
|
||||
showCloseButton={!restartBusy}
|
||||
footer={(
|
||||
<>
|
||||
<Button variant="secondary" onClick={closeRestartConfirm} disabled={restartBusy} data-initial-focus>
|
||||
{copy.restartCancelLabel}
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
icon={restartBusy ? <Loader2 size={16} className="cx-page-spin" /> : <Power size={16} />}
|
||||
onClick={() => void confirmRestart()}
|
||||
disabled={restartBusy}
|
||||
>
|
||||
{restartBusy ? copy.restartingLabel : copy.restartConfirmLabel}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<div className="cx-page-restart-target">
|
||||
<Power size={18} aria-hidden="true" />
|
||||
<strong>{copy.restartTargetLabel}</strong>
|
||||
</div>
|
||||
</ModalShell>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import type { ProviderPresetChoice } from "./components/ProviderPresetPicker";
|
||||
import type { ProviderModelMapping, SavedProvider } from "./types";
|
||||
|
||||
export type ProviderPresetVariant = {
|
||||
id: string;
|
||||
label: string;
|
||||
labelEn: string;
|
||||
providerName: string;
|
||||
baseUrl: string;
|
||||
models: readonly ProviderModelMapping[];
|
||||
note: string;
|
||||
noteEn: string;
|
||||
};
|
||||
|
||||
export type ProviderPreset = ProviderPresetChoice & {
|
||||
variants: readonly ProviderPresetVariant[];
|
||||
sources?: readonly string[];
|
||||
};
|
||||
|
||||
const model = (id: string, displayName: string, contextWindow: number | null): ProviderModelMapping => ({ model: id, displayName, contextWindow });
|
||||
const deepseekModels = [model("deepseek-v4-flash", "DeepSeek V4 Flash", 1048576), model("deepseek-v4-pro", "DeepSeek V4 Pro", 1048576)];
|
||||
const minimaxModels = [model("MiniMax-M3", "MiniMax M3", 1000000), model("MiniMax-M2.7", "MiniMax M2.7", 204800)];
|
||||
const mimoModels = [model("mimo-v2.5-pro", "MiMo V2.5 Pro", 1048576), model("mimo-v2.5", "MiMo V2.5", 1048576)];
|
||||
|
||||
// Reviewed against vendor documentation on 2026-09-09. Only native Responses
|
||||
// endpoints belong here; Chat/Anthropic-only plans need a separate adapter.
|
||||
export const PROVIDER_PRESETS: readonly ProviderPreset[] = [
|
||||
{ id: "custom", name: "自定义配置", nameEn: "Custom", brand: "custom", subtitle: "自行填写 API 配置", subtitleEn: "Your own API settings", variants: [] },
|
||||
{ id: "official", name: "OpenAI Official", brand: "openai", subtitle: "ChatGPT 账号登录", subtitleEn: "Sign in with ChatGPT", variants: [] },
|
||||
{
|
||||
id: "deepseek", name: "DeepSeek", brand: "deepseek", subtitle: "V4 Flash / Pro",
|
||||
variants: [{ id: "default", label: "开放平台", labelEn: "API platform", providerName: "DeepSeek", baseUrl: "https://api.deepseek.com", models: deepseekModels,
|
||||
note: "使用 DeepSeek 开放平台的 API Key。", noteEn: "Use an API key from the DeepSeek platform." }],
|
||||
sources: ["https://api-docs.deepseek.com/quick_start/agent_integrations/codex"],
|
||||
},
|
||||
{
|
||||
id: "minimax", name: "MiniMax", brand: "minimax", subtitle: "M3 / M2.7 · 国内与国际", subtitleEn: "M3 / M2.7 · China & global",
|
||||
variants: [
|
||||
{ id: "cn", label: "国内", labelEn: "China", providerName: "MiniMax 国内", baseUrl: "https://api.minimax.cn/v1", models: minimaxModels,
|
||||
note: "使用 MiniMax 国内平台的 API Key;请确认账号已开通所选模型。", noteEn: "Use a MiniMax China API key with access to the selected model." },
|
||||
{ id: "global", label: "国际", labelEn: "Global", providerName: "MiniMax 国际", baseUrl: "https://api.minimax.io/v1", models: minimaxModels,
|
||||
note: "使用 MiniMax 国际平台的 API Key;请确认账号已开通所选模型。", noteEn: "Use a MiniMax global API key with access to the selected model." },
|
||||
],
|
||||
sources: ["https://platform.minimaxi.com/docs/token-plan/codex", "https://platform.minimax.io/docs/token-plan/codex", "https://platform.minimax.io/docs/api-reference/responses-create"],
|
||||
},
|
||||
{
|
||||
id: "mimo", name: "小米 MiMo", nameEn: "Xiaomi MiMo", brand: "mimo", subtitle: "V2.5 Pro / V2.5",
|
||||
variants: [
|
||||
{ id: "api", label: "按量付费", labelEn: "Pay as you go", providerName: "小米 MiMo", baseUrl: "https://api.xiaomimimo.com/v1", models: mimoModels,
|
||||
note: "使用按量付费的 sk- 开头 API Key。", noteEn: "Use a pay-as-you-go API key beginning with sk-." },
|
||||
{ id: "token-plan", label: "Token Plan 套餐", labelEn: "Token Plan", providerName: "小米 MiMo Token Plan", baseUrl: "https://token-plan-cn.xiaomimimo.com/v1", models: mimoModels,
|
||||
note: "使用 Token Plan 套餐的 tp- 开头 API Key,套餐与按量付费地址不同。", noteEn: "Use a Token Plan key beginning with tp-. This plan has its own API endpoint." },
|
||||
],
|
||||
sources: ["https://mimo.mi.com/docs/zh-CN/tokenplan/integration/codex-configuration"],
|
||||
},
|
||||
{
|
||||
id: "kimi", name: "Kimi", brand: "kimi", subtitle: "K3 · 开放平台", subtitleEn: "K3 · API platform",
|
||||
variants: [{ id: "default", label: "开放平台", labelEn: "API platform", providerName: "Kimi", baseUrl: "https://api.moonshot.cn/v1", models: [model("kimi-k3", "Kimi K3", 1048576)],
|
||||
note: "使用 Kimi 开放平台的 API Key,不适用于 Kimi Code 订阅密钥。", noteEn: "Use a Kimi API platform key. Kimi Code subscription keys use a different service." }],
|
||||
sources: ["https://platform.kimi.com/docs/guide/codex-kimi", "https://platform.kimi.com/docs/api/responses"],
|
||||
},
|
||||
{
|
||||
id: "glm", name: "智谱 GLM", nameEn: "Zhipu GLM", brand: "glm", subtitle: "GLM Coding Plan",
|
||||
variants: [{ id: "cn", label: "Coding Plan", labelEn: "Coding Plan", providerName: "智谱 GLM", baseUrl: "https://open.bigmodel.cn/api/v1", models: [model("glm-5.3", "GLM-5.3", 1048576), model("glm-5-turbo", "GLM-5 Turbo", 204800)],
|
||||
note: "使用对应 GLM Coding Plan 套餐的 API Key;团队套餐请使用团队密钥。", noteEn: "Use the API key for your GLM Coding Plan. Team plans require their team key." }],
|
||||
sources: ["https://docs.bigmodel.cn/cn/coding-plan/tool/codex"],
|
||||
},
|
||||
{
|
||||
id: "qwen", name: "阿里千问", nameEn: "Alibaba Qwen", brand: "qwen", subtitle: "Qwen3.8 Max / Flash",
|
||||
variants: [{ id: "beijing", label: "北京地域", labelEn: "Beijing region", providerName: "阿里千问", baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1", models: [model("qwen3.8-max", "Qwen3.8 Max", null), model("qwen3.8-flash", "Qwen3.8 Flash", null)],
|
||||
note: "使用阿里云百炼北京地域的普通 API Key,不适用于 Coding Plan 专属密钥。", noteEn: "Use a regular Model Studio API key for Beijing. Coding Plan keys use a different endpoint." }],
|
||||
sources: ["https://help.aliyun.com/zh/model-studio/compatibility-with-openai-responses-api"],
|
||||
},
|
||||
];
|
||||
|
||||
export function getProviderPreset(id: string): ProviderPreset | undefined {
|
||||
return PROVIDER_PRESETS.find((preset) => preset.id === id);
|
||||
}
|
||||
|
||||
export function getProviderPresetVariant(presetId: string, variantId: string): ProviderPresetVariant | undefined {
|
||||
const preset = getProviderPreset(presetId);
|
||||
return preset?.variants.find((variant) => variant.id === variantId) ?? preset?.variants[0];
|
||||
}
|
||||
|
||||
export function createPresetProvider(presetId: string, variantId: string, inheritedToml = ""): SavedProvider | null {
|
||||
const variant = getProviderPresetVariant(presetId, variantId);
|
||||
if (!variant) return null;
|
||||
return {
|
||||
id: `${presetId}-${variant.id}`,
|
||||
providerName: variant.providerName,
|
||||
baseUrl: variant.baseUrl,
|
||||
model: variant.models[0].model,
|
||||
apiKey: "",
|
||||
tomlConfig: inheritedToml,
|
||||
wireApi: "responses",
|
||||
requiresOpenaiAuth: false,
|
||||
modelMappings: variant.models.map((entry) => ({ ...entry })),
|
||||
};
|
||||
}
|
||||