mirror of
https://github.com/yynxxxxx/Codex-X.git
synced 2026-10-04 04:18:30 +08:00
Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d1b40285dd | ||
|
|
3ac28c3193 | ||
|
|
39dc8fc535 | ||
|
|
762ff36131 | ||
|
|
5894dceb98 | ||
|
|
92c371e5e0 | ||
|
|
936f2f49e4 | ||
|
|
9687a4b11c | ||
|
|
32bab7731e | ||
|
|
f64848ca9c | ||
|
|
94ab211431 | ||
|
|
e8b0e5b73c | ||
|
|
5b6655754d | ||
|
|
1fd5b1e608 | ||
|
|
7d0e0064d5 | ||
|
|
d38548a1f4 | ||
|
|
f25e43d281 | ||
|
|
d404905812 | ||
|
|
c200e6b86e |
@@ -131,9 +131,14 @@ jobs:
|
||||
--draft
|
||||
fi
|
||||
|
||||
release_id=$(gh api "/repos/${GITHUB_REPOSITORY}/releases?per_page=100" \
|
||||
--jq ".[] | select(.tag_name == \"${RELEASE_TAG}\") | .id" \
|
||||
| head -n 1)
|
||||
release_id=""
|
||||
for attempt in {1..10}; do
|
||||
release_id=$(gh release view "$RELEASE_TAG" \
|
||||
--json databaseId \
|
||||
--jq '.databaseId' 2>/dev/null || true)
|
||||
[ -z "$release_id" ] || break
|
||||
sleep 2
|
||||
done
|
||||
if [ -z "$release_id" ]; then
|
||||
echo "Unable to locate draft release for ${RELEASE_TAG}" >&2
|
||||
exit 1
|
||||
@@ -158,12 +163,12 @@ jobs:
|
||||
platform: macos-latest
|
||||
macos: true
|
||||
args: --bundles app,dmg
|
||||
asset_pattern: Codex-X-[version]-macos-arm64[ext]
|
||||
asset_pattern: Codex-X-[version]-macos-apple-silicon[ext]
|
||||
- name: macOS Intel
|
||||
platform: macos-15-intel
|
||||
macos: true
|
||||
args: --bundles app,dmg
|
||||
asset_pattern: Codex-X-[version]-macos-x64[ext]
|
||||
asset_pattern: Codex-X-[version]-macos-intel[ext]
|
||||
- name: Linux x64
|
||||
platform: ubuntu-22.04
|
||||
macos: false
|
||||
@@ -382,6 +387,28 @@ jobs:
|
||||
with:
|
||||
ref: ${{ needs.prepare.outputs.tag }}
|
||||
|
||||
- name: Match release asset labels to filenames
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
RELEASE_ID: ${{ needs.prepare.outputs.release_id }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
assets_endpoint="/repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}/assets?per_page=100"
|
||||
gh api "$assets_endpoint" \
|
||||
--jq '.[] | select(.label != .name) | [.id, .name] | @tsv' \
|
||||
| while IFS=$'\t' read -r asset_id asset_name; do
|
||||
gh api --method PATCH \
|
||||
"/repos/${GITHUB_REPOSITORY}/releases/assets/${asset_id}" \
|
||||
-f "label=${asset_name}" >/dev/null
|
||||
done
|
||||
|
||||
mismatched_labels=$(gh api "$assets_endpoint" \
|
||||
--jq '[.[] | select(.label != .name)] | length')
|
||||
if [ "$mismatched_labels" -ne 0 ]; then
|
||||
echo "Release asset labels do not match their filenames" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Download and validate updater manifest
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
@@ -404,9 +431,24 @@ jobs:
|
||||
--manifest latest.json \
|
||||
--assets release-assets.json \
|
||||
--version "$VERSION" \
|
||||
--rewrite-download-urls
|
||||
--repository "$GITHUB_REPOSITORY" \
|
||||
--release-tag "$RELEASE_TAG" \
|
||||
--rewrite-download-urls \
|
||||
--require-signature-assets
|
||||
gh release upload "$RELEASE_TAG" latest.json --clobber
|
||||
|
||||
- name: Remove public signature assets
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
RELEASE_ID: ${{ needs.prepare.outputs.release_id }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
gh api "/repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}/assets?per_page=100" \
|
||||
--jq '.[] | select(.name | endswith(".sig")) | .id' \
|
||||
| while read -r asset_id; do
|
||||
[ -z "$asset_id" ] || gh api --method DELETE "/repos/${GITHUB_REPOSITORY}/releases/assets/${asset_id}"
|
||||
done
|
||||
|
||||
- name: Publish the verified release
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
@@ -429,7 +471,9 @@ jobs:
|
||||
python3 scripts/validate_updater_release.py \
|
||||
--manifest published-latest.json \
|
||||
--assets release-assets.json \
|
||||
--version "$VERSION"
|
||||
--version "$VERSION" \
|
||||
--repository "$GITHUB_REPOSITORY" \
|
||||
--release-tag "$RELEASE_TAG"
|
||||
|
||||
python3 - <<'PY' > updater-urls.txt
|
||||
import json
|
||||
@@ -438,9 +482,11 @@ jobs:
|
||||
print(entry["url"])
|
||||
PY
|
||||
sort -u updater-urls.txt | while read -r url; do
|
||||
curl -fsSLI \
|
||||
curl -fsSL \
|
||||
--range 0-0 \
|
||||
--retry 5 \
|
||||
--retry-delay 3 \
|
||||
--retry-all-errors \
|
||||
"$url" >/dev/null
|
||||
"$url" \
|
||||
-o /dev/null
|
||||
done
|
||||
|
||||
+124
@@ -2,6 +2,130 @@
|
||||
|
||||
All notable changes to Codex-X will be documented here.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [v0.3.11] - 2026-08-11
|
||||
|
||||
### 更新
|
||||
|
||||
- 优化了软件的流畅度和部分 bug。
|
||||
|
||||
## [v0.3.10] - 2026-08-11
|
||||
|
||||
### 更新
|
||||
|
||||
- 优化了软件的流畅度和部分 bug。
|
||||
|
||||
## [v0.3.9] - 2026-08-11
|
||||
|
||||
### 更新
|
||||
|
||||
- 优化了软件的流畅度和部分 bug。
|
||||
|
||||
## [v0.3.8] - 2026-08-03
|
||||
|
||||
### 修复 Bug
|
||||
|
||||
- 修复了某些问题。
|
||||
|
||||
## [v0.3.7] - 2026-08-02
|
||||
|
||||
### 修复 Bug
|
||||
|
||||
- 修复了一些 bug。
|
||||
|
||||
## [v0.3.6] - 2026-07-31
|
||||
|
||||
### 更新
|
||||
|
||||
- 供应商与 OpenAI Official 切换改为热更新;切换后新建会话即可生效,不再要求重启 Codex 客户端。
|
||||
- macOS 关闭主窗口后进入顶部栏驻留模式,Windows 隐藏任务栏窗口;托盘菜单仍可恢复窗口或明确退出应用。
|
||||
- 新增独立的项目维护日志,记录配置写入、供应商切换、会话同步和桌面生命周期的长期约束与验证依据。
|
||||
|
||||
### 修复 Bug
|
||||
|
||||
- 修复官方认证被第三方供应商覆盖或清除的问题;第三方密钥仅写入当前 provider,官方 ChatGPT 登录与官方 API Key 均使用独立快照保存并可往返恢复。
|
||||
- 修复编辑活动供应商会新建副本、重复导入 cc-switch 供应商产生多条相同记录,以及旧版 cc-switch 数据库缺少 `category` 字段时无法读取官方认证的问题。
|
||||
- 修复供应商、提示词及其他配置操作并发写入时可能覆盖或破坏 `config.toml` 的问题;写入现在使用跨进程锁、原始快照、条件原子替换和失败回滚。
|
||||
- 修复会话同步显示成功但 Codex 未实际使用同步结果的问题;只处理活动 SQLite,严格校验 SQLite 指定的 rollout 路径、文件身份和线程 ID,并兼容 `codex-dev.db` 与显式 `sqlite_home` 下的自定义数据库名。
|
||||
- 修复会话列表将旧数据库或孤立 JSONL 计入活动会话、把 JSONL 记录行数误当会话数,以及同步过程中修改非活动数据库或工作目录字段的问题。
|
||||
|
||||
## [v0.3.5] - 2026-07-28
|
||||
|
||||
### 更新
|
||||
|
||||
- GitHub 指令提示词现在可以直接查看和编辑本地内容;模板名称和文件名继续跟随在线目录,Markdown 内容由用户在现有提示词编辑页中维护。
|
||||
- 模板内容实际修改后会标记为“本地已修改”,后续同步永久跳过该模板的远端内容,同时继续发现和下载 GitHub 新增模板;启用时优先使用用户的本地版本。
|
||||
- 仅查看或保存未变化的内容不会退出 GitHub 同步;本地修改在下次启用该模板时生效。
|
||||
|
||||
### 修复 Bug
|
||||
|
||||
- 修复 jsDelivr `@main` 目录缓存旧版本时,“同步 GitHub 模板”只能看到 5 个模板、无法发现仓库中 11 个模板的问题;目录成员现在始终由 GitHub 源站确认,源站不可用时仍保留 CDN 和本地缓存回退。
|
||||
- 修复当前使用中转供应商且 live `auth.json` 不存在时,概览页错误显示“认证文件未找到”的问题;已保存的官方认证快照现在会显示为“官方认证已保存”。
|
||||
|
||||
## [v0.3.4] - 2026-07-28
|
||||
|
||||
### 更新
|
||||
|
||||
- 在线提示词库新增 3 套软件开发模板和 3 套写作辅助模板,并按照模板文件名前缀自动归入对应分类;用户手动分类仍然优先。
|
||||
- 软件开发分类新增“长期维护工程师”“系统化调试与根因修复”“严格代码审查”,其中“长期维护工程师”来自项目当前实际使用的维护规范。
|
||||
- 写作辅助分类新增“清晰表达与润色”“技术文档写作”“结构化长文起草”。6 套新增模板通过 GitHub 在线同步,不增加安装包体积。
|
||||
|
||||
### 修复 Bug
|
||||
|
||||
- 彻底隔离 OpenAI Official 与中转供应商认证:切换到中转前保存可信的官方配置快照,切回官方时恢复该快照,避免 `auth.json` 被中转或 cc-switch 覆盖后导致官方登录失效。
|
||||
- “还原官方配置”现在只恢复独立快照,不再自动切换当前供应商;新增“新建官方配置”,可清除受污染的 live `auth.json` 并引导用户重新完成官方登录。
|
||||
- 优化官方 `auth.json` 编辑器的高度、换行和长 Token 选取体验,避免内容看起来被截断或只能依赖底部横向滚动条。
|
||||
|
||||
## [v0.3.3] - 2026-07-26
|
||||
|
||||
### 调整
|
||||
|
||||
- 暂时下线皮肤中心并隐藏侧边栏入口,皮肤页面、运行时实现和用户本地主题文件继续保留,后续可以通过统一功能开关重新启用。
|
||||
- 升级后会自动暂停 Codex-X 此前启用的皮肤并恢复官方显示;若自动停用失败,应用会显示明确错误,不会让用户在入口隐藏后失去恢复提示。
|
||||
- 内置“椎名真白·樱花画室”不再注册为随应用编译的主题,其约 1.7 MB 壁纸不再进入正式安装包;主题源文件仍保留在仓库中。
|
||||
|
||||
## [v0.3.2] - 2026-07-25
|
||||
|
||||
### 修复 Bug
|
||||
|
||||
- 修复 Windows Codex 冷启动或进入新版首页、外观设置页时,皮肤运行时因只识别旧版主壳层和侧栏而错误提示“未找到经过验证的 Codex 渲染器”的问题;首次连接现在会等待页面渐进加载,并兼容渲染文档切换。
|
||||
- “关闭皮肤”保持为随时可用的异常恢复入口,不再因运行状态刷新延迟、皮肤部分失效或其他操作卡住而无法点击;恢复期间仍会阻止重复提交和其他皮肤写操作。
|
||||
|
||||
### 安全
|
||||
|
||||
- 新版 Windows 页面检测继续要求官方进程、回环 CDP、`app://` 协议以及 Codex 壳层、首页或外观设置页的明确锚点,不会向普通网页或无标记辅助页面注入皮肤。
|
||||
|
||||
## [v0.3.1] - 2026-07-24
|
||||
|
||||
### 更新
|
||||
|
||||
- 皮肤中心重新设计主题画廊:直接展示真实壁纸和侧边栏、会话区、输入框、按钮等整套界面效果,单个主题不再拉伸占满整行,并移除端点、目录、`theme.json` 路径和主题包结构等开发者信息。
|
||||
- 皮肤中心新增可管理的分类筛选并进一步压缩主题卡片密度;导出主题时改用系统“另存为”窗口选择目录和文件名。
|
||||
- 支持直接从图片创建自适应主题,并可编辑主题名称、简介和界面透明度;内置主题与图片主题共用相同的编辑与预览规格。
|
||||
- 主题画廊根据当前分类数量自适应 1 至 4 列,少量主题保持紧凑,多个主题充分利用可用宽度。
|
||||
- 实机换肤扩展到 Windows:动态校验官方 `OpenAI.Codex` Store 包,安全启动仅绑定回环地址的 CDP 会话,并在 Windows 发布包中附带固定校验的 Node.js 22 运行时;不修改 `WindowsApps`、`app.asar` 或权限。
|
||||
- 皮肤中心只保留“关闭皮肤”入口,移除与其重复的“恢复官方外观”按钮;底层完整恢复能力继续保留用于异常恢复。
|
||||
|
||||
### 修复 Bug
|
||||
|
||||
- 修复 macOS 皮肤已经注入后 Codex-X 仍长期显示“应用中”、重开后无法识别当前主题的问题;单次注入现在会明确退出,外部命令带有硬超时,并可从经过验证的 Codex 主进程恢复遗失的本机调试端口。
|
||||
- 修复皮肤尚未运行时错误显示“重新应用”、刷新缺少明确反馈,以及提示词或皮肤卡片过多时页面无法继续向下滚动的问题。
|
||||
- 优化普通横图在新任务首页超宽横幅中的自动焦点,避免人物头部被居中 `cover` 裁掉;主题包仍可通过 `art.focusY` 显式覆盖自动位置。
|
||||
- 修复切换到中转供应商时覆盖官方 `auth.json` 的问题,官方登录凭据与供应商凭据现在分别保存和恢复。
|
||||
- 改进 Codex Desktop 检测,兼容更多安装路径、包标识和版本输出格式,减少部分用户错误显示“未检测到 Codex”的情况。
|
||||
- 修复启用皮肤后新会话输入框消失、输入框出现会话内容穿透,以及图片主题预览错误使用默认背景的问题。
|
||||
- 修复浅色主题的固定白色遮罩洗淡壁纸颜色的问题;壁纸保持原图色彩,卡片和项目选择区域跟随透明度,同时为输入框保留可读性下限。
|
||||
|
||||
### 安全
|
||||
|
||||
- 换肤运行时只连接经过签名、进程归属和页面标记验证的官方 Codex,并将 CDP 限制在 `127.0.0.1`;停止注入器前会核对 PID、启动时间、Node 路径、脚本路径和端口。
|
||||
- 主题导入增加 ZIP 越界、符号链接、重复文件、文件数量、图片大小和 Manifest 字段校验;运行时使用稳定快照,避免主题切换时配置与图片不一致。
|
||||
|
||||
### 开发
|
||||
|
||||
- 固定复用 Codex Dream Skin Studio `5fd8af532efbaa87d2d0092297fd2d45cd56574e` 的 MIT 注入实现,并加入 Rust 与 Node 专项回归测试;未包含上游声明为非 MIT 授权的真人主题素材。
|
||||
|
||||
## [v0.3.0] - 2026-07-16
|
||||
|
||||
### 更新
|
||||
|
||||
+33
-3
@@ -143,7 +143,7 @@ You can use it to:
|
||||
### 1. Prompt Template Center
|
||||
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/Template_library-5_total-2563eb?style=flat-square" alt="5 templates in the current library" />
|
||||
<img src="https://img.shields.io/badge/Template_library-11_total-2563eb?style=flat-square" alt="11 templates in the current library" />
|
||||
<img src="https://img.shields.io/badge/Bundled_offline-5_templates-16a34a?style=flat-square" alt="5 templates bundled for offline use" />
|
||||
<img src="https://img.shields.io/badge/GitHub_sync-Automatic_updates-f59e0b?style=flat-square" alt="Automatic GitHub template sync" />
|
||||
</p>
|
||||
@@ -151,7 +151,7 @@ You can use it to:
|
||||
> [!TIP]
|
||||
> **Ready after installation, automatically expanded when online.**
|
||||
>
|
||||
> The app bundle includes all 5 current templates for offline use. After launch, it synchronizes updates and newly added templates from GitHub `examples/` in the background, without waiting for the **Prompt** page to open. Successfully synchronized online versions are cached locally and remain available during temporary network outages.
|
||||
> The app bundle includes 5 templates for offline use. After launch, it synchronizes 6 additional software-development and writing templates, plus later updates, from GitHub `examples/` in the background without waiting for the **Prompt** page to open. Successfully synchronized online versions are cached locally and remain available during temporary network outages.
|
||||
|
||||
<div align="center">
|
||||
<table>
|
||||
@@ -185,6 +185,36 @@ You can use it to:
|
||||
<td align="left">A Chinese technical-operator persona with routing for coding, CTF, reverse engineering, memory, and protocol work</td>
|
||||
<td align="center">Bundled offline<br />GitHub updates</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="examples/software-development-maintainer.md"><code>software-development-maintainer.md</code></a></td>
|
||||
<td align="left">Long-term maintenance with reuse, minimal changes, safety, testing, and maintainability</td>
|
||||
<td align="center">GitHub sync</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="examples/software-development-debugging.md"><code>software-development-debugging.md</code></a></td>
|
||||
<td align="left">Evidence-driven debugging from reliable reproduction to root-cause repair and regression checks</td>
|
||||
<td align="center">GitHub sync</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="examples/software-development-code-review.md"><code>software-development-code-review.md</code></a></td>
|
||||
<td align="left">Severity-ranked review of defects, regressions, security risks, and test gaps</td>
|
||||
<td align="center">GitHub sync</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="examples/writing-clarity-editor.md"><code>writing-clarity-editor.md</code></a></td>
|
||||
<td align="left">Chinese and English editing that preserves the author's meaning and facts</td>
|
||||
<td align="center">GitHub sync</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="examples/writing-technical-docs.md"><code>writing-technical-docs.md</code></a></td>
|
||||
<td align="left">Source-grounded README, guide, API, architecture, and release documentation</td>
|
||||
<td align="center">GitHub sync</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="examples/writing-structured-draft.md"><code>writing-structured-draft.md</code></a></td>
|
||||
<td align="left">Turns scattered material into a structured report, proposal, retrospective, or article draft</td>
|
||||
<td align="center">GitHub sync</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@@ -369,7 +399,7 @@ Build desktop bundles:
|
||||
pnpm --dir apps/desktop tauri build
|
||||
```
|
||||
|
||||
## macOS Installation Note
|
||||
## Desktop Installation Notes
|
||||
|
||||
If you see “app is damaged” when opening an unsigned / unnotarized DMG, this is normal macOS Gatekeeper behavior.
|
||||
|
||||
|
||||
@@ -123,7 +123,7 @@
|
||||
### 1. 可视化提示词注入中心
|
||||
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/当前模板库-5_套-2563eb?style=flat-square" alt="当前模板库 5 套" />
|
||||
<img src="https://img.shields.io/badge/当前模板库-11_套-2563eb?style=flat-square" alt="当前模板库 11 套" />
|
||||
<img src="https://img.shields.io/badge/离线内置-5_套-16a34a?style=flat-square" alt="离线内置 5 套" />
|
||||
<img src="https://img.shields.io/badge/GitHub_同步-自动更新-f59e0b?style=flat-square" alt="GitHub 自动同步" />
|
||||
<img src="https://img.shields.io/badge/自定义提示词-支持导入_编辑-7c3aed?style=flat-square" alt="支持自定义提示词" />
|
||||
@@ -132,7 +132,7 @@
|
||||
> [!TIP]
|
||||
> **安装后就能用,联网后自动补齐,也能维护自己的提示词库。**
|
||||
>
|
||||
> 安装包离线自带当前全部 5 套模板;软件启动后可同步 GitHub `examples/` 的更新和新增模板。同步成功的在线版本会缓存到本地,临时离线仍可继续使用。你也可以导入自己的 `.md`、新增分类、编辑说明,并像切换插件一样启用或禁用任意提示词。
|
||||
> 安装包离线自带 5 套模板;软件启动后会从 GitHub `examples/` 同步另外 6 套软件开发与写作辅助模板,以及后续更新。同步成功的在线版本会缓存到本地,临时离线仍可继续使用。你也可以导入自己的 `.md`、新增分类、编辑说明,并像切换插件一样启用或禁用任意提示词。
|
||||
|
||||
Codex-X 现在不只是“几套内置 Prompt”的启动器,而是一个可视化提示词注入与管理工具:
|
||||
|
||||
@@ -174,6 +174,36 @@ Codex-X 现在不只是“几套内置 Prompt”的启动器,而是一个可
|
||||
<td align="left">中文技术操作员人格,覆盖 coding、CTF、逆向、内存与协议任务路由</td>
|
||||
<td align="center">离线内置<br />GitHub 更新</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="examples/software-development-maintainer.md"><code>software-development-maintainer.md</code></a></td>
|
||||
<td align="left">长期维护正式项目,强调复用、最小改动、安全、测试与可维护性</td>
|
||||
<td align="center">GitHub 在线同步</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="examples/software-development-debugging.md"><code>software-development-debugging.md</code></a></td>
|
||||
<td align="left">从稳定复现和证据采集推进到根因修复与回归验证</td>
|
||||
<td align="center">GitHub 在线同步</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="examples/software-development-code-review.md"><code>software-development-code-review.md</code></a></td>
|
||||
<td align="left">按严重级别审查缺陷、回归、安全风险和测试缺口</td>
|
||||
<td align="center">GitHub 在线同步</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="examples/writing-clarity-editor.md"><code>writing-clarity-editor.md</code></a></td>
|
||||
<td align="left">在保留原意和事实的前提下润色中英文表达</td>
|
||||
<td align="center">GitHub 在线同步</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="examples/writing-technical-docs.md"><code>writing-technical-docs.md</code></a></td>
|
||||
<td align="left">基于代码与事实编写 README、指南、API 和发布文档</td>
|
||||
<td align="center">GitHub 在线同步</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="examples/writing-structured-draft.md"><code>writing-structured-draft.md</code></a></td>
|
||||
<td align="left">将零散材料组织成报告、方案、复盘或文章初稿</td>
|
||||
<td align="center">GitHub 在线同步</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@@ -358,7 +388,7 @@ pnpm dev
|
||||
pnpm --dir apps/desktop tauri build
|
||||
```
|
||||
|
||||
## macOS 安装说明
|
||||
## 桌面端安装说明
|
||||
|
||||
如果你在未签名 / 未公证的 DMG 中看到“软件已损坏”提示,这是 macOS Gatekeeper 的正常行为。
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "codex-x",
|
||||
"version": "0.3.0",
|
||||
"version": "0.3.11",
|
||||
"private": true,
|
||||
"description": "Codex Switch & Instruct desktop manager",
|
||||
"type": "module",
|
||||
|
||||
Generated
+412
-1
@@ -68,6 +68,137 @@ dependencies = [
|
||||
"derive_arbitrary",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-broadcast"
|
||||
version = "0.7.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532"
|
||||
dependencies = [
|
||||
"event-listener",
|
||||
"event-listener-strategy",
|
||||
"futures-core",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-channel"
|
||||
version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2"
|
||||
dependencies = [
|
||||
"concurrent-queue",
|
||||
"event-listener-strategy",
|
||||
"futures-core",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-executor"
|
||||
version = "1.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a"
|
||||
dependencies = [
|
||||
"async-task",
|
||||
"concurrent-queue",
|
||||
"fastrand",
|
||||
"futures-lite",
|
||||
"pin-project-lite",
|
||||
"slab",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-io"
|
||||
version = "2.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
"cfg-if",
|
||||
"concurrent-queue",
|
||||
"futures-io",
|
||||
"futures-lite",
|
||||
"parking",
|
||||
"polling",
|
||||
"rustix",
|
||||
"slab",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-lock"
|
||||
version = "3.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311"
|
||||
dependencies = [
|
||||
"event-listener",
|
||||
"event-listener-strategy",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-process"
|
||||
version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75"
|
||||
dependencies = [
|
||||
"async-channel",
|
||||
"async-io",
|
||||
"async-lock",
|
||||
"async-signal",
|
||||
"async-task",
|
||||
"blocking",
|
||||
"cfg-if",
|
||||
"event-listener",
|
||||
"futures-lite",
|
||||
"rustix",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-recursion"
|
||||
version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.118",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-signal"
|
||||
version = "0.2.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485"
|
||||
dependencies = [
|
||||
"async-io",
|
||||
"async-lock",
|
||||
"atomic-waker",
|
||||
"cfg-if",
|
||||
"futures-core",
|
||||
"futures-io",
|
||||
"rustix",
|
||||
"signal-hook-registry",
|
||||
"slab",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-task"
|
||||
version = "4.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de"
|
||||
|
||||
[[package]]
|
||||
name = "async-trait"
|
||||
version = "0.1.89"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.118",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "atk"
|
||||
version = "0.18.2"
|
||||
@@ -163,6 +294,19 @@ dependencies = [
|
||||
"objc2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "blocking"
|
||||
version = "1.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21"
|
||||
dependencies = [
|
||||
"async-channel",
|
||||
"async-task",
|
||||
"futures-io",
|
||||
"futures-lite",
|
||||
"piper",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "brotli"
|
||||
version = "8.0.4"
|
||||
@@ -346,7 +490,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "codex-x"
|
||||
version = "0.3.0"
|
||||
version = "0.3.11"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dirs 5.0.1",
|
||||
@@ -361,6 +505,7 @@ dependencies = [
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-process",
|
||||
"tauri-plugin-single-instance",
|
||||
"tauri-plugin-updater",
|
||||
"thiserror 2.0.18",
|
||||
"toml_edit 0.22.27",
|
||||
@@ -378,6 +523,15 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "concurrent-queue"
|
||||
version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973"
|
||||
dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cookie"
|
||||
version = "0.18.1"
|
||||
@@ -790,6 +944,33 @@ version = "1.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7"
|
||||
|
||||
[[package]]
|
||||
name = "endi"
|
||||
version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"
|
||||
|
||||
[[package]]
|
||||
name = "enumflags2"
|
||||
version = "0.7.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef"
|
||||
dependencies = [
|
||||
"enumflags2_derive",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "enumflags2_derive"
|
||||
version = "0.7.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.118",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.2"
|
||||
@@ -817,6 +998,26 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "event-listener"
|
||||
version = "5.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2"
|
||||
dependencies = [
|
||||
"parking",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "event-listener-strategy"
|
||||
version = "0.5.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"
|
||||
dependencies = [
|
||||
"event-listener",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fallible-iterator"
|
||||
version = "0.3.0"
|
||||
@@ -961,6 +1162,19 @@ version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
|
||||
|
||||
[[package]]
|
||||
name = "futures-lite"
|
||||
version = "2.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"futures-core",
|
||||
"futures-io",
|
||||
"parking",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-macro"
|
||||
version = "0.3.32"
|
||||
@@ -1333,6 +1547,12 @@ version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "hermit-abi"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
|
||||
|
||||
[[package]]
|
||||
name = "hex"
|
||||
version = "0.4.3"
|
||||
@@ -2245,6 +2465,16 @@ version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
|
||||
|
||||
[[package]]
|
||||
name = "ordered-stream"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "osakit"
|
||||
version = "0.3.1"
|
||||
@@ -2284,6 +2514,12 @@ dependencies = [
|
||||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "parking"
|
||||
version = "2.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
|
||||
|
||||
[[package]]
|
||||
name = "parking_lot"
|
||||
version = "0.12.5"
|
||||
@@ -2372,6 +2608,17 @@ version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
||||
|
||||
[[package]]
|
||||
name = "piper"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1"
|
||||
dependencies = [
|
||||
"atomic-waker",
|
||||
"fastrand",
|
||||
"futures-io",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pkg-config"
|
||||
version = "0.3.33"
|
||||
@@ -2417,6 +2664,20 @@ dependencies = [
|
||||
"miniz_oxide",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "polling"
|
||||
version = "3.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"concurrent-queue",
|
||||
"hermit-abi",
|
||||
"pin-project-lite",
|
||||
"rustix",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "potential_utf"
|
||||
version = "0.1.5"
|
||||
@@ -3093,6 +3354,16 @@ version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
|
||||
|
||||
[[package]]
|
||||
name = "signal-hook-registry"
|
||||
version = "1.4.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
|
||||
dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "simd-adler32"
|
||||
version = "0.3.9"
|
||||
@@ -3526,6 +3797,22 @@ dependencies = [
|
||||
"tauri-plugin",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-single-instance"
|
||||
version = "2.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b3214becf9ef5783c0ae99a3bb25adf5353a7a16ebf53e74b909e29205735c6c"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"windows-sys 0.60.2",
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-updater"
|
||||
version = "2.10.1"
|
||||
@@ -4004,9 +4291,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
|
||||
dependencies = [
|
||||
"pin-project-lite",
|
||||
"tracing-attributes",
|
||||
"tracing-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-attributes"
|
||||
version = "0.1.31"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.118",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-core"
|
||||
version = "0.1.36"
|
||||
@@ -4056,6 +4355,17 @@ version = "1.20.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
||||
|
||||
[[package]]
|
||||
name = "uds_windows"
|
||||
version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
|
||||
dependencies = [
|
||||
"memoffset",
|
||||
"tempfile",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unic-char-property"
|
||||
version = "0.9.0"
|
||||
@@ -5107,6 +5417,67 @@ dependencies = [
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zbus"
|
||||
version = "5.18.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fe18fb60dc696039e738717b76eaea21e7a4489bbb1885020b43c94236d7e98a"
|
||||
dependencies = [
|
||||
"async-broadcast",
|
||||
"async-executor",
|
||||
"async-io",
|
||||
"async-lock",
|
||||
"async-process",
|
||||
"async-recursion",
|
||||
"async-task",
|
||||
"async-trait",
|
||||
"blocking",
|
||||
"enumflags2",
|
||||
"event-listener",
|
||||
"futures-core",
|
||||
"futures-lite",
|
||||
"hex",
|
||||
"libc",
|
||||
"ordered-stream",
|
||||
"rustix",
|
||||
"serde",
|
||||
"serde_repr",
|
||||
"tracing",
|
||||
"uds_windows",
|
||||
"uuid",
|
||||
"windows-sys 0.61.2",
|
||||
"winnow 1.0.3",
|
||||
"zbus_macros",
|
||||
"zbus_names",
|
||||
"zvariant",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zbus_macros"
|
||||
version = "5.18.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fe96480bed92df2b442a1a30df364e12d08eed03aeb061f2b8dc6afb2be91119"
|
||||
dependencies = [
|
||||
"proc-macro-crate 3.5.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.118",
|
||||
"zbus_names",
|
||||
"zvariant",
|
||||
"zvariant_utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zbus_names"
|
||||
version = "4.3.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"winnow 1.0.3",
|
||||
"zvariant",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy"
|
||||
version = "0.8.52"
|
||||
@@ -5233,3 +5604,43 @@ dependencies = [
|
||||
"log",
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zvariant"
|
||||
version = "5.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bee2a0bcd2a907786a456fff45aaaaf54c9ba5f50b71ae9ec1a4edd200c94911"
|
||||
dependencies = [
|
||||
"endi",
|
||||
"enumflags2",
|
||||
"serde",
|
||||
"winnow 1.0.3",
|
||||
"zvariant_derive",
|
||||
"zvariant_utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zvariant_derive"
|
||||
version = "5.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "38a708216a18780796770bfe3f4739c7c83a3e8f789b755534bbbc06e4e23e12"
|
||||
dependencies = [
|
||||
"proc-macro-crate 3.5.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.118",
|
||||
"zvariant_utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zvariant_utils"
|
||||
version = "3.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "90cb9383f9b45290407a1258b202d3f8f01db719eb60b4e4055c6375af4fc7c7"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"serde",
|
||||
"syn 2.0.118",
|
||||
"winnow 1.0.3",
|
||||
]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "codex-x"
|
||||
version = "0.3.0"
|
||||
version = "0.3.11"
|
||||
description = "Codex Switch & Instruct desktop manager"
|
||||
authors = ["yynxxxxx"]
|
||||
license = "MIT"
|
||||
@@ -15,7 +15,7 @@ crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
tauri-build = { version = "2.4.0", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2.8.2", features = [] }
|
||||
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"] }
|
||||
@@ -37,3 +37,4 @@ rustls = { version = "0.23", default-features = false, features = ["ring"] }
|
||||
sha2 = "0.10"
|
||||
percent-encoding = "2.3"
|
||||
zip = { version = "2.2", default-features = false, features = ["deflate"] }
|
||||
tauri-plugin-single-instance = "=2.4.3"
|
||||
|
||||
@@ -3,7 +3,110 @@ use crate::file_io::ensure_directory;
|
||||
use crate::paths::app_home;
|
||||
use crate::sqlite_utils::table_column_set;
|
||||
use rusqlite::Connection;
|
||||
use std::path::PathBuf;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
use std::time::Duration;
|
||||
|
||||
const APP_DB_SCHEMA_VERSION: i64 = 1;
|
||||
|
||||
struct DatabaseInitializer {
|
||||
migration_lock: Mutex<()>,
|
||||
}
|
||||
|
||||
impl DatabaseInitializer {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
migration_lock: Mutex::new(()),
|
||||
}
|
||||
}
|
||||
|
||||
fn open_at(&self, path: &Path) -> Result<Connection> {
|
||||
self.open_at_with(path, initialize_schema)
|
||||
}
|
||||
|
||||
fn open_at_with(
|
||||
&self,
|
||||
path: &Path,
|
||||
initialize: impl FnOnce(&Connection) -> Result<()>,
|
||||
) -> Result<Connection> {
|
||||
if let Some(parent) = path.parent() {
|
||||
ensure_directory(parent)?;
|
||||
}
|
||||
let conn = Connection::open(path).map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
conn.busy_timeout(Duration::from_secs(5))
|
||||
.map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
|
||||
if schema_is_current(&conn)? {
|
||||
return Ok(conn);
|
||||
}
|
||||
|
||||
// Serialize first-time migrations inside this process. The persistent
|
||||
// schema version also prevents repeated migrations across launches and
|
||||
// detects a database replaced at the same path.
|
||||
let _migration_guard = self
|
||||
.migration_lock
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
if !schema_is_current(&conn)? {
|
||||
migrate_schema(&conn, initialize)?;
|
||||
}
|
||||
Ok(conn)
|
||||
}
|
||||
}
|
||||
|
||||
fn schema_version(conn: &Connection) -> Result<i64> {
|
||||
conn.pragma_query_value(None, "user_version", |row| row.get(0))
|
||||
.map_err(|error| CodexxError::Database(error.to_string()))
|
||||
}
|
||||
|
||||
fn schema_is_current(conn: &Connection) -> Result<bool> {
|
||||
Ok(schema_version(conn)? >= APP_DB_SCHEMA_VERSION)
|
||||
}
|
||||
|
||||
fn migrate_schema(
|
||||
conn: &Connection,
|
||||
initialize: impl FnOnce(&Connection) -> Result<()>,
|
||||
) -> Result<()> {
|
||||
conn.execute_batch("BEGIN IMMEDIATE")
|
||||
.map_err(|error| CodexxError::Database(error.to_string()))?;
|
||||
|
||||
let migration = (|| {
|
||||
// Another Codex-X process may have completed the migration while this
|
||||
// connection waited for SQLite's write lock.
|
||||
if !schema_is_current(conn)? {
|
||||
initialize(conn)?;
|
||||
conn.pragma_update(None, "user_version", APP_DB_SCHEMA_VERSION)
|
||||
.map_err(|error| CodexxError::Database(error.to_string()))?;
|
||||
}
|
||||
conn.execute_batch("COMMIT")
|
||||
.map_err(|error| CodexxError::Database(error.to_string()))
|
||||
})();
|
||||
|
||||
match migration {
|
||||
Ok(()) => Ok(()),
|
||||
Err(error) => match conn.execute_batch("ROLLBACK") {
|
||||
Ok(()) => Err(error),
|
||||
Err(rollback_error) => Err(CodexxError::Database(format!(
|
||||
"{error}; app database migration rollback failed: {rollback_error}"
|
||||
))),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn database_initializer() -> &'static DatabaseInitializer {
|
||||
static INITIALIZER: OnceLock<DatabaseInitializer> = OnceLock::new();
|
||||
INITIALIZER.get_or_init(DatabaseInitializer::new)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn test_db_guard() -> std::sync::MutexGuard<'static, ()> {
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
static TEST_DB_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
||||
TEST_DB_LOCK
|
||||
.get_or_init(|| Mutex::new(()))
|
||||
.lock()
|
||||
.expect("test app database lock poisoned")
|
||||
}
|
||||
|
||||
fn db_path() -> Result<PathBuf> {
|
||||
Ok(app_home()?.join("codexx.db"))
|
||||
@@ -34,12 +137,7 @@ fn ensure_sqlite_column(
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn open() -> Result<Connection> {
|
||||
let path = db_path()?;
|
||||
if let Some(parent) = path.parent() {
|
||||
ensure_directory(parent)?;
|
||||
}
|
||||
let conn = Connection::open(&path).map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
fn initialize_schema(conn: &Connection) -> Result<()> {
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE IF NOT EXISTS providers (
|
||||
id TEXT PRIMARY KEY,
|
||||
@@ -50,6 +148,8 @@ pub(crate) fn open() -> Result<Connection> {
|
||||
toml_config TEXT,
|
||||
wire_api TEXT NOT NULL DEFAULT 'responses',
|
||||
requires_openai_auth INTEGER NOT NULL DEFAULT 1,
|
||||
source TEXT NOT NULL DEFAULT 'manual',
|
||||
source_id TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
@@ -71,6 +171,12 @@ pub(crate) fn open() -> Result<Connection> {
|
||||
checked_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS builtin_prompt_overrides (
|
||||
template_id TEXT PRIMARY KEY,
|
||||
content TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS managed_mcp_servers (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
@@ -91,11 +197,29 @@ pub(crate) fn open() -> Result<Connection> {
|
||||
)
|
||||
.map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
ensure_sqlite_column(
|
||||
&conn,
|
||||
conn,
|
||||
"providers",
|
||||
"toml_config",
|
||||
"ALTER TABLE providers ADD COLUMN toml_config TEXT",
|
||||
)?;
|
||||
ensure_sqlite_column(
|
||||
conn,
|
||||
"providers",
|
||||
"source",
|
||||
"ALTER TABLE providers ADD COLUMN source TEXT NOT NULL DEFAULT 'manual'",
|
||||
)?;
|
||||
ensure_sqlite_column(
|
||||
conn,
|
||||
"providers",
|
||||
"source_id",
|
||||
"ALTER TABLE providers ADD COLUMN source_id TEXT",
|
||||
)?;
|
||||
conn.execute_batch(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS idx_providers_source_identity
|
||||
ON providers(source, source_id)
|
||||
WHERE source_id IS NOT NULL;",
|
||||
)
|
||||
.map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
conn.execute(
|
||||
"DELETE FROM prompts
|
||||
WHERE id LIKE 'external-%'
|
||||
@@ -131,5 +255,183 @@ pub(crate) fn open() -> Result<Connection> {
|
||||
[],
|
||||
)
|
||||
.map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
Ok(conn)
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn open() -> Result<Connection> {
|
||||
database_initializer().open_at(&db_path()?)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, Barrier};
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
fn test_db_path(name: &str) -> PathBuf {
|
||||
static COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
let suffix = COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||
std::env::temp_dir()
|
||||
.join(format!(
|
||||
"codex-x-app-db-{name}-{}-{suffix}",
|
||||
std::process::id()
|
||||
))
|
||||
.join("codexx.db")
|
||||
}
|
||||
|
||||
fn remove_test_db(path: &Path) {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::remove_dir_all(parent).expect("remove app database test directory");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_opens_do_not_rerun_legacy_cleanup() {
|
||||
let path = test_db_path("cleanup-once");
|
||||
let initializer = DatabaseInitializer::new();
|
||||
let conn = initializer.open_at(&path).expect("initialize database");
|
||||
conn.execute_batch(
|
||||
"INSERT INTO prompts (id, title, filename, content, created_at, updated_at)
|
||||
VALUES
|
||||
('kept', 'Kept', 'same.md', 'same', '1', '1'),
|
||||
('external-duplicate', 'Duplicate', 'same.md', 'same', '2', '2');",
|
||||
)
|
||||
.expect("seed a post-migration duplicate");
|
||||
drop(conn);
|
||||
|
||||
let reopened_initializer = DatabaseInitializer::new();
|
||||
let conn = reopened_initializer
|
||||
.open_at(&path)
|
||||
.expect("reopen database in a new process lifecycle");
|
||||
let count: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM prompts", [], |row| row.get(0))
|
||||
.expect("count prompts after reopen");
|
||||
assert_eq!(count, 2, "reopen must not rerun migration cleanup");
|
||||
drop(conn);
|
||||
remove_test_db(&path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn database_replaced_at_the_same_path_is_initialized_again() {
|
||||
let path = test_db_path("replace-database");
|
||||
let initializer = DatabaseInitializer::new();
|
||||
let conn = initializer.open_at(&path).expect("initialize database");
|
||||
assert_eq!(schema_version(&conn).expect("read schema version"), 1);
|
||||
drop(conn);
|
||||
|
||||
fs::remove_file(&path).expect("replace initialized database");
|
||||
let conn = initializer
|
||||
.open_at(&path)
|
||||
.expect("initialize replacement database");
|
||||
let provider_count: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM providers", [], |row| row.get(0))
|
||||
.expect("query replacement database schema");
|
||||
assert_eq!(provider_count, 0);
|
||||
assert_eq!(schema_version(&conn).expect("read replacement version"), 1);
|
||||
drop(conn);
|
||||
remove_test_db(&path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn concurrent_first_opens_initialize_once() {
|
||||
let path = test_db_path("concurrent-init");
|
||||
let attempts = Arc::new(AtomicUsize::new(0));
|
||||
let barrier = Arc::new(Barrier::new(3));
|
||||
let mut workers = Vec::new();
|
||||
|
||||
for _ in 0..2 {
|
||||
let path = path.clone();
|
||||
// Separate initializers model independent Codex-X processes; the
|
||||
// SQLite transaction and persistent version still permit one run.
|
||||
let initializer = DatabaseInitializer::new();
|
||||
let attempts = Arc::clone(&attempts);
|
||||
let barrier = Arc::clone(&barrier);
|
||||
workers.push(thread::spawn(move || {
|
||||
barrier.wait();
|
||||
initializer.open_at_with(&path, |conn| {
|
||||
attempts.fetch_add(1, Ordering::SeqCst);
|
||||
thread::sleep(Duration::from_millis(25));
|
||||
conn.execute_batch("CREATE TABLE initialized_once (id INTEGER PRIMARY KEY);")
|
||||
.map_err(|error| CodexxError::Database(error.to_string()))
|
||||
})
|
||||
}));
|
||||
}
|
||||
|
||||
barrier.wait();
|
||||
for worker in workers {
|
||||
drop(
|
||||
worker
|
||||
.join()
|
||||
.expect("join concurrent database opener")
|
||||
.expect("open database concurrently"),
|
||||
);
|
||||
}
|
||||
assert_eq!(attempts.load(Ordering::SeqCst), 1);
|
||||
remove_test_db(&path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_initialization_can_retry() {
|
||||
let path = test_db_path("retry-init");
|
||||
let initializer = DatabaseInitializer::new();
|
||||
let attempts = AtomicUsize::new(0);
|
||||
|
||||
let error = initializer
|
||||
.open_at_with(&path, |_| {
|
||||
attempts.fetch_add(1, Ordering::SeqCst);
|
||||
Err(CodexxError::Database(
|
||||
"injected transient initialization failure".to_string(),
|
||||
))
|
||||
})
|
||||
.expect_err("first initialization must fail");
|
||||
assert!(error
|
||||
.to_string()
|
||||
.contains("transient initialization failure"));
|
||||
|
||||
let conn = initializer
|
||||
.open_at_with(&path, |conn| {
|
||||
attempts.fetch_add(1, Ordering::SeqCst);
|
||||
conn.execute_batch("CREATE TABLE retry_succeeded (id INTEGER PRIMARY KEY);")
|
||||
.map_err(|error| CodexxError::Database(error.to_string()))
|
||||
})
|
||||
.expect("retry initialization");
|
||||
drop(conn);
|
||||
let conn = initializer
|
||||
.open_at_with(&path, |_| {
|
||||
attempts.fetch_add(1, Ordering::SeqCst);
|
||||
Ok(())
|
||||
})
|
||||
.expect("open initialized database");
|
||||
assert_eq!(attempts.load(Ordering::SeqCst), 2);
|
||||
drop(conn);
|
||||
remove_test_db(&path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn initialized_read_connection_does_not_need_a_write_lock() {
|
||||
let path = test_db_path("read-with-writer");
|
||||
let initializer = DatabaseInitializer::new();
|
||||
let writer = initializer.open_at(&path).expect("initialize database");
|
||||
writer
|
||||
.execute_batch("BEGIN IMMEDIATE")
|
||||
.expect("hold database write reservation");
|
||||
|
||||
let reader = initializer
|
||||
.open_at(&path)
|
||||
.expect("open reader while write reservation is held");
|
||||
let count: i64 = reader
|
||||
.query_row("SELECT COUNT(*) FROM providers", [], |row| row.get(0))
|
||||
.expect("read while another connection holds write reservation");
|
||||
assert_eq!(count, 0);
|
||||
|
||||
drop(reader);
|
||||
writer
|
||||
.execute_batch("ROLLBACK")
|
||||
.expect("release write lock");
|
||||
drop(writer);
|
||||
remove_test_db(&path);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use crate::constants::AGENTS_FILENAME;
|
||||
use crate::error::Result;
|
||||
use crate::error::{CodexxError, Result};
|
||||
use crate::file_io::{ensure_directory, io_err, write_json};
|
||||
use crate::paths::app_home;
|
||||
use crate::prompts::agents_path;
|
||||
@@ -7,7 +7,7 @@ use crate::{auth_path, config_path};
|
||||
use chrono::Local;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -40,6 +40,84 @@ pub(crate) struct BackupEntry {
|
||||
had_agents: bool,
|
||||
}
|
||||
|
||||
fn lexical_absolute_path(path: &Path) -> Result<PathBuf> {
|
||||
let absolute = if path.is_absolute() {
|
||||
path.to_path_buf()
|
||||
} else {
|
||||
std::env::current_dir()
|
||||
.map_err(|error| io_err(path, error))?
|
||||
.join(path)
|
||||
};
|
||||
let mut normalized = PathBuf::new();
|
||||
for component in absolute.components() {
|
||||
match component {
|
||||
Component::Prefix(prefix) => normalized.push(prefix.as_os_str()),
|
||||
Component::RootDir => normalized.push(component.as_os_str()),
|
||||
Component::CurDir => {}
|
||||
Component::ParentDir => {
|
||||
normalized.pop();
|
||||
}
|
||||
Component::Normal(part) => normalized.push(part),
|
||||
}
|
||||
}
|
||||
Ok(normalized)
|
||||
}
|
||||
|
||||
/// Resolves every existing ancestor while retaining a normalized suffix. This
|
||||
/// compares missing CODEX_HOME targets without requiring the target to exist.
|
||||
fn normalized_path_identity(path: &Path) -> Result<PathBuf> {
|
||||
let absolute = if path.is_absolute() {
|
||||
path.to_path_buf()
|
||||
} else {
|
||||
std::env::current_dir()
|
||||
.map_err(|error| io_err(path, error))?
|
||||
.join(path)
|
||||
};
|
||||
for ancestor in absolute.ancestors() {
|
||||
match fs::canonicalize(ancestor) {
|
||||
Ok(canonical) => {
|
||||
let suffix = absolute
|
||||
.strip_prefix(ancestor)
|
||||
.expect("ancestor must be a path prefix");
|
||||
return lexical_absolute_path(&canonical.join(suffix));
|
||||
}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(error) => return Err(io_err(ancestor, error)),
|
||||
}
|
||||
}
|
||||
lexical_absolute_path(&absolute)
|
||||
}
|
||||
|
||||
fn same_path_identity(left: &Path, right: &Path) -> Result<bool> {
|
||||
let left = normalized_path_identity(left)?;
|
||||
let right = normalized_path_identity(right)?;
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let left = left.to_string_lossy().replace('/', "\\");
|
||||
let right = right.to_string_lossy().replace('/', "\\");
|
||||
Ok(left.eq_ignore_ascii_case(&right))
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
Ok(left == right)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn validate_backup_codex_dir(meta: &BackupMeta, codex_dir: &Path) -> Result<()> {
|
||||
let recorded = meta.codex_dir.trim();
|
||||
if recorded.is_empty() {
|
||||
return Err(CodexxError::Config("备份元数据缺少 CODEX_HOME".to_string()));
|
||||
}
|
||||
if same_path_identity(Path::new(recorded), codex_dir)? {
|
||||
return Ok(());
|
||||
}
|
||||
Err(CodexxError::Config(format!(
|
||||
"备份属于其他 CODEX_HOME,拒绝恢复:备份为 {},当前为 {}",
|
||||
Path::new(recorded).display(),
|
||||
codex_dir.display()
|
||||
)))
|
||||
}
|
||||
|
||||
fn backup_root() -> Result<PathBuf> {
|
||||
Ok(app_home()?.join("backups"))
|
||||
}
|
||||
@@ -138,7 +216,3 @@ pub(crate) fn backups() -> Result<Vec<BackupEntry>> {
|
||||
entries.sort_by(|a, b| b.created_at.cmp(&a.created_at));
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
pub(crate) fn latest_backup() -> Result<Option<BackupEntry>> {
|
||||
Ok(backups()?.into_iter().next())
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
use crate::backups::create_backup;
|
||||
use crate::config_path;
|
||||
use crate::error::Result;
|
||||
use crate::file_io::{parse_toml_document, read_to_string_if_exists, write_text};
|
||||
use crate::file_io::parse_toml_document;
|
||||
#[cfg(test)]
|
||||
use crate::live_config::acquire_live_config_lock;
|
||||
use crate::live_config::{atomic_write_if_unchanged, read_file_snapshot, text_from_snapshot};
|
||||
use std::path::Path;
|
||||
use toml_edit::{DocumentMut, Item, Table};
|
||||
|
||||
@@ -28,14 +31,18 @@ fn remove_markdown_path(table: &mut Table, key: &str) -> Option<Item> {
|
||||
|
||||
/// Repairs prompt paths appended by older Codex-X versions after a `[tui]`
|
||||
/// header. Only the exact legacy keys with Markdown path values are touched.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn migrate_legacy_prompt_config(codex_dir: &Path) -> Result<bool> {
|
||||
let cfg = config_path(codex_dir);
|
||||
let text = read_to_string_if_exists(&cfg)?;
|
||||
let _lock = acquire_live_config_lock(codex_dir)?;
|
||||
migrate_legacy_prompt_config_locked(codex_dir)
|
||||
}
|
||||
|
||||
pub(crate) fn migrated_legacy_prompt_config_text(cfg: &Path, text: &str) -> Result<Option<String>> {
|
||||
if text.trim().is_empty() {
|
||||
return Ok(false);
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let mut doc = parse_toml_document(&cfg, &text)?;
|
||||
let mut doc = parse_toml_document(cfg, text)?;
|
||||
let nested_instruction = tui_table(&doc)
|
||||
.and_then(|tui| tui.get(MODEL_AVAILABILITY_NUX_KEY))
|
||||
.and_then(|item| item.as_table())
|
||||
@@ -46,12 +53,10 @@ pub(crate) fn migrate_legacy_prompt_config(codex_dir: &Path) -> Result<bool> {
|
||||
tui_table(&doc).is_some_and(|tui| markdown_path(tui.get(MODEL_AVAILABILITY_NUX_KEY)));
|
||||
|
||||
if !nested_instruction && !tui_instruction && !nux_as_instruction {
|
||||
return Ok(false);
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let root_instruction_exists = doc.as_table().contains_key(INSTRUCTION_KEY);
|
||||
create_backup(codex_dir, "migrate-legacy-prompt-config")?;
|
||||
|
||||
let removed_nested = doc
|
||||
.get_mut("tui")
|
||||
.and_then(|item| item.as_table_mut())
|
||||
@@ -75,7 +80,19 @@ pub(crate) fn migrate_legacy_prompt_config(codex_dir: &Path) -> Result<bool> {
|
||||
doc.as_table_mut().insert(INSTRUCTION_KEY, instruction);
|
||||
}
|
||||
|
||||
write_text(&cfg, &doc.to_string())?;
|
||||
Ok(Some(doc.to_string()))
|
||||
}
|
||||
|
||||
pub(crate) fn migrate_legacy_prompt_config_locked(codex_dir: &Path) -> Result<bool> {
|
||||
let cfg = config_path(codex_dir);
|
||||
let original = read_file_snapshot(&cfg)?;
|
||||
let text = text_from_snapshot(&cfg, original.as_deref())?;
|
||||
let Some(migrated) = migrated_legacy_prompt_config_text(&cfg, &text)? else {
|
||||
return Ok(false);
|
||||
};
|
||||
create_backup(codex_dir, "migrate-legacy-prompt-config")?;
|
||||
|
||||
atomic_write_if_unchanged(&cfg, original.as_deref(), migrated.as_bytes())?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
use tauri::{
|
||||
menu::{Menu, MenuItem},
|
||||
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
|
||||
Manager, WindowEvent,
|
||||
};
|
||||
|
||||
const MAIN_WINDOW_LABEL: &str = "main";
|
||||
const TRAY_ID: &str = "codex-x-tray";
|
||||
const SHOW_WINDOW_MENU_ID: &str = "show-main-window";
|
||||
const QUIT_APP_MENU_ID: &str = "quit-codex-x";
|
||||
const SHOW_TRAY_MENU_ON_LEFT_CLICK: bool = cfg!(target_os = "macos");
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn set_macos_tray_mode(app: &tauri::AppHandle, dock_visible: bool) -> tauri::Result<()> {
|
||||
let policy = if dock_visible {
|
||||
tauri::ActivationPolicy::Regular
|
||||
} else {
|
||||
tauri::ActivationPolicy::Accessory
|
||||
};
|
||||
|
||||
// Attempt both operations: either one alone can leave a stale Dock entry on some macOS versions.
|
||||
let dock_result = app.set_dock_visibility(dock_visible);
|
||||
let policy_result = app.set_activation_policy(policy);
|
||||
dock_result?;
|
||||
policy_result
|
||||
}
|
||||
|
||||
fn retain_first_error(first_error: &mut Option<tauri::Error>, result: tauri::Result<()>) {
|
||||
if let Err(error) = result {
|
||||
if first_error.is_none() {
|
||||
*first_error = Some(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn show_main_window(app: &tauri::AppHandle) -> tauri::Result<()> {
|
||||
let Some(window) = app.get_webview_window(MAIN_WINDOW_LABEL) else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let mut first_error = None;
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
retain_first_error(&mut first_error, window.set_skip_taskbar(false));
|
||||
#[cfg(target_os = "macos")]
|
||||
retain_first_error(&mut first_error, app.show());
|
||||
|
||||
retain_first_error(&mut first_error, window.unminimize());
|
||||
retain_first_error(&mut first_error, window.show());
|
||||
retain_first_error(&mut first_error, window.set_focus());
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
retain_first_error(&mut first_error, set_macos_tray_mode(app, true));
|
||||
|
||||
first_error.map_or(Ok(()), Err)
|
||||
}
|
||||
|
||||
pub(crate) fn restore_main_window(app: &tauri::AppHandle) {
|
||||
if let Err(error) = show_main_window(app) {
|
||||
eprintln!("failed to restore the Codex-X window: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn setup_system_tray(app: &tauri::App) -> tauri::Result<()> {
|
||||
let show_window =
|
||||
MenuItem::with_id(app, SHOW_WINDOW_MENU_ID, "显示 Codex-X", true, None::<&str>)?;
|
||||
let quit_app = MenuItem::with_id(app, QUIT_APP_MENU_ID, "退出 Codex-X", true, None::<&str>)?;
|
||||
let menu = Menu::with_items(app, &[&show_window, &quit_app])?;
|
||||
|
||||
let mut tray = TrayIconBuilder::with_id(TRAY_ID)
|
||||
.menu(&menu)
|
||||
.tooltip("Codex-X")
|
||||
.show_menu_on_left_click(SHOW_TRAY_MENU_ON_LEFT_CLICK)
|
||||
.on_menu_event(|app, event| match event.id().as_ref() {
|
||||
SHOW_WINDOW_MENU_ID => restore_main_window(app),
|
||||
QUIT_APP_MENU_ID => app.exit(0),
|
||||
_ => {}
|
||||
})
|
||||
.on_tray_icon_event(|tray, event| {
|
||||
if !SHOW_TRAY_MENU_ON_LEFT_CLICK
|
||||
&& matches!(
|
||||
event,
|
||||
TrayIconEvent::Click {
|
||||
button: MouseButton::Left,
|
||||
button_state: MouseButtonState::Up,
|
||||
..
|
||||
}
|
||||
)
|
||||
{
|
||||
restore_main_window(tray.app_handle());
|
||||
}
|
||||
});
|
||||
|
||||
if let Some(icon) = app.default_window_icon().cloned() {
|
||||
tray = tray.icon(icon);
|
||||
}
|
||||
|
||||
tray.build(app)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn handle_window_event(window: &tauri::Window, event: &WindowEvent) {
|
||||
if window.label() != MAIN_WINDOW_LABEL {
|
||||
return;
|
||||
}
|
||||
|
||||
if let WindowEvent::CloseRequested { api, .. } = event {
|
||||
api.prevent_close();
|
||||
if let Err(error) = window.hide() {
|
||||
eprintln!("failed to hide the Codex-X window: {error}");
|
||||
return;
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
if let Err(error) = window.set_skip_taskbar(true) {
|
||||
eprintln!("failed to remove Codex-X from the taskbar: {error}");
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
if let Err(error) = set_macos_tray_mode(window.app_handle(), false) {
|
||||
eprintln!("failed to move Codex-X to the menu bar: {error}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn handle_run_event(app: &tauri::AppHandle, event: tauri::RunEvent) {
|
||||
#[cfg(target_os = "macos")]
|
||||
if let tauri::RunEvent::Reopen { .. } = event {
|
||||
restore_main_window(app);
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
let _ = (app, event);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ use crate::error::{CodexxError, Result};
|
||||
use chrono::Local;
|
||||
use serde_json::Value;
|
||||
use std::fs;
|
||||
use std::fs::OpenOptions;
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
use toml_edit::DocumentMut;
|
||||
@@ -107,6 +108,30 @@ pub(crate) fn read_to_string_if_exists(path: &Path) -> Result<String> {
|
||||
fs::read_to_string(path).map_err(|e| io_err(path, e))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn harden_sensitive_file_permissions(path: &Path) -> Result<()> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let metadata = match fs::metadata(path) {
|
||||
Ok(metadata) => metadata,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
|
||||
Err(error) => return Err(io_err(path, error)),
|
||||
};
|
||||
if !metadata.is_file() || metadata.permissions().mode() & 0o777 == 0o600 {
|
||||
return Ok(());
|
||||
}
|
||||
fs::set_permissions(path, fs::Permissions::from_mode(0o600))
|
||||
.map_err(|error| io_err(path, error))?;
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
let _ = path;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn parse_toml_document(path: &Path, text: &str) -> Result<DocumentMut> {
|
||||
if text.trim().is_empty() {
|
||||
return Ok(DocumentMut::new());
|
||||
@@ -117,7 +142,55 @@ pub(crate) fn parse_toml_document(path: &Path, text: &str) -> Result<DocumentMut
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn atomic_write(path: &Path, bytes: &[u8]) -> Result<()> {
|
||||
#[cfg(unix)]
|
||||
fn atomic_write_mode(path: &Path) -> Option<u32> {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let sensitive = path
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.is_some_and(|name| matches!(name, "config.toml" | "auth.json"));
|
||||
if sensitive {
|
||||
return Some(0o600);
|
||||
}
|
||||
fs::metadata(path)
|
||||
.ok()
|
||||
.map(|metadata| metadata.permissions().mode() & 0o777)
|
||||
}
|
||||
|
||||
fn create_atomic_temp(path: &Path, tmp: &Path, private: bool) -> Result<fs::File> {
|
||||
let mut options = OpenOptions::new();
|
||||
options.write(true).create_new(true);
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
|
||||
|
||||
let mode = private.then_some(0o600).or_else(|| atomic_write_mode(path));
|
||||
if let Some(mode) = mode {
|
||||
options.mode(mode);
|
||||
let file = options.open(tmp).map_err(|error| io_err(tmp, error))?;
|
||||
fs::set_permissions(tmp, fs::Permissions::from_mode(mode))
|
||||
.map_err(|error| io_err(tmp, error))?;
|
||||
return Ok(file);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
let _ = private;
|
||||
|
||||
options.open(tmp).map_err(|error| io_err(tmp, error))
|
||||
}
|
||||
|
||||
fn atomic_write_with_privacy_and_check<F>(
|
||||
path: &Path,
|
||||
bytes: &[u8],
|
||||
private: bool,
|
||||
pre_commit: F,
|
||||
) -> Result<()>
|
||||
where
|
||||
F: FnOnce() -> Result<()>,
|
||||
{
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
static WRITE_COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
if let Some(parent) = path.parent() {
|
||||
@@ -129,13 +202,29 @@ pub(crate) fn atomic_write(path: &Path, bytes: &[u8]) -> Result<()> {
|
||||
Local::now().timestamp_nanos_opt().unwrap_or_default(),
|
||||
WRITE_COUNTER.fetch_add(1, Ordering::Relaxed),
|
||||
));
|
||||
{
|
||||
let mut file = fs::File::create(&tmp).map_err(|e| io_err(&tmp, e))?;
|
||||
let result = (|| {
|
||||
let mut file = create_atomic_temp(path, &tmp, private)?;
|
||||
file.write_all(bytes).map_err(|e| io_err(&tmp, e))?;
|
||||
file.sync_all().map_err(|e| io_err(&tmp, e))?;
|
||||
drop(file);
|
||||
pre_commit()?;
|
||||
fs::rename(&tmp, path).map_err(|e| io_err(path, e))
|
||||
})();
|
||||
if result.is_err() {
|
||||
let _ = fs::remove_file(&tmp);
|
||||
}
|
||||
fs::rename(&tmp, path).map_err(|e| io_err(path, e))?;
|
||||
Ok(())
|
||||
result
|
||||
}
|
||||
|
||||
pub(crate) fn atomic_write(path: &Path, bytes: &[u8]) -> Result<()> {
|
||||
atomic_write_with_privacy_and_check(path, bytes, false, || Ok(()))
|
||||
}
|
||||
|
||||
pub(crate) fn atomic_write_checked<F>(path: &Path, bytes: &[u8], pre_commit: F) -> Result<()>
|
||||
where
|
||||
F: FnOnce() -> Result<()>,
|
||||
{
|
||||
atomic_write_with_privacy_and_check(path, bytes, false, pre_commit)
|
||||
}
|
||||
|
||||
pub(crate) fn write_text(path: &Path, text: &str) -> Result<()> {
|
||||
@@ -147,6 +236,11 @@ pub(crate) fn write_json(path: &Path, value: &Value) -> Result<()> {
|
||||
write_text(path, &(text + "\n"))
|
||||
}
|
||||
|
||||
pub(crate) fn write_private_json(path: &Path, value: &Value) -> Result<()> {
|
||||
let text = serde_json::to_string_pretty(value).map_err(|e| json_err(path, e))?;
|
||||
atomic_write_with_privacy_and_check(path, (text + "\n").as_bytes(), true, || Ok(()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -182,6 +276,91 @@ mod tests {
|
||||
fs::remove_dir_all(dir).expect("remove test directory");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checked_atomic_write_rejects_a_change_immediately_before_replace() {
|
||||
let dir = temp_dir("checked-race");
|
||||
let path = dir.join("config.toml");
|
||||
fs::write(&path, b"old").expect("write original file");
|
||||
|
||||
let error = atomic_write_checked(&path, b"codex-x", || {
|
||||
fs::write(&path, b"external").expect("simulate external writer");
|
||||
Err(CodexxError::Config("stale snapshot".to_string()))
|
||||
})
|
||||
.expect_err("stale checked write must fail");
|
||||
|
||||
assert!(error.to_string().contains("stale snapshot"));
|
||||
assert_eq!(fs::read(&path).expect("read external value"), b"external");
|
||||
assert_eq!(fs::read_dir(&dir).expect("read directory").count(), 1);
|
||||
fs::remove_dir_all(dir).expect("remove test directory");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn atomic_write_restricts_sensitive_codex_files_and_preserves_other_modes() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let dir = temp_dir("permissions");
|
||||
let config = dir.join("config.toml");
|
||||
let auth = dir.join("auth.json");
|
||||
let script = dir.join("tool.sh");
|
||||
for path in [&config, &auth, &script] {
|
||||
fs::write(path, b"old").expect("seed file");
|
||||
}
|
||||
fs::set_permissions(&config, fs::Permissions::from_mode(0o644)).expect("set config mode");
|
||||
fs::set_permissions(&auth, fs::Permissions::from_mode(0o644)).expect("set auth mode");
|
||||
fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).expect("set script mode");
|
||||
|
||||
atomic_write(&config, b"new config").expect("replace config");
|
||||
atomic_write(&auth, b"new auth").expect("replace auth");
|
||||
atomic_write(&script, b"new script").expect("replace script");
|
||||
|
||||
let mode = |path: &Path| fs::metadata(path).unwrap().permissions().mode() & 0o777;
|
||||
assert_eq!(mode(&config), 0o600);
|
||||
assert_eq!(mode(&auth), 0o600);
|
||||
assert_eq!(mode(&script), 0o755);
|
||||
fs::remove_dir_all(dir).expect("remove test directory");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn harden_sensitive_permissions_updates_existing_files_without_creating_missing_files() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let dir = temp_dir("harden-permissions");
|
||||
let config = dir.join("config.toml");
|
||||
let missing = dir.join("auth.json");
|
||||
fs::write(&config, b"model = \"gpt\"\n").expect("seed config");
|
||||
fs::set_permissions(&config, fs::Permissions::from_mode(0o644)).expect("set open mode");
|
||||
|
||||
harden_sensitive_file_permissions(&config).expect("harden config");
|
||||
harden_sensitive_file_permissions(&missing).expect("ignore missing auth");
|
||||
|
||||
assert_eq!(
|
||||
fs::metadata(&config).unwrap().permissions().mode() & 0o777,
|
||||
0o600
|
||||
);
|
||||
assert!(!missing.exists());
|
||||
fs::remove_dir_all(dir).expect("remove test directory");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn private_json_is_private_from_its_first_atomic_write() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let dir = temp_dir("private-json");
|
||||
let path = dir.join("official-snapshot.json");
|
||||
|
||||
write_private_json(&path, &serde_json::json!({"token": "secret"}))
|
||||
.expect("write private JSON");
|
||||
|
||||
assert_eq!(
|
||||
fs::metadata(&path).unwrap().permissions().mode() & 0o777,
|
||||
0o600
|
||||
);
|
||||
fs::remove_dir_all(dir).expect("remove test directory");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ensure_directory_accepts_existing_and_missing_directories() {
|
||||
let root = temp_dir("ensure-directory");
|
||||
|
||||
+388
-122
@@ -18,8 +18,10 @@ mod backups;
|
||||
mod ccswitch;
|
||||
mod config_migration;
|
||||
mod constants;
|
||||
mod desktop_lifecycle;
|
||||
mod error;
|
||||
mod file_io;
|
||||
mod live_config;
|
||||
mod paths;
|
||||
mod platform;
|
||||
mod prompts;
|
||||
@@ -32,50 +34,62 @@ mod state;
|
||||
mod toml_utils;
|
||||
mod updates;
|
||||
|
||||
use backups::{action_backup_root, backups, create_backup, BackupEntry, BackupMeta};
|
||||
use backups::{
|
||||
action_backup_root, backups, create_backup, validate_backup_codex_dir, BackupEntry, BackupMeta,
|
||||
};
|
||||
use config_migration::{migrate_legacy_prompt_config_locked, migrated_legacy_prompt_config_text};
|
||||
use constants::*;
|
||||
use error::{CodexxError, Result};
|
||||
#[cfg(target_os = "windows")]
|
||||
use file_io::io_err;
|
||||
use file_io::{directory_exists, ensure_directory, parse_toml_document};
|
||||
#[cfg(test)]
|
||||
use file_io::write_json;
|
||||
use file_io::{
|
||||
atomic_write, directory_exists, ensure_directory, io_err, parse_toml_document,
|
||||
read_to_string_if_exists, write_text,
|
||||
use file_io::{write_json, write_text};
|
||||
use live_config::{
|
||||
acquire_live_config_lock, apply_file_change, fail_with_file_rollback, read_file_snapshot,
|
||||
text_from_snapshot, AppliedFileChange,
|
||||
};
|
||||
#[cfg(test)]
|
||||
use paths::app_home;
|
||||
use paths::home_dir;
|
||||
use prompts::{
|
||||
agents_path, builtin_prompt_content, builtin_prompt_status_inner, bundled_prompt_meta,
|
||||
delete_prompt_inner, get_saved_prompt_inner, install_managed_agents_block,
|
||||
list_saved_prompts_inner, managed_agents_bounds, normalize_prompt_filename,
|
||||
prompt_template_key_for_instruction, refresh_builtin_prompts_with_active,
|
||||
remember_current_instruction_prompt, resolve_instruction_path, save_prompt_inner,
|
||||
uninstall_managed_agents_block, BuiltinPromptStatus, SavedPrompt,
|
||||
agents_path, builtin_prompt_content, builtin_prompt_detail_inner, builtin_prompt_status_inner,
|
||||
bundled_prompt_meta, delete_prompt_inner, get_saved_prompt_inner,
|
||||
install_managed_agents_block_in_content, list_saved_prompts_inner, managed_agents_bounds,
|
||||
normalize_prompt_filename, prompt_template_key_for_instruction,
|
||||
refresh_builtin_prompts_with_active, remember_current_instruction_prompt,
|
||||
remove_managed_agents_block_from_content, resolve_instruction_path,
|
||||
save_builtin_prompt_override_inner, save_prompt_inner, BuiltinPromptDetail,
|
||||
BuiltinPromptStatus, SavedPrompt,
|
||||
};
|
||||
#[cfg(test)]
|
||||
use prompts::{
|
||||
bundled_prompt_metas, cached_prompt_fallback_statuses, delete_cached_prompt_ids,
|
||||
github_prompt_catalog_from_entries, jsdelivr_prompt_catalog_from_entries,
|
||||
managed_agents_template_key_from_content, prompt_content_source_urls, stable_remote_prompt_id,
|
||||
stale_cached_prompt_ids, CachedBuiltinPrompt, GithubContentEntry,
|
||||
github_prompt_catalog_from_entries, install_managed_agents_block,
|
||||
jsdelivr_prompt_catalog_from_entries, managed_agents_template_key_from_content,
|
||||
prompt_content_source_urls, stable_remote_prompt_id, stale_cached_prompt_ids,
|
||||
uninstall_managed_agents_block, CachedBuiltinPrompt, GithubContentEntry,
|
||||
};
|
||||
#[cfg(test)]
|
||||
use providers::{
|
||||
build_ccswitch_codex_provider, canonical_provider_base_url, codex_sections_from_config,
|
||||
detected_live_custom_provider, is_official_ccswitch_row, list_saved_providers_on_connection,
|
||||
merge_duplicate_provider_identities, normalize_saved_provider, provider_by_id_on_connection,
|
||||
provider_identity, provider_status_result, read_ccswitch_codex_rows,
|
||||
save_manual_provider_on_connection, save_provider_toml_config_with_pre_persist,
|
||||
switch_official_provider_with_pre_persist, switch_provider_with_pre_persist,
|
||||
consolidate_legacy_provider_duplicates_on_connection, detected_live_custom_provider,
|
||||
is_official_ccswitch_row, list_saved_providers_on_connection, normalize_saved_provider,
|
||||
official_snapshot_path_for_test, provider_by_id_on_connection, provider_identity,
|
||||
provider_status_result, read_ccswitch_codex_rows, save_manual_provider_on_connection,
|
||||
save_provider_toml_config_with_pre_persist, switch_official_provider_with_pre_persist,
|
||||
switch_provider_with_pre_persist, upsert_ccswitch_provider_on_connection,
|
||||
upsert_provider_on_connection, CcSwitchCodexRow, ProviderUpsertKind, ProviderUpsertMode,
|
||||
};
|
||||
use providers::{
|
||||
delete_provider_inner, fetch_provider_models_inner, import_ccswitch_codex_providers_inner,
|
||||
list_saved_providers_inner, read_ccswitch_official_auth_inner, save_official_config_inner,
|
||||
build_provider_toml_draft_inner, delete_saved_provider_inner, fetch_provider_models_inner,
|
||||
get_official_config_draft_inner, import_ccswitch_codex_providers_inner,
|
||||
list_saved_providers_inner, read_ccswitch_official_auth_inner, reset_official_provider_inner,
|
||||
restore_official_provider_inner, save_active_provider_inner, save_official_config_inner,
|
||||
save_provider_inner, save_provider_toml_config_inner, switch_official_provider_inner,
|
||||
switch_provider_inner, test_provider_connection_inner, ImportResult, OfficialAuthCandidate,
|
||||
OfficialConfigInput, ProviderConnectionResult, ProviderInput, ProviderModelsResult,
|
||||
ProviderTomlInput, SavedProvider,
|
||||
OfficialConfigDraft, OfficialConfigInput, ProviderConnectionResult, ProviderInput,
|
||||
ProviderModelsResult, ProviderTomlInput, SavedProvider,
|
||||
};
|
||||
#[cfg(test)]
|
||||
use sessions::{
|
||||
@@ -101,7 +115,9 @@ use skills_mcp::{
|
||||
};
|
||||
#[cfg(test)]
|
||||
use state::active_saved_provider_id_from_config;
|
||||
use state::{auth_has_material, build_state, 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;
|
||||
@@ -167,7 +183,7 @@ pub(crate) fn now_rfc3339() -> String {
|
||||
|
||||
fn active_remote_builtin_prompt_id(config_dir: Option<String>) -> Option<String> {
|
||||
let codex_dir = resolve_codex_dir(config_dir).ok()?;
|
||||
let state = build_state(codex_dir).ok()?;
|
||||
let state = build_state_after_migration(codex_dir).ok()?;
|
||||
let template_key = state.instruction_template_key.as_deref()?;
|
||||
let id = template_key.strip_prefix("builtin:")?.trim();
|
||||
if id.is_empty() || bundled_prompt_meta(id).is_some() {
|
||||
@@ -576,6 +592,33 @@ async fn get_builtin_prompt_status() -> Result<Vec<BuiltinPromptStatus>> {
|
||||
.map_err(|e| CodexxError::Config(format!("读取内置提示词状态失败: {e}")))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn get_builtin_prompt_detail(template_id: String) -> Result<BuiltinPromptDetail> {
|
||||
tauri::async_runtime::spawn_blocking(move || builtin_prompt_detail_inner(&template_id))
|
||||
.await
|
||||
.map_err(|e| CodexxError::Config(format!("读取内置提示词失败: {e}")))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn save_builtin_prompt_override(
|
||||
template_id: String,
|
||||
content: String,
|
||||
) -> Result<BuiltinPromptDetail> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let id = template_id.trim();
|
||||
// Resolve first so stale or unknown IDs cannot create detached local records.
|
||||
let current = builtin_prompt_detail_inner(id)?;
|
||||
let content = content.trim();
|
||||
if !current.customized && current.content.trim() == content {
|
||||
return Ok(current);
|
||||
}
|
||||
save_builtin_prompt_override_inner(id, content)?;
|
||||
builtin_prompt_detail_inner(id)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| CodexxError::Config(format!("保存内置提示词修改失败: {e}")))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn refresh_builtin_prompts(config_dir: Option<String>) -> Result<Vec<BuiltinPromptStatus>> {
|
||||
tauri::async_runtime::spawn_blocking(move || refresh_builtin_prompts_inner(config_dir))
|
||||
@@ -640,6 +683,23 @@ fn managed_model_instruction_path(codex_dir: &Path, doc: &DocumentMut) -> Result
|
||||
Ok(Some(resolve_instruction_path(codex_dir, ¤t)))
|
||||
}
|
||||
|
||||
fn finish_file_action(
|
||||
codex_dir: PathBuf,
|
||||
changes: &[AppliedFileChange],
|
||||
message: String,
|
||||
backup_id: Option<String>,
|
||||
) -> Result<ActionResult> {
|
||||
match build_state_after_migration(codex_dir) {
|
||||
Ok(state) => Ok(ActionResult {
|
||||
ok: true,
|
||||
message,
|
||||
backup_id,
|
||||
state,
|
||||
}),
|
||||
Err(error) => fail_with_file_rollback(error, changes),
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn enable_prompt_content_inner(
|
||||
config_dir: Option<String>,
|
||||
@@ -664,49 +724,84 @@ fn enable_prompt_content_inner(
|
||||
|
||||
let codex_dir = resolve_codex_dir(config_dir)?;
|
||||
ensure_directory(&codex_dir)?;
|
||||
let _live_lock = acquire_live_config_lock(&codex_dir)?;
|
||||
migrate_legacy_prompt_config_locked(&codex_dir)?;
|
||||
let cfg = config_path(&codex_dir);
|
||||
let agents = agents_path(&codex_dir);
|
||||
let text = read_to_string_if_exists(&cfg)?;
|
||||
let config_before = read_file_snapshot(&cfg)?;
|
||||
let text = text_from_snapshot(&cfg, config_before.as_deref())?;
|
||||
let mut doc = parse_toml_document(&cfg, &text)?;
|
||||
let agents_text = read_to_string_if_exists(&agents)?;
|
||||
let agents_before = read_file_snapshot(&agents)?;
|
||||
let agents_text = text_from_snapshot(&agents, agents_before.as_deref())?;
|
||||
managed_agents_bounds(&agents_text)?;
|
||||
let previous_managed_file = managed_model_instruction_path(&codex_dir, &doc)?;
|
||||
if injection_mode == PromptInjectionMode::Replace {
|
||||
let _ = remember_current_instruction_prompt(&codex_dir);
|
||||
remember_current_instruction_prompt(&codex_dir)?;
|
||||
}
|
||||
let backup_id = create_backup(&codex_dir, action)?;
|
||||
let mut changes = Vec::new();
|
||||
|
||||
match injection_mode {
|
||||
PromptInjectionMode::Replace => {
|
||||
if doc.get("model").is_none() {
|
||||
doc["model"] = value("gpt-5.5");
|
||||
let mutation = (|| -> Result<()> {
|
||||
match injection_mode {
|
||||
PromptInjectionMode::Replace => {
|
||||
if doc.get("model").is_none() {
|
||||
doc["model"] = value("gpt-5.5");
|
||||
}
|
||||
doc["model_instructions_file"] = value(format!("./{filename}"));
|
||||
let prompt_path = codex_dir.join(filename);
|
||||
let prompt_before = read_file_snapshot(&prompt_path)?;
|
||||
changes.push(apply_file_change(
|
||||
&prompt_path,
|
||||
prompt_before,
|
||||
Some(content.as_bytes().to_vec()),
|
||||
)?);
|
||||
changes.push(apply_file_change(
|
||||
&cfg,
|
||||
config_before,
|
||||
Some(doc.to_string().into_bytes()),
|
||||
)?);
|
||||
let (next_agents, _) = remove_managed_agents_block_from_content(&agents_text)?;
|
||||
let next_agents =
|
||||
(!next_agents.trim().is_empty()).then(|| next_agents.into_bytes());
|
||||
changes.push(apply_file_change(&agents, agents_before, next_agents)?);
|
||||
}
|
||||
doc["model_instructions_file"] = value(format!("./{filename}"));
|
||||
write_text(&codex_dir.join(filename), content)?;
|
||||
write_text(&cfg, &doc.to_string())?;
|
||||
uninstall_managed_agents_block(&codex_dir)?;
|
||||
}
|
||||
PromptInjectionMode::Append => {
|
||||
install_managed_agents_block(&codex_dir, template_key, content)?;
|
||||
if previous_managed_file.is_some() {
|
||||
doc.as_table_mut().remove("model_instructions_file");
|
||||
write_text(&cfg, &doc.to_string())?;
|
||||
PromptInjectionMode::Append => {
|
||||
let next_agents =
|
||||
install_managed_agents_block_in_content(&agents_text, template_key, content)?;
|
||||
changes.push(apply_file_change(
|
||||
&agents,
|
||||
agents_before,
|
||||
Some(next_agents.into_bytes()),
|
||||
)?);
|
||||
if previous_managed_file.is_some() {
|
||||
doc.as_table_mut().remove("model_instructions_file");
|
||||
changes.push(apply_file_change(
|
||||
&cfg,
|
||||
config_before,
|
||||
Some(doc.to_string().into_bytes()),
|
||||
)?);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(previous) = previous_managed_file {
|
||||
let next = codex_dir.join(filename);
|
||||
let should_remove = injection_mode == PromptInjectionMode::Append || previous != next;
|
||||
if should_remove && previous.parent() == Some(codex_dir.as_path()) && previous.exists() {
|
||||
fs::remove_file(&previous).map_err(|e| io_err(&previous, e))?;
|
||||
if let Some(previous) = previous_managed_file {
|
||||
let next = codex_dir.join(filename);
|
||||
let should_remove = injection_mode == PromptInjectionMode::Append || previous != next;
|
||||
if should_remove && previous.parent() == Some(codex_dir.as_path()) {
|
||||
let previous_before = read_file_snapshot(&previous)?;
|
||||
changes.push(apply_file_change(&previous, previous_before, None)?);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})();
|
||||
if let Err(error) = mutation {
|
||||
return fail_with_file_rollback(error, &changes);
|
||||
}
|
||||
|
||||
let state = build_state(codex_dir)?;
|
||||
Ok(ActionResult {
|
||||
ok: true,
|
||||
message: format!(
|
||||
finish_file_action(
|
||||
codex_dir,
|
||||
&changes,
|
||||
format!(
|
||||
"已用{}模式启用 {title}(来源:{content_source})",
|
||||
if injection_mode == PromptInjectionMode::Append {
|
||||
"追加"
|
||||
@@ -715,8 +810,7 @@ fn enable_prompt_content_inner(
|
||||
}
|
||||
),
|
||||
backup_id,
|
||||
state,
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
fn enable_saved_prompt_inner(
|
||||
@@ -758,6 +852,18 @@ async fn list_saved_providers() -> Result<Vec<SavedProvider>> {
|
||||
.map_err(|e| CodexxError::Config(format!("读取供应商列表失败: {e}")))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn build_provider_toml_draft(
|
||||
provider: SavedProvider,
|
||||
config_dir: Option<String>,
|
||||
) -> Result<String> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
build_provider_toml_draft_inner(provider, config_dir)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| CodexxError::Config(format!("生成供应商 TOML 失败: {e}")))?
|
||||
}
|
||||
|
||||
fn save_provider_command_inner(provider: SavedProvider) -> Result<SavedProvider> {
|
||||
save_provider_inner(provider)
|
||||
}
|
||||
@@ -770,20 +876,32 @@ async fn save_provider(provider: SavedProvider) -> Result<SavedProvider> {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn delete_saved_provider(id: String) -> Result<()> {
|
||||
tauri::async_runtime::spawn_blocking(move || delete_provider_inner(id.trim()))
|
||||
async fn save_active_provider(
|
||||
provider: SavedProvider,
|
||||
config_dir: Option<String>,
|
||||
) -> Result<ActionResult> {
|
||||
tauri::async_runtime::spawn_blocking(move || save_active_provider_inner(provider, config_dir))
|
||||
.await
|
||||
.map_err(|e| CodexxError::Config(format!("保存活动供应商失败: {e}")))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn delete_saved_provider(id: String, config_dir: Option<String>) -> Result<()> {
|
||||
tauri::async_runtime::spawn_blocking(move || delete_saved_provider_inner(id.trim(), config_dir))
|
||||
.await
|
||||
.map_err(|e| CodexxError::Config(format!("删除供应商失败: {e}")))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn get_codex_state(config_dir: Option<String>) -> Result<CodexState> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let codex_dir = resolve_codex_dir(config_dir)?;
|
||||
build_state(codex_dir)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| CodexxError::Config(format!("读取 Codex 状态失败: {e}")))?
|
||||
tauri::async_runtime::spawn_blocking(move || get_codex_state_inner(config_dir))
|
||||
.await
|
||||
.map_err(|e| CodexxError::Config(format!("读取 Codex 状态失败: {e}")))?
|
||||
}
|
||||
|
||||
fn get_codex_state_inner(config_dir: Option<String>) -> Result<CodexState> {
|
||||
let codex_dir = resolve_codex_dir(config_dir)?;
|
||||
build_state_after_migration(codex_dir)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -793,10 +911,40 @@ async fn switch_official_provider(config_dir: Option<String>) -> Result<ActionRe
|
||||
.map_err(|e| CodexxError::Config(format!("切换官方配置失败: {e}")))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn get_official_config_draft(
|
||||
config_dir: Option<String>,
|
||||
) -> Result<Option<OfficialConfigDraft>> {
|
||||
tauri::async_runtime::spawn_blocking(move || get_official_config_draft_inner(config_dir))
|
||||
.await
|
||||
.map_err(|e| CodexxError::Config(format!("读取官方配置快照失败: {e}")))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn restore_official_provider(config_dir: Option<String>) -> Result<ActionResult> {
|
||||
tauri::async_runtime::spawn_blocking(move || restore_official_provider_inner(config_dir))
|
||||
.await
|
||||
.map_err(|e| CodexxError::Config(format!("还原官方配置失败: {e}")))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn reset_official_provider(input: OfficialConfigInput) -> Result<ActionResult> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
reset_official_provider_inner(input.config_dir, input.model, input.config_text)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| CodexxError::Config(format!("新建官方配置失败: {e}")))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn save_official_config(input: OfficialConfigInput) -> Result<ActionResult> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
save_official_config_inner(input.config_dir, input.model, input.auth_json)
|
||||
save_official_config_inner(
|
||||
input.config_dir,
|
||||
input.model,
|
||||
input.auth_json,
|
||||
input.config_text,
|
||||
)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| CodexxError::Config(format!("保存官方配置失败: {e}")))?
|
||||
@@ -856,34 +1004,59 @@ fn disable_instruction_inner(
|
||||
delete_file: Option<bool>,
|
||||
) -> Result<ActionResult> {
|
||||
let codex_dir = resolve_codex_dir(config_dir)?;
|
||||
ensure_directory(&codex_dir)?;
|
||||
let _live_lock = acquire_live_config_lock(&codex_dir)?;
|
||||
migrate_legacy_prompt_config_locked(&codex_dir)?;
|
||||
let cfg = config_path(&codex_dir);
|
||||
let agents_text = read_to_string_if_exists(&agents_path(&codex_dir))?;
|
||||
let agents = agents_path(&codex_dir);
|
||||
let agents_before = read_file_snapshot(&agents)?;
|
||||
let agents_text = text_from_snapshot(&agents, agents_before.as_deref())?;
|
||||
managed_agents_bounds(&agents_text)?;
|
||||
let backup_id = create_backup(&codex_dir, "disable-instruct")?;
|
||||
|
||||
let text = read_to_string_if_exists(&cfg)?;
|
||||
let config_before = read_file_snapshot(&cfg)?;
|
||||
let text = text_from_snapshot(&cfg, config_before.as_deref())?;
|
||||
let mut doc = parse_toml_document(&cfg, &text)?;
|
||||
let current = string_value(&doc, "model_instructions_file");
|
||||
let managed_model_path = managed_model_instruction_path(&codex_dir, &doc)?;
|
||||
let removed_model = managed_model_path.is_some();
|
||||
let mut changes = Vec::new();
|
||||
if removed_model {
|
||||
doc.as_table_mut().remove("model_instructions_file");
|
||||
write_text(&cfg, &doc.to_string())?;
|
||||
changes.push(apply_file_change(
|
||||
&cfg,
|
||||
config_before,
|
||||
Some(doc.to_string().into_bytes()),
|
||||
)?);
|
||||
}
|
||||
let (next_agents, removed_agents) = remove_managed_agents_block_from_content(&agents_text)?;
|
||||
if removed_agents {
|
||||
let next_agents = (!next_agents.trim().is_empty()).then(|| next_agents.into_bytes());
|
||||
match apply_file_change(&agents, agents_before, next_agents) {
|
||||
Ok(change) => changes.push(change),
|
||||
Err(error) => return fail_with_file_rollback(error, &changes),
|
||||
}
|
||||
}
|
||||
let removed_agents = uninstall_managed_agents_block(&codex_dir)?;
|
||||
if delete_file.unwrap_or(true) {
|
||||
if let Some(md) = managed_model_path {
|
||||
if md.parent() == Some(codex_dir.as_path()) && md.exists() {
|
||||
fs::remove_file(&md).map_err(|e| io_err(&md, e))?;
|
||||
if md.parent() == Some(codex_dir.as_path()) {
|
||||
let before = match read_file_snapshot(&md) {
|
||||
Ok(before) => before,
|
||||
Err(error) => return fail_with_file_rollback(error, &changes),
|
||||
};
|
||||
match apply_file_change(&md, before, None) {
|
||||
Ok(change) => changes.push(change),
|
||||
Err(error) => return fail_with_file_rollback(error, &changes),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let state = build_state(codex_dir)?;
|
||||
let removed = removed_model || removed_agents;
|
||||
Ok(ActionResult {
|
||||
ok: true,
|
||||
message: if removed {
|
||||
finish_file_action(
|
||||
codex_dir,
|
||||
&changes,
|
||||
if removed {
|
||||
"已禁用指令提示词".to_string()
|
||||
} else if current.is_some() {
|
||||
"当前使用的是用户自己的提示词,Codex-X 未做修改".to_string()
|
||||
@@ -891,8 +1064,7 @@ fn disable_instruction_inner(
|
||||
"当前没有启用 Codex-X 提示词".to_string()
|
||||
},
|
||||
backup_id,
|
||||
state,
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -907,8 +1079,12 @@ async fn disable_instruction(
|
||||
|
||||
fn disable_external_instruction_inner(config_dir: Option<String>) -> Result<ActionResult> {
|
||||
let codex_dir = resolve_codex_dir(config_dir)?;
|
||||
ensure_directory(&codex_dir)?;
|
||||
let _live_lock = acquire_live_config_lock(&codex_dir)?;
|
||||
migrate_legacy_prompt_config_locked(&codex_dir)?;
|
||||
let cfg = config_path(&codex_dir);
|
||||
let text = read_to_string_if_exists(&cfg)?;
|
||||
let config_before = read_file_snapshot(&cfg)?;
|
||||
let text = text_from_snapshot(&cfg, config_before.as_deref())?;
|
||||
let mut doc = parse_toml_document(&cfg, &text)?;
|
||||
let current = string_value(&doc, "model_instructions_file");
|
||||
if let Some(value) = current.as_deref() {
|
||||
@@ -919,21 +1095,25 @@ fn disable_external_instruction_inner(config_dir: Option<String>) -> Result<Acti
|
||||
}
|
||||
}
|
||||
let backup_id = create_backup(&codex_dir, "disable-external-instruct")?;
|
||||
let mut changes = Vec::new();
|
||||
if current.is_some() {
|
||||
doc.as_table_mut().remove("model_instructions_file");
|
||||
write_text(&cfg, &doc.to_string())?;
|
||||
changes.push(apply_file_change(
|
||||
&cfg,
|
||||
config_before,
|
||||
Some(doc.to_string().into_bytes()),
|
||||
)?);
|
||||
}
|
||||
let state = build_state(codex_dir)?;
|
||||
Ok(ActionResult {
|
||||
ok: true,
|
||||
message: if current.is_some() {
|
||||
finish_file_action(
|
||||
codex_dir,
|
||||
&changes,
|
||||
if current.is_some() {
|
||||
"已禁用用户外部提示词,原 md 文件已保留".to_string()
|
||||
} else {
|
||||
"当前没有外部提示词".to_string()
|
||||
},
|
||||
backup_id,
|
||||
state,
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -984,56 +1164,123 @@ async fn list_backups() -> Result<Vec<BackupEntry>> {
|
||||
.map_err(|e| CodexxError::Config(format!("读取备份列表失败: {e}")))?
|
||||
}
|
||||
|
||||
fn read_backup_file_snapshot(path: &Path) -> Result<Option<Vec<u8>>> {
|
||||
let metadata = match fs::symlink_metadata(path) {
|
||||
Ok(metadata) => metadata,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
|
||||
Err(error) => return Err(file_io::io_err(path, error)),
|
||||
};
|
||||
if metadata.file_type().is_symlink() || !metadata.is_file() {
|
||||
return Err(CodexxError::Config(format!(
|
||||
"备份文件不是普通文件: {}",
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
read_file_snapshot(path)
|
||||
}
|
||||
|
||||
fn declared_backup_file_snapshot(path: &Path, declared_present: bool) -> Result<Option<Vec<u8>>> {
|
||||
match (declared_present, read_backup_file_snapshot(path)?) {
|
||||
(true, Some(bytes)) => Ok(Some(bytes)),
|
||||
(true, None) => Err(CodexxError::Config(format!(
|
||||
"备份元数据声明文件存在,但备份文件缺失: {}",
|
||||
path.display()
|
||||
))),
|
||||
(false, None) => Ok(None),
|
||||
(false, Some(_)) => Err(CodexxError::Config(format!(
|
||||
"备份元数据声明文件不存在,但目录中出现了多余文件: {}",
|
||||
path.display()
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
fn restore_backup_inner(config_dir: Option<String>, backup_id: String) -> Result<ActionResult> {
|
||||
let codex_dir = resolve_codex_dir(config_dir)?;
|
||||
let backup_id = backup_id.trim().to_string();
|
||||
let mut backup_components = Path::new(&backup_id).components();
|
||||
if !matches!(
|
||||
backup_components.next(),
|
||||
Some(std::path::Component::Normal(_))
|
||||
) || backup_components.next().is_some()
|
||||
{
|
||||
return Err(CodexxError::Config("备份 ID 无效".to_string()));
|
||||
}
|
||||
let dir = action_backup_root(&codex_dir)?.join(&backup_id);
|
||||
if !dir.exists() {
|
||||
let dir_metadata = fs::symlink_metadata(&dir).ok();
|
||||
if !dir_metadata
|
||||
.as_ref()
|
||||
.is_some_and(|metadata| metadata.is_dir() && !metadata.file_type().is_symlink())
|
||||
{
|
||||
return Err(CodexxError::Config(format!("备份不存在: {backup_id}")));
|
||||
}
|
||||
|
||||
let restore_marker = create_backup(&codex_dir, "before-restore")?;
|
||||
let backup_meta_path = dir.join("meta.json");
|
||||
let backup_meta_bytes = read_backup_file_snapshot(&backup_meta_path)?.ok_or_else(|| {
|
||||
CodexxError::Config(format!(
|
||||
"备份缺少元数据文件: {}",
|
||||
backup_meta_path.display()
|
||||
))
|
||||
})?;
|
||||
let backup_meta = serde_json::from_slice::<BackupMeta>(&backup_meta_bytes)
|
||||
.map_err(|error| file_io::json_err(&backup_meta_path, error))?;
|
||||
if backup_meta.id != backup_id {
|
||||
return Err(CodexxError::Config(format!(
|
||||
"备份元数据 ID 与请求不一致:元数据为 {},请求为 {backup_id}",
|
||||
backup_meta.id
|
||||
)));
|
||||
}
|
||||
validate_backup_codex_dir(&backup_meta, &codex_dir)?;
|
||||
|
||||
let cfg = config_path(&codex_dir);
|
||||
let auth = auth_path(&codex_dir);
|
||||
let agents = agents_path(&codex_dir);
|
||||
ensure_directory(&codex_dir)?;
|
||||
|
||||
let backup_meta = fs::read_to_string(dir.join("meta.json"))
|
||||
.ok()
|
||||
.and_then(|text| serde_json::from_str::<BackupMeta>(&text).ok());
|
||||
|
||||
let backup_cfg = dir.join("config.toml");
|
||||
if backup_cfg.exists() {
|
||||
let bytes = fs::read(&backup_cfg).map_err(|e| io_err(&backup_cfg, e))?;
|
||||
atomic_write(&cfg, &bytes)?;
|
||||
} else if cfg.exists() {
|
||||
fs::remove_file(&cfg).map_err(|e| io_err(&cfg, e))?;
|
||||
}
|
||||
|
||||
let backup_auth = dir.join("auth.json");
|
||||
if backup_auth.exists() {
|
||||
let bytes = fs::read(&backup_auth).map_err(|e| io_err(&backup_auth, e))?;
|
||||
atomic_write(&auth, &bytes)?;
|
||||
} else if auth.exists() {
|
||||
fs::remove_file(&auth).map_err(|e| io_err(&auth, e))?;
|
||||
}
|
||||
|
||||
if backup_meta.as_ref().is_some_and(|meta| meta.tracks_agents) {
|
||||
let backup_agents = dir.join(AGENTS_FILENAME);
|
||||
if backup_agents.exists() {
|
||||
let bytes = fs::read(&backup_agents).map_err(|e| io_err(&backup_agents, e))?;
|
||||
atomic_write(&agents, &bytes)?;
|
||||
} else if agents.exists() {
|
||||
fs::remove_file(&agents).map_err(|e| io_err(&agents, e))?;
|
||||
let backup_agents = dir.join(AGENTS_FILENAME);
|
||||
let backup_config = declared_backup_file_snapshot(&backup_cfg, backup_meta.had_config)?;
|
||||
let backup_config = match backup_config {
|
||||
Some(bytes) => {
|
||||
let text = text_from_snapshot(&backup_cfg, Some(&bytes))?;
|
||||
Some(
|
||||
migrated_legacy_prompt_config_text(&cfg, &text)?
|
||||
.unwrap_or(text)
|
||||
.into_bytes(),
|
||||
)
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
let backup_auth = declared_backup_file_snapshot(&backup_auth, backup_meta.had_auth)?;
|
||||
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)?;
|
||||
let _live_lock = acquire_live_config_lock(&codex_dir)?;
|
||||
migrate_legacy_prompt_config_locked(&codex_dir)?;
|
||||
let restore_marker = create_backup(&codex_dir, "before-restore")?;
|
||||
let config_before = read_file_snapshot(&cfg)?;
|
||||
let auth_before = read_file_snapshot(&auth)?;
|
||||
let agents_before = read_file_snapshot(&agents)?;
|
||||
|
||||
let mut changes = Vec::new();
|
||||
let mutation = (|| -> Result<()> {
|
||||
changes.push(apply_file_change(&cfg, config_before, backup_config)?);
|
||||
changes.push(apply_file_change(&auth, auth_before, backup_auth)?);
|
||||
changes.push(apply_file_change(&agents, agents_before, backup_agents)?);
|
||||
Ok(())
|
||||
})();
|
||||
if let Err(error) = mutation {
|
||||
return fail_with_file_rollback(error, &changes);
|
||||
}
|
||||
|
||||
let state = build_state(codex_dir)?;
|
||||
Ok(ActionResult {
|
||||
ok: true,
|
||||
message: format!("已恢复备份 {backup_id}"),
|
||||
backup_id: restore_marker,
|
||||
state,
|
||||
})
|
||||
finish_file_action(
|
||||
codex_dir,
|
||||
&changes,
|
||||
format!("已恢复备份 {backup_id}"),
|
||||
restore_marker,
|
||||
)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -1079,9 +1326,19 @@ fn open_url(url: String) -> std::result::Result<(), String> {
|
||||
}
|
||||
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
let app = tauri::Builder::default()
|
||||
// This must remain the first plugin so a second launch cannot initialize
|
||||
// another tray or start concurrent configuration work.
|
||||
.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
|
||||
desktop_lifecycle::restore_main_window(app);
|
||||
}))
|
||||
.plugin(tauri_plugin_process::init())
|
||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||
.setup(|app| {
|
||||
desktop_lifecycle::setup_system_tray(app)?;
|
||||
Ok(())
|
||||
})
|
||||
.on_window_event(desktop_lifecycle::handle_window_event)
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
get_about_info,
|
||||
check_app_update,
|
||||
@@ -1100,16 +1357,23 @@ pub fn run() {
|
||||
import_ccswitch_codex_providers,
|
||||
list_saved_prompts,
|
||||
get_builtin_prompt_status,
|
||||
get_builtin_prompt_detail,
|
||||
save_builtin_prompt_override,
|
||||
refresh_builtin_prompts,
|
||||
remember_current_instruction,
|
||||
save_prompt,
|
||||
delete_saved_prompt,
|
||||
enable_saved_prompt,
|
||||
list_saved_providers,
|
||||
build_provider_toml_draft,
|
||||
save_provider,
|
||||
save_active_provider,
|
||||
delete_saved_provider,
|
||||
get_codex_state,
|
||||
switch_official_provider,
|
||||
get_official_config_draft,
|
||||
restore_official_provider,
|
||||
reset_official_provider,
|
||||
save_official_config,
|
||||
enable_instruction,
|
||||
enable_instruction_template,
|
||||
@@ -1123,8 +1387,10 @@ pub fn run() {
|
||||
restore_backup,
|
||||
open_url,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running Codex-X");
|
||||
.build(tauri::generate_context!())
|
||||
.expect("error while building Codex-X");
|
||||
|
||||
app.run(desktop_lifecycle::handle_run_event);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
use crate::error::{CodexxError, Result};
|
||||
use crate::file_io::{atomic_write_checked, ensure_directory, io_err};
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
|
||||
pub(crate) struct LiveConfigLock {
|
||||
file: fs::File,
|
||||
}
|
||||
|
||||
impl Drop for LiveConfigLock {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.file.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn acquire_live_config_lock(codex_dir: &Path) -> Result<LiveConfigLock> {
|
||||
let tmp_dir = codex_dir.join("tmp");
|
||||
ensure_directory(&tmp_dir)?;
|
||||
let path = tmp_dir.join("codex-x-live-config.lock");
|
||||
if path.is_dir() {
|
||||
return Err(CodexxError::Config(format!(
|
||||
"Codex live 配置锁被同名目录占用: {}",
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
let mut file = fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.truncate(false)
|
||||
.read(true)
|
||||
.write(true)
|
||||
.open(&path)
|
||||
.map_err(|error| io_err(&path, error))?;
|
||||
file.try_lock().map_err(|_| {
|
||||
CodexxError::Config(format!(
|
||||
"另一个 Codex-X 正在修改 Codex live 配置,请稍后重试: {}",
|
||||
path.display()
|
||||
))
|
||||
})?;
|
||||
file.set_len(0).map_err(|error| io_err(&path, error))?;
|
||||
writeln!(file, "pid={}", std::process::id()).map_err(|error| io_err(&path, error))?;
|
||||
file.sync_all().map_err(|error| io_err(&path, error))?;
|
||||
Ok(LiveConfigLock { file })
|
||||
}
|
||||
|
||||
pub(crate) fn read_file_snapshot(path: &Path) -> Result<Option<Vec<u8>>> {
|
||||
match fs::read(path) {
|
||||
Ok(bytes) => Ok(Some(bytes)),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
||||
Err(error) => Err(io_err(path, error)),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn text_from_snapshot(path: &Path, snapshot: Option<&[u8]>) -> Result<String> {
|
||||
String::from_utf8(snapshot.unwrap_or_default().to_vec())
|
||||
.map_err(|_| CodexxError::Config(format!("{} 不是有效的 UTF-8 文本", path.display())))
|
||||
}
|
||||
|
||||
pub(crate) fn ensure_file_snapshot_unchanged(path: &Path, expected: Option<&[u8]>) -> Result<()> {
|
||||
if read_file_snapshot(path)?.as_deref() == expected {
|
||||
return Ok(());
|
||||
}
|
||||
Err(CodexxError::Config(format!(
|
||||
"{} 已被其他程序修改,本次写入已取消,请刷新后重试",
|
||||
path.display()
|
||||
)))
|
||||
}
|
||||
|
||||
pub(crate) fn atomic_write_if_unchanged(
|
||||
path: &Path,
|
||||
expected: Option<&[u8]>,
|
||||
replacement: &[u8],
|
||||
) -> Result<()> {
|
||||
atomic_write_checked(path, replacement, || {
|
||||
ensure_file_snapshot_unchanged(path, expected)
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn remove_file_if_unchanged(path: &Path, expected: Option<&[u8]>) -> Result<()> {
|
||||
ensure_file_snapshot_unchanged(path, expected)?;
|
||||
match fs::remove_file(path) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||
Err(error) => Err(io_err(path, error)),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn restore_file_snapshot_if_unchanged(
|
||||
path: &Path,
|
||||
expected_current: Option<&[u8]>,
|
||||
snapshot: Option<&[u8]>,
|
||||
) -> Result<()> {
|
||||
match snapshot {
|
||||
Some(bytes) => atomic_write_if_unchanged(path, expected_current, bytes),
|
||||
None => remove_file_if_unchanged(path, expected_current),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct AppliedFileChange {
|
||||
path: std::path::PathBuf,
|
||||
before: Option<Vec<u8>>,
|
||||
after: Option<Vec<u8>>,
|
||||
changed: bool,
|
||||
}
|
||||
|
||||
impl AppliedFileChange {
|
||||
pub(crate) fn rollback(&self) -> Result<()> {
|
||||
if !self.changed {
|
||||
return Ok(());
|
||||
}
|
||||
restore_file_snapshot_if_unchanged(
|
||||
&self.path,
|
||||
self.after.as_deref(),
|
||||
self.before.as_deref(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn apply_file_change(
|
||||
path: &Path,
|
||||
before: Option<Vec<u8>>,
|
||||
after: Option<Vec<u8>>,
|
||||
) -> Result<AppliedFileChange> {
|
||||
let changed = before != after;
|
||||
if changed {
|
||||
match after.as_deref() {
|
||||
Some(bytes) => atomic_write_if_unchanged(path, before.as_deref(), bytes)?,
|
||||
None => remove_file_if_unchanged(path, before.as_deref())?,
|
||||
}
|
||||
}
|
||||
Ok(AppliedFileChange {
|
||||
path: path.to_path_buf(),
|
||||
before,
|
||||
after,
|
||||
changed,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn rollback_file_changes(changes: &[AppliedFileChange]) -> Result<()> {
|
||||
for change in changes.iter().filter(|change| change.changed) {
|
||||
ensure_file_snapshot_unchanged(&change.path, change.after.as_deref())?;
|
||||
}
|
||||
|
||||
let mut failures = Vec::new();
|
||||
for change in changes.iter().rev() {
|
||||
if let Err(error) = change.rollback() {
|
||||
failures.push(error.to_string());
|
||||
}
|
||||
}
|
||||
if failures.is_empty() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(CodexxError::Config(failures.join(";")))
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn fail_with_file_rollback<T>(
|
||||
error: CodexxError,
|
||||
changes: &[AppliedFileChange],
|
||||
) -> Result<T> {
|
||||
match rollback_file_changes(changes) {
|
||||
Ok(()) => Err(error),
|
||||
Err(rollback_error) => Err(CodexxError::Config(format!(
|
||||
"{error};文件回滚失败:{rollback_error}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
fn temp_dir(name: &str) -> std::path::PathBuf {
|
||||
static COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"codex-x-live-config-{name}-{}-{}",
|
||||
std::process::id(),
|
||||
COUNTER.fetch_add(1, Ordering::Relaxed),
|
||||
));
|
||||
let _ = fs::remove_dir_all(&path);
|
||||
fs::create_dir_all(&path).expect("create test directory");
|
||||
path
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lock_rejects_a_second_writer_and_releases_on_drop() {
|
||||
let dir = temp_dir("lock");
|
||||
let first = acquire_live_config_lock(&dir).expect("acquire first lock");
|
||||
let error = acquire_live_config_lock(&dir)
|
||||
.err()
|
||||
.expect("second lock must fail");
|
||||
assert!(error.to_string().contains("另一个 Codex-X"));
|
||||
drop(first);
|
||||
acquire_live_config_lock(&dir).expect("lock is released on drop");
|
||||
fs::remove_dir_all(dir).expect("remove test directory");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_checked_write_preserves_the_external_value() {
|
||||
let dir = temp_dir("stale");
|
||||
let path = dir.join("config.toml");
|
||||
fs::write(&path, b"old").expect("seed file");
|
||||
let old = read_file_snapshot(&path).expect("capture file");
|
||||
fs::write(&path, b"external").expect("simulate external writer");
|
||||
|
||||
atomic_write_if_unchanged(&path, old.as_deref(), b"codex-x")
|
||||
.expect_err("stale write must fail");
|
||||
|
||||
assert_eq!(fs::read(&path).expect("read file"), b"external");
|
||||
fs::remove_dir_all(dir).expect("remove test directory");
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,28 @@
|
||||
use std::collections::HashSet;
|
||||
use std::fs;
|
||||
use std::io::Read;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Output};
|
||||
use std::process::{Child, Command, Output, Stdio};
|
||||
use std::sync::{mpsc, OnceLock};
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
use std::env;
|
||||
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
const WINDOWS_CODEX_PACKAGE_IDENTITIES: &[&str] =
|
||||
&["OpenAI.Codex", "OpenAI.CodexBeta", "OpenAI.ChatGPT-Desktop"];
|
||||
#[cfg(target_os = "windows")]
|
||||
const WINDOWS_CODEX_EXECUTABLES: &[&str] = &["ChatGPT.exe", "Codex.exe", "codex.exe"];
|
||||
|
||||
const CODEX_VERSION_PROBE_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
const PROGRAM_TIMEOUT: Duration = Duration::from_secs(2);
|
||||
const PROGRAM_POLL_INTERVAL: Duration = Duration::from_millis(10);
|
||||
const CHILD_TERMINATION_GRACE: Duration = Duration::from_millis(250);
|
||||
|
||||
static CODEX_VERSION: OnceLock<String> = OnceLock::new();
|
||||
|
||||
fn version_line(stdout: &str, stderr: &str, success: bool) -> Option<String> {
|
||||
let lines = stdout.lines().chain(stderr.lines()).map(str::trim);
|
||||
let preferred = lines.clone().find(|line| {
|
||||
@@ -73,14 +90,142 @@ pub fn program_command(program: &Path, args: &[&str]) -> Command {
|
||||
command
|
||||
}
|
||||
|
||||
fn run_program(program: &Path, args: &[&str]) -> Option<Output> {
|
||||
program_command(program, args).output().ok()
|
||||
fn remaining_timeout(deadline: Option<Instant>, maximum: Duration) -> Option<Duration> {
|
||||
let remaining = deadline
|
||||
.map(|deadline| deadline.saturating_duration_since(Instant::now()))
|
||||
.unwrap_or(maximum)
|
||||
.min(maximum);
|
||||
(!remaining.is_zero()).then_some(remaining)
|
||||
}
|
||||
|
||||
fn command_version(program: &Path) -> Option<String> {
|
||||
run_program(program, &["--version"])
|
||||
fn deadline_expired(deadline: Option<Instant>) -> bool {
|
||||
deadline.is_some_and(|deadline| Instant::now() >= deadline)
|
||||
}
|
||||
|
||||
fn wait_for_child_exit(child: &mut Child, deadline: Instant) {
|
||||
loop {
|
||||
match child.try_wait() {
|
||||
Ok(Some(_)) | Err(_) => return,
|
||||
Ok(None) if Instant::now() < deadline => {
|
||||
let remaining = deadline.saturating_duration_since(Instant::now());
|
||||
thread::sleep(remaining.min(PROGRAM_POLL_INTERVAL));
|
||||
}
|
||||
Ok(None) => return,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn terminate_child(child: &mut Child) {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
|
||||
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||
let pid = child.id().to_string();
|
||||
let mut taskkill = Command::new("taskkill.exe");
|
||||
taskkill
|
||||
.args(["/PID", pid.as_str(), "/T", "/F"])
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.creation_flags(CREATE_NO_WINDOW);
|
||||
if let Ok(mut killer) = taskkill.spawn() {
|
||||
let deadline = Instant::now() + CHILD_TERMINATION_GRACE;
|
||||
wait_for_child_exit(&mut killer, deadline);
|
||||
let _ = killer.kill();
|
||||
}
|
||||
}
|
||||
|
||||
let _ = child.kill();
|
||||
wait_for_child_exit(child, Instant::now() + CHILD_TERMINATION_GRACE);
|
||||
}
|
||||
|
||||
fn output_reader<R>(mut stream: R) -> Option<mpsc::Receiver<Option<Vec<u8>>>>
|
||||
where
|
||||
R: Read + Send + 'static,
|
||||
{
|
||||
let (sender, receiver) = mpsc::channel();
|
||||
thread::Builder::new()
|
||||
.name("codex-version-output".to_string())
|
||||
.spawn(move || {
|
||||
let mut output = Vec::new();
|
||||
let result = stream.read_to_end(&mut output).ok().map(|_| output);
|
||||
let _ = sender.send(result);
|
||||
})
|
||||
.ok()?;
|
||||
Some(receiver)
|
||||
}
|
||||
|
||||
fn receive_output(
|
||||
receiver: &mpsc::Receiver<Option<Vec<u8>>>,
|
||||
deadline: Instant,
|
||||
) -> Option<Vec<u8>> {
|
||||
match receiver.try_recv() {
|
||||
Ok(output) => output,
|
||||
Err(mpsc::TryRecvError::Disconnected) => None,
|
||||
Err(mpsc::TryRecvError::Empty) => {
|
||||
let remaining = deadline.saturating_duration_since(Instant::now());
|
||||
(!remaining.is_zero())
|
||||
.then(|| receiver.recv_timeout(remaining).ok().flatten())
|
||||
.flatten()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn run_program(program: &Path, args: &[&str], deadline: Option<Instant>) -> Option<Output> {
|
||||
let timeout = remaining_timeout(deadline, PROGRAM_TIMEOUT)?;
|
||||
let command_deadline = Instant::now().checked_add(timeout)?;
|
||||
let mut command = program_command(program, args);
|
||||
command
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
let mut child = command.spawn().ok()?;
|
||||
|
||||
let Some(stdout) = child.stdout.take() else {
|
||||
terminate_child(&mut child);
|
||||
return None;
|
||||
};
|
||||
let Some(stderr) = child.stderr.take() else {
|
||||
terminate_child(&mut child);
|
||||
return None;
|
||||
};
|
||||
// Drain both pipes while polling so verbose commands cannot block on a full pipe buffer.
|
||||
let Some(stdout_receiver) = output_reader(stdout) else {
|
||||
terminate_child(&mut child);
|
||||
return None;
|
||||
};
|
||||
let Some(stderr_receiver) = output_reader(stderr) else {
|
||||
terminate_child(&mut child);
|
||||
return None;
|
||||
};
|
||||
|
||||
let status = loop {
|
||||
match child.try_wait() {
|
||||
Ok(Some(status)) => break status,
|
||||
Ok(None) if Instant::now() < command_deadline => {
|
||||
let remaining = command_deadline.saturating_duration_since(Instant::now());
|
||||
thread::sleep(remaining.min(PROGRAM_POLL_INTERVAL));
|
||||
}
|
||||
Ok(None) | Err(_) => {
|
||||
terminate_child(&mut child);
|
||||
return None;
|
||||
}
|
||||
}
|
||||
};
|
||||
let stdout = receive_output(&stdout_receiver, command_deadline)?;
|
||||
let stderr = receive_output(&stderr_receiver, command_deadline)?;
|
||||
Some(Output {
|
||||
status,
|
||||
stdout,
|
||||
stderr,
|
||||
})
|
||||
}
|
||||
|
||||
fn command_version(program: &Path, deadline: Option<Instant>) -> Option<String> {
|
||||
run_program(program, &["--version"], deadline)
|
||||
.and_then(version_from_output)
|
||||
.or_else(|| run_program(program, &["-V"]).and_then(version_from_output))
|
||||
.or_else(|| run_program(program, &["-V"], deadline).and_then(version_from_output))
|
||||
}
|
||||
|
||||
fn candidate_key(path: &Path) -> String {
|
||||
@@ -98,17 +243,98 @@ fn push_candidate(candidates: &mut Vec<PathBuf>, seen: &mut HashSet<String>, pat
|
||||
}
|
||||
}
|
||||
|
||||
fn collect_named_files(root: &Path, names: &[&str], depth: usize, output: &mut Vec<PathBuf>) {
|
||||
if depth == 0 || !root.is_dir() {
|
||||
return;
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
fn numeric_version(value: &str) -> Option<Vec<u32>> {
|
||||
let parts = value
|
||||
.split('.')
|
||||
.map(str::parse::<u32>)
|
||||
.collect::<std::result::Result<Vec<_>, _>>()
|
||||
.ok()?;
|
||||
(parts.len() >= 2).then_some(parts)
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
fn windows_package_version(package_name: &str) -> Option<(Vec<u32>, String)> {
|
||||
for identity in WINDOWS_CODEX_PACKAGE_IDENTITIES {
|
||||
let prefix_len = identity.len();
|
||||
if !package_name
|
||||
.get(..prefix_len)
|
||||
.is_some_and(|prefix| prefix.eq_ignore_ascii_case(identity))
|
||||
|| package_name.as_bytes().get(prefix_len) != Some(&b'_')
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let version = package_name.get(prefix_len + 1..)?.split('_').next()?;
|
||||
return Some((numeric_version(version)?, version.to_string()));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
fn latest_windows_package_version<'a>(
|
||||
package_names: impl IntoIterator<Item = &'a str>,
|
||||
) -> Option<String> {
|
||||
package_names
|
||||
.into_iter()
|
||||
.filter_map(windows_package_version)
|
||||
.max_by(|left, right| left.0.cmp(&right.0))
|
||||
.map(|(_, version)| version)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn windows_store_app_version_from_roots(
|
||||
roots: &[PathBuf],
|
||||
deadline: Option<Instant>,
|
||||
) -> Option<String> {
|
||||
let mut package_names = Vec::new();
|
||||
for root in roots {
|
||||
if deadline_expired(deadline) {
|
||||
break;
|
||||
}
|
||||
let Ok(entries) = fs::read_dir(root) else {
|
||||
continue;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
if deadline_expired(deadline) {
|
||||
break;
|
||||
}
|
||||
if entry.path().is_dir() {
|
||||
package_names.push(entry.file_name().to_string_lossy().to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
latest_windows_package_version(package_names.iter().map(String::as_str))
|
||||
.map(|version| format!("Codex app {version}"))
|
||||
}
|
||||
|
||||
fn visit_named_files<F>(
|
||||
root: &Path,
|
||||
names: &[&str],
|
||||
depth: usize,
|
||||
deadline: Option<Instant>,
|
||||
visit: &mut F,
|
||||
) -> bool
|
||||
where
|
||||
F: FnMut(PathBuf) -> bool,
|
||||
{
|
||||
if depth == 0 || deadline_expired(deadline) {
|
||||
return !deadline_expired(deadline);
|
||||
}
|
||||
if !root.is_dir() {
|
||||
return !deadline_expired(deadline);
|
||||
}
|
||||
let Ok(entries) = fs::read_dir(root) else {
|
||||
return;
|
||||
return !deadline_expired(deadline);
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
if deadline_expired(deadline) {
|
||||
return false;
|
||||
}
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
collect_named_files(&path, names, depth - 1, output);
|
||||
if !visit_named_files(&path, names, depth - 1, deadline, visit) {
|
||||
return false;
|
||||
}
|
||||
} else if path.is_file()
|
||||
&& path
|
||||
.file_name()
|
||||
@@ -118,117 +344,176 @@ fn collect_named_files(root: &Path, names: &[&str], depth: usize, output: &mut V
|
||||
.iter()
|
||||
.any(|candidate| name.eq_ignore_ascii_case(candidate))
|
||||
})
|
||||
&& !visit(path)
|
||||
{
|
||||
output.push(path);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
!deadline_expired(deadline)
|
||||
}
|
||||
|
||||
fn extension_codex_candidates(home: &Path) -> Vec<PathBuf> {
|
||||
fn visit_extension_codex_candidates<F>(
|
||||
home: &Path,
|
||||
deadline: Option<Instant>,
|
||||
visit: &mut F,
|
||||
) -> bool
|
||||
where
|
||||
F: FnMut(PathBuf) -> bool,
|
||||
{
|
||||
let roots = [
|
||||
home.join(".cursor").join("extensions"),
|
||||
home.join(".vscode").join("extensions"),
|
||||
home.join(".vscode-insiders").join("extensions"),
|
||||
home.join(".windsurf").join("extensions"),
|
||||
];
|
||||
let mut candidates = Vec::new();
|
||||
for root in roots {
|
||||
if deadline_expired(deadline) {
|
||||
return false;
|
||||
}
|
||||
let Ok(entries) = fs::read_dir(root) else {
|
||||
continue;
|
||||
};
|
||||
let mut extension_dirs = entries
|
||||
.flatten()
|
||||
.map(|entry| entry.path())
|
||||
.filter(|path| {
|
||||
path.is_dir()
|
||||
&& path
|
||||
.file_name()
|
||||
.and_then(|value| value.to_str())
|
||||
.is_some_and(|name| {
|
||||
let lower = name.to_ascii_lowercase();
|
||||
lower.starts_with("openai.chatgpt-")
|
||||
|| lower.starts_with("openai.codex-")
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let mut extension_dirs = Vec::new();
|
||||
for entry in entries.flatten() {
|
||||
if deadline_expired(deadline) {
|
||||
break;
|
||||
}
|
||||
let path = entry.path();
|
||||
if path.is_dir()
|
||||
&& path
|
||||
.file_name()
|
||||
.and_then(|value| value.to_str())
|
||||
.is_some_and(|name| {
|
||||
let lower = name.to_ascii_lowercase();
|
||||
lower.starts_with("openai.chatgpt-") || lower.starts_with("openai.codex-")
|
||||
})
|
||||
{
|
||||
extension_dirs.push(path);
|
||||
}
|
||||
}
|
||||
if deadline_expired(deadline) {
|
||||
return false;
|
||||
}
|
||||
extension_dirs.sort_by(|a, b| b.file_name().cmp(&a.file_name()));
|
||||
for extension_dir in extension_dirs {
|
||||
collect_named_files(
|
||||
if deadline_expired(deadline) {
|
||||
return false;
|
||||
}
|
||||
if !visit_named_files(
|
||||
&extension_dir,
|
||||
&["codex", "codex.exe", "codex.cmd"],
|
||||
5,
|
||||
&mut candidates,
|
||||
);
|
||||
deadline,
|
||||
visit,
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
candidates
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn platform_candidates(home: &Path) -> Vec<PathBuf> {
|
||||
let mut candidates = vec![
|
||||
fn visit_platform_candidates<F>(home: &Path, deadline: Option<Instant>, visit: &mut F) -> bool
|
||||
where
|
||||
F: FnMut(PathBuf) -> bool,
|
||||
{
|
||||
let candidates = [
|
||||
PathBuf::from("/Applications/ChatGPT.app/Contents/Resources/codex"),
|
||||
home.join("Applications/ChatGPT.app/Contents/Resources/codex"),
|
||||
PathBuf::from("/Applications/Codex.app/Contents/Resources/codex"),
|
||||
home.join("Applications/Codex.app/Contents/Resources/codex"),
|
||||
PathBuf::from("/Applications/OpenAI Codex.app/Contents/Resources/codex"),
|
||||
home.join("Applications/OpenAI Codex.app/Contents/Resources/codex"),
|
||||
PathBuf::from("/Applications/OpenAI.Codex.app/Contents/Resources/codex"),
|
||||
home.join("Applications/OpenAI.Codex.app/Contents/Resources/codex"),
|
||||
PathBuf::from("/Applications/ChatGPT Codex.app/Contents/Resources/codex"),
|
||||
home.join("Applications/ChatGPT Codex.app/Contents/Resources/codex"),
|
||||
PathBuf::from("/opt/homebrew/bin/codex"),
|
||||
PathBuf::from("/usr/local/bin/codex"),
|
||||
home.join(".local/bin/codex"),
|
||||
home.join(".npm-global/bin/codex"),
|
||||
home.join("Library/pnpm/codex"),
|
||||
];
|
||||
candidates.extend(extension_codex_candidates(home));
|
||||
candidates
|
||||
for candidate in candidates {
|
||||
if deadline_expired(deadline) || !visit(candidate) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
visit_extension_codex_candidates(home, deadline, visit)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn platform_candidates(home: &Path) -> Vec<PathBuf> {
|
||||
let mut candidates = Vec::new();
|
||||
fn visit_platform_candidates<F>(home: &Path, deadline: Option<Instant>, visit: &mut F) -> bool
|
||||
where
|
||||
F: FnMut(PathBuf) -> bool,
|
||||
{
|
||||
if let Ok(appdata) = env::var("APPDATA") {
|
||||
let appdata = PathBuf::from(appdata);
|
||||
candidates.push(appdata.join("npm").join("codex.cmd"));
|
||||
candidates.push(appdata.join("npm").join("codex.exe"));
|
||||
for candidate in [
|
||||
appdata.join("npm").join("codex.cmd"),
|
||||
appdata.join("npm").join("codex.exe"),
|
||||
] {
|
||||
if deadline_expired(deadline) || !visit(candidate) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
for target in ["x86_64-pc-windows-msvc", "aarch64-pc-windows-msvc"] {
|
||||
candidates.push(
|
||||
appdata
|
||||
.join("npm/node_modules/@openai/codex/vendor")
|
||||
.join(target)
|
||||
.join("codex/codex.exe"),
|
||||
);
|
||||
let candidate = appdata
|
||||
.join("npm/node_modules/@openai/codex/vendor")
|
||||
.join(target)
|
||||
.join("codex/codex.exe");
|
||||
if deadline_expired(deadline) || !visit(candidate) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Ok(localappdata) = env::var("LOCALAPPDATA") {
|
||||
let localappdata = PathBuf::from(localappdata);
|
||||
candidates.push(localappdata.join("Microsoft/WindowsApps/codex.exe"));
|
||||
candidates.push(localappdata.join("Microsoft/WindowsApps/codex.cmd"));
|
||||
for candidate in [
|
||||
localappdata.join("Microsoft/WindowsApps/codex.exe"),
|
||||
localappdata.join("Microsoft/WindowsApps/codex.cmd"),
|
||||
] {
|
||||
if deadline_expired(deadline) || !visit(candidate) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
for root in [
|
||||
localappdata.join("Programs/ChatGPT"),
|
||||
localappdata.join("Programs/Codex"),
|
||||
localappdata.join("Programs/OpenAI/Codex"),
|
||||
localappdata.join("OpenAI/ChatGPT"),
|
||||
localappdata.join("OpenAI/Codex"),
|
||||
] {
|
||||
collect_named_files(&root, &["codex.exe", "codex.cmd"], 7, &mut candidates);
|
||||
if !visit_named_files(&root, &["codex.exe", "codex.cmd"], 7, deadline, visit) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
for variable in ["ProgramFiles", "ProgramFiles(x86)"] {
|
||||
if let Ok(program_files) = env::var(variable) {
|
||||
for app in ["ChatGPT", "Codex"] {
|
||||
collect_named_files(
|
||||
if !visit_named_files(
|
||||
&PathBuf::from(&program_files).join(app),
|
||||
&["codex.exe", "codex.cmd"],
|
||||
7,
|
||||
&mut candidates,
|
||||
);
|
||||
deadline,
|
||||
visit,
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
candidates.extend(extension_codex_candidates(home));
|
||||
candidates
|
||||
visit_extension_codex_candidates(home, deadline, visit)
|
||||
}
|
||||
|
||||
#[cfg(all(not(target_os = "macos"), not(target_os = "windows")))]
|
||||
fn platform_candidates(home: &Path) -> Vec<PathBuf> {
|
||||
let mut candidates = vec![
|
||||
fn visit_platform_candidates<F>(home: &Path, deadline: Option<Instant>, visit: &mut F) -> bool
|
||||
where
|
||||
F: FnMut(PathBuf) -> bool,
|
||||
{
|
||||
let candidates = [
|
||||
PathBuf::from("/usr/local/bin/codex"),
|
||||
PathBuf::from("/usr/bin/codex"),
|
||||
PathBuf::from("/snap/bin/codex"),
|
||||
@@ -236,21 +521,17 @@ fn platform_candidates(home: &Path) -> Vec<PathBuf> {
|
||||
home.join(".npm-global/bin/codex"),
|
||||
home.join(".local/share/pnpm/codex"),
|
||||
];
|
||||
candidates.extend(extension_codex_candidates(home));
|
||||
candidates
|
||||
for candidate in candidates {
|
||||
if deadline_expired(deadline) || !visit(candidate) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
visit_extension_codex_candidates(home, deadline, visit)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn windows_where_candidates() -> Vec<PathBuf> {
|
||||
use std::os::windows::process::CommandExt;
|
||||
|
||||
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||
let mut command = Command::new("where.exe");
|
||||
let Ok(output) = command
|
||||
.creation_flags(CREATE_NO_WINDOW)
|
||||
.arg("codex")
|
||||
.output()
|
||||
else {
|
||||
fn windows_where_candidates(deadline: Option<Instant>) -> Vec<PathBuf> {
|
||||
let Some(output) = run_program(Path::new("where.exe"), &["codex"], deadline) else {
|
||||
return Vec::new();
|
||||
};
|
||||
if !output.status.success() {
|
||||
@@ -265,75 +546,273 @@ fn windows_where_candidates() -> Vec<PathBuf> {
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
fn windows_where_candidates() -> Vec<PathBuf> {
|
||||
fn windows_where_candidates(_deadline: Option<Instant>) -> Vec<PathBuf> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn macos_app_version() -> Option<String> {
|
||||
for app in [
|
||||
"/Applications/ChatGPT.app",
|
||||
"/Applications/Codex.app",
|
||||
"/Applications/OpenAI Codex.app",
|
||||
"/Applications/ChatGPT Codex.app",
|
||||
] {
|
||||
let Some(output) =
|
||||
run_program(Path::new("mdls"), &["-name", "kMDItemVersion", "-raw", app])
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if !output.status.success() {
|
||||
continue;
|
||||
}
|
||||
let version = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
if !version.is_empty() && version != "(null)" {
|
||||
let app_name = if app.ends_with("ChatGPT.app") {
|
||||
fn macos_app_version(deadline: Option<Instant>) -> Option<String> {
|
||||
let home = dirs::home_dir().unwrap_or_default();
|
||||
for root in [PathBuf::from("/Applications"), home.join("Applications")] {
|
||||
for name in [
|
||||
"Codex.app",
|
||||
"OpenAI Codex.app",
|
||||
"OpenAI.Codex.app",
|
||||
"ChatGPT Codex.app",
|
||||
"ChatGPT.app",
|
||||
] {
|
||||
if deadline_expired(deadline) {
|
||||
return None;
|
||||
}
|
||||
let app = root.join(name);
|
||||
if !app.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let app_name = if name == "ChatGPT.app" {
|
||||
"ChatGPT app"
|
||||
} else {
|
||||
"Codex app"
|
||||
};
|
||||
return Some(format!("{app_name} {version}"));
|
||||
if let Some(version) = macos_info_plist_version(&app).or_else(|| {
|
||||
let app = app.to_str()?;
|
||||
let output = run_program(
|
||||
Path::new("mdls"),
|
||||
&["-name", "kMDItemVersion", "-raw", app],
|
||||
deadline,
|
||||
)?;
|
||||
output
|
||||
.status
|
||||
.success()
|
||||
.then(|| String::from_utf8_lossy(&output.stdout).trim().to_string())
|
||||
}) {
|
||||
if !version.is_empty() && version != "(null)" {
|
||||
return Some(format!("{app_name} {version}"));
|
||||
}
|
||||
}
|
||||
return Some(format!("{app_name} installed"));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn macos_info_plist_version(app: &Path) -> Option<String> {
|
||||
let plist = fs::read_to_string(app.join("Contents/Info.plist")).ok()?;
|
||||
plist_string_value(&plist, "CFBundleShortVersionString")
|
||||
.or_else(|| plist_string_value(&plist, "CFBundleVersion"))
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "macos", test))]
|
||||
fn plist_string_value(plist: &str, key: &str) -> Option<String> {
|
||||
let (_, after_key) = plist.split_once(&format!("<key>{key}</key>"))?;
|
||||
let (_, after_open) = after_key.split_once("<string>")?;
|
||||
let (value, _) = after_open.split_once("</string>")?;
|
||||
let value = value.trim();
|
||||
(!value.is_empty()).then(|| value.to_string())
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
fn macos_app_version() -> Option<String> {
|
||||
fn macos_app_version(_deadline: Option<Instant>) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
pub fn codex_executable_candidates() -> Vec<PathBuf> {
|
||||
let home = dirs::home_dir().unwrap_or_default();
|
||||
#[cfg(target_os = "windows")]
|
||||
fn windows_app_version(deadline: Option<Instant>) -> Option<String> {
|
||||
let mut roots = Vec::new();
|
||||
for variable in ["ProgramFiles", "ProgramW6432"] {
|
||||
if let Ok(program_files) = env::var(variable) {
|
||||
roots.push(PathBuf::from(program_files).join("WindowsApps"));
|
||||
}
|
||||
}
|
||||
roots.push(PathBuf::from(r"C:\Program Files\WindowsApps"));
|
||||
roots.sort();
|
||||
roots.dedup();
|
||||
if let Some(version) = windows_store_app_version_from_roots(&roots, deadline) {
|
||||
return Some(version);
|
||||
}
|
||||
|
||||
if deadline_expired(deadline) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let script = "Get-AppxPackage | Where-Object { $_.Name -in @('OpenAI.Codex','OpenAI.CodexBeta','OpenAI.ChatGPT-Desktop') } | ForEach-Object { $_.Version.ToString() }";
|
||||
if let Some(output) = run_program(
|
||||
Path::new("powershell.exe"),
|
||||
&["-NoProfile", "-NonInteractive", "-Command", script],
|
||||
deadline,
|
||||
) {
|
||||
if output.status.success() {
|
||||
let versions = String::from_utf8_lossy(&output.stdout)
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.filter(|line| !line.is_empty())
|
||||
.filter_map(|version| {
|
||||
numeric_version(version).map(|parsed| (parsed, version.to_string()))
|
||||
})
|
||||
.max_by(|left, right| left.0.cmp(&right.0));
|
||||
if let Some((_, version)) = versions {
|
||||
return Some(format!("Codex app {version}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let local_appdata = env::var("LOCALAPPDATA").ok().map(PathBuf::from)?;
|
||||
for directory in [
|
||||
local_appdata.join("OpenAI/Codex/bin"),
|
||||
local_appdata.join("OpenAI/Codex"),
|
||||
local_appdata.join("Programs/OpenAI/Codex"),
|
||||
local_appdata.join("Programs/Codex"),
|
||||
] {
|
||||
if deadline_expired(deadline) {
|
||||
return None;
|
||||
}
|
||||
if WINDOWS_CODEX_EXECUTABLES.iter().any(|name| {
|
||||
directory.join(name).is_file() || directory.join("app").join(name).is_file()
|
||||
}) {
|
||||
return Some("Codex app installed".to_string());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
fn windows_app_version(_deadline: Option<Instant>) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
fn path_codex_candidates(deadline: Option<Instant>) -> Vec<PathBuf> {
|
||||
let mut candidates = ["codex", "codex.exe", "codex.cmd"]
|
||||
.into_iter()
|
||||
.map(PathBuf::from)
|
||||
.collect::<Vec<_>>();
|
||||
candidates.extend(windows_where_candidates());
|
||||
candidates.extend(platform_candidates(&home));
|
||||
if !deadline_expired(deadline) {
|
||||
candidates.extend(windows_where_candidates(deadline));
|
||||
}
|
||||
candidates
|
||||
}
|
||||
|
||||
fn append_unique_candidates(
|
||||
unique: &mut Vec<PathBuf>,
|
||||
seen: &mut HashSet<String>,
|
||||
candidates: impl IntoIterator<Item = PathBuf>,
|
||||
) {
|
||||
for candidate in candidates {
|
||||
push_candidate(unique, seen, candidate);
|
||||
}
|
||||
}
|
||||
|
||||
fn codex_executable_candidates_until(deadline: Option<Instant>) -> Vec<PathBuf> {
|
||||
let home = dirs::home_dir().unwrap_or_default();
|
||||
let mut seen = HashSet::new();
|
||||
let mut unique = Vec::new();
|
||||
for candidate in candidates {
|
||||
push_candidate(&mut unique, &mut seen, candidate);
|
||||
append_unique_candidates(&mut unique, &mut seen, path_codex_candidates(deadline));
|
||||
if !deadline_expired(deadline) {
|
||||
let mut collect = |candidate| {
|
||||
if deadline_expired(deadline) {
|
||||
return false;
|
||||
}
|
||||
push_candidate(&mut unique, &mut seen, candidate);
|
||||
true
|
||||
};
|
||||
let _ = visit_platform_candidates(&home, deadline, &mut collect);
|
||||
}
|
||||
unique
|
||||
}
|
||||
|
||||
pub fn detect_codex_version() -> Option<String> {
|
||||
for candidate in codex_executable_candidates() {
|
||||
pub fn codex_executable_candidates() -> Vec<PathBuf> {
|
||||
codex_executable_candidates_until(None)
|
||||
}
|
||||
|
||||
fn version_from_candidates(
|
||||
candidates: impl IntoIterator<Item = PathBuf>,
|
||||
seen: &mut HashSet<String>,
|
||||
deadline: Instant,
|
||||
) -> Option<String> {
|
||||
for candidate in candidates {
|
||||
if deadline_expired(Some(deadline)) {
|
||||
return None;
|
||||
}
|
||||
if !seen.insert(candidate_key(&candidate)) {
|
||||
continue;
|
||||
}
|
||||
let is_bare_command = candidate.components().count() == 1;
|
||||
if is_bare_command || candidate.is_file() {
|
||||
if let Some(version) = command_version(&candidate) {
|
||||
if let Some(version) = command_version(&candidate, Some(deadline)) {
|
||||
return Some(version);
|
||||
}
|
||||
}
|
||||
}
|
||||
macos_app_version()
|
||||
None
|
||||
}
|
||||
|
||||
fn version_from_platform_candidates(
|
||||
home: &Path,
|
||||
seen: &mut HashSet<String>,
|
||||
deadline: Instant,
|
||||
) -> Option<String> {
|
||||
let mut detected = None;
|
||||
let mut probe = |candidate| {
|
||||
if deadline_expired(Some(deadline)) {
|
||||
return false;
|
||||
}
|
||||
detected = version_from_candidates([candidate], seen, deadline);
|
||||
detected.is_none() && !deadline_expired(Some(deadline))
|
||||
};
|
||||
let _ = visit_platform_candidates(home, Some(deadline), &mut probe);
|
||||
detected
|
||||
}
|
||||
|
||||
fn detect_codex_version_uncached() -> Option<String> {
|
||||
let deadline = Instant::now().checked_add(CODEX_VERSION_PROBE_TIMEOUT)?;
|
||||
let mut seen = HashSet::new();
|
||||
|
||||
// Probe cheap PATH/where.exe results before walking redirected profiles or slow disks.
|
||||
if let Some(version) =
|
||||
version_from_candidates(path_codex_candidates(Some(deadline)), &mut seen, deadline)
|
||||
{
|
||||
return Some(version);
|
||||
}
|
||||
if deadline_expired(Some(deadline)) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let home = dirs::home_dir().unwrap_or_default();
|
||||
if let Some(version) = version_from_platform_candidates(&home, &mut seen, deadline) {
|
||||
return Some(version);
|
||||
}
|
||||
if deadline_expired(Some(deadline)) {
|
||||
return None;
|
||||
}
|
||||
macos_app_version(Some(deadline)).or_else(|| windows_app_version(Some(deadline)))
|
||||
}
|
||||
|
||||
fn cached_codex_version(
|
||||
cache: &OnceLock<String>,
|
||||
detect: impl FnOnce() -> Option<String>,
|
||||
) -> Option<String> {
|
||||
if let Some(version) = cache.get() {
|
||||
return Some(version.clone());
|
||||
}
|
||||
let detected = detect()?;
|
||||
let _ = cache.set(detected.clone());
|
||||
cache.get().cloned().or(Some(detected))
|
||||
}
|
||||
|
||||
pub fn detect_codex_version() -> Option<String> {
|
||||
cached_codex_version(&CODEX_VERSION, detect_codex_version_uncached)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::version_line;
|
||||
use super::{
|
||||
cached_codex_version, latest_windows_package_version, plist_string_value, run_program,
|
||||
version_line, visit_named_files,
|
||||
};
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::OnceLock;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
#[test]
|
||||
fn version_parser_prefers_codex_line_over_warning() {
|
||||
@@ -363,4 +842,116 @@ mod tests {
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_version_cache_runs_probe_once() {
|
||||
let cache = OnceLock::new();
|
||||
let calls = AtomicUsize::new(0);
|
||||
|
||||
let first = cached_codex_version(&cache, || {
|
||||
calls.fetch_add(1, Ordering::Relaxed);
|
||||
Some("codex-cli 1.2.3".to_string())
|
||||
});
|
||||
let second = cached_codex_version(&cache, || {
|
||||
calls.fetch_add(1, Ordering::Relaxed);
|
||||
Some("codex-cli 9.9.9".to_string())
|
||||
});
|
||||
|
||||
assert_eq!(first.as_deref(), Some("codex-cli 1.2.3"));
|
||||
assert_eq!(second, first);
|
||||
assert_eq!(calls.load(Ordering::Relaxed), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_version_cache_retries_after_a_missing_result() {
|
||||
let cache = OnceLock::new();
|
||||
let calls = AtomicUsize::new(0);
|
||||
|
||||
assert_eq!(
|
||||
cached_codex_version(&cache, || {
|
||||
calls.fetch_add(1, Ordering::Relaxed);
|
||||
None
|
||||
}),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
cached_codex_version(&cache, || {
|
||||
calls.fetch_add(1, Ordering::Relaxed);
|
||||
Some("codex-cli 1.2.3".to_string())
|
||||
}),
|
||||
Some("codex-cli 1.2.3".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
cached_codex_version(&cache, || {
|
||||
calls.fetch_add(1, Ordering::Relaxed);
|
||||
Some("codex-cli 9.9.9".to_string())
|
||||
}),
|
||||
Some("codex-cli 1.2.3".to_string())
|
||||
);
|
||||
assert_eq!(calls.load(Ordering::Relaxed), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recursive_candidate_scan_stops_immediately_after_visitor_finishes() {
|
||||
static TEMP_SEQUENCE: AtomicUsize = AtomicUsize::new(0);
|
||||
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"codex-x-platform-test-{}-{}",
|
||||
std::process::id(),
|
||||
TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed)
|
||||
));
|
||||
fs::create_dir_all(root.join("first")).expect("create first candidate directory");
|
||||
fs::create_dir_all(root.join("second")).expect("create second candidate directory");
|
||||
fs::write(root.join("first/codex.exe"), b"").expect("create first candidate");
|
||||
fs::write(root.join("second/codex.exe"), b"").expect("create second candidate");
|
||||
|
||||
let mut visits = 0;
|
||||
let completed = visit_named_files(&root, &["codex.exe"], 3, None, &mut |_| {
|
||||
visits += 1;
|
||||
false
|
||||
});
|
||||
fs::remove_dir_all(&root).expect("remove candidate test directory");
|
||||
|
||||
assert!(!completed);
|
||||
assert_eq!(visits, 1);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn program_runner_stops_hung_process_at_deadline() {
|
||||
let started = Instant::now();
|
||||
let deadline = started + Duration::from_millis(100);
|
||||
|
||||
assert!(run_program(
|
||||
Path::new("/bin/sh"),
|
||||
&["-c", "while :; do :; done"],
|
||||
Some(deadline),
|
||||
)
|
||||
.is_none());
|
||||
assert!(started.elapsed() < Duration::from_secs(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_package_detection_accepts_supported_codex_packages() {
|
||||
assert_eq!(
|
||||
latest_windows_package_version([
|
||||
"OpenAI.Codex_1.2.3.4_x64__publisher",
|
||||
"OpenAI.CodexBeta_1.3.0.0_x64__publisher",
|
||||
"Other.App_99.0.0.0_x64__publisher",
|
||||
]),
|
||||
Some("1.3.0.0".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plist_parser_reads_codex_bundle_version() {
|
||||
let plist = r#"<plist><dict>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.2026.204</string>
|
||||
</dict></plist>"#;
|
||||
assert_eq!(
|
||||
plist_string_value(plist, "CFBundleShortVersionString").as_deref(),
|
||||
Some("1.2026.204")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
use super::store::normalize_prompt_filename;
|
||||
use super::store::{
|
||||
builtin_prompt_override_ids_inner, builtin_prompt_override_inner, normalize_prompt_filename,
|
||||
};
|
||||
use super::types::{
|
||||
BuiltinPromptStatus, BundledPromptMeta, CachedBuiltinPrompt, GithubContentEntry,
|
||||
BuiltinPromptDetail, BuiltinPromptStatus, BundledPromptMeta, CachedBuiltinPrompt,
|
||||
GithubContentEntry,
|
||||
};
|
||||
use crate::constants::{
|
||||
GITHUB_EXAMPLES_API, GITHUB_EXAMPLES_BASE, INSTRUCTION_54_CONTENT, INSTRUCTION_54_FILENAME,
|
||||
@@ -140,6 +143,42 @@ pub(crate) fn stable_remote_prompt_id(filename: &str) -> String {
|
||||
}
|
||||
|
||||
fn prompt_display_meta(filename: &str) -> (String, String, String) {
|
||||
let curated = match filename.to_ascii_lowercase().as_str() {
|
||||
"software-development-maintainer.md" => Some((
|
||||
"长期维护工程师",
|
||||
"正式项目的最小改动、复用、测试与安全规范",
|
||||
"软件开发",
|
||||
)),
|
||||
"software-development-debugging.md" => Some((
|
||||
"系统化调试与根因修复",
|
||||
"从稳定复现、证据采集到最小修复与回归验证",
|
||||
"软件开发",
|
||||
)),
|
||||
"software-development-code-review.md" => Some((
|
||||
"严格代码审查",
|
||||
"按严重级别发现缺陷、回归、安全风险与测试缺口",
|
||||
"软件开发",
|
||||
)),
|
||||
"writing-clarity-editor.md" => Some((
|
||||
"清晰表达与润色",
|
||||
"保留原意与事实,优化中文或英文的结构、语气和可读性",
|
||||
"写作辅助",
|
||||
)),
|
||||
"writing-technical-docs.md" => Some((
|
||||
"技术文档写作",
|
||||
"基于代码和事实编写 README、指南、API 与变更文档",
|
||||
"写作辅助",
|
||||
)),
|
||||
"writing-structured-draft.md" => Some((
|
||||
"结构化长文起草",
|
||||
"把零散材料整理为提纲清晰、论证连贯的完整初稿",
|
||||
"写作辅助",
|
||||
)),
|
||||
_ => None,
|
||||
};
|
||||
if let Some((title, subtitle, badge)) = curated {
|
||||
return (title.to_string(), subtitle.to_string(), badge.to_string());
|
||||
}
|
||||
if let Some(meta) = bundled_prompt_metas()
|
||||
.into_iter()
|
||||
.find(|item| item.filename.eq_ignore_ascii_case(filename))
|
||||
@@ -455,6 +494,22 @@ fn fetch_github_prompt_catalog() -> Result<RemotePromptCatalog> {
|
||||
fetch_first_valid(&sources, parse_prompt_catalog)
|
||||
}
|
||||
|
||||
fn confirm_prompt_catalog_with<Fetch>(
|
||||
catalog: RemotePromptCatalog,
|
||||
fetch_authoritative: Fetch,
|
||||
) -> (RemotePromptCatalog, bool)
|
||||
where
|
||||
Fetch: FnOnce() -> Result<RemotePromptCatalog>,
|
||||
{
|
||||
if catalog.authoritative {
|
||||
return (catalog, false);
|
||||
}
|
||||
match fetch_authoritative() {
|
||||
Ok(authoritative) => (authoritative, false),
|
||||
Err(_) => (catalog, true),
|
||||
}
|
||||
}
|
||||
|
||||
fn prompt_status_from_cache(cache: CachedBuiltinPrompt, message: &str) -> BuiltinPromptStatus {
|
||||
let (title, subtitle, badge) = prompt_display_meta(&cache.filename);
|
||||
BuiltinPromptStatus {
|
||||
@@ -470,6 +525,7 @@ fn prompt_status_from_cache(cache: CachedBuiltinPrompt, message: &str) -> Builti
|
||||
sync_issue: None,
|
||||
checked_at: Some(cache.checked_at),
|
||||
message: message.to_string(),
|
||||
customized: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -526,6 +582,7 @@ fn refresh_builtin_prompt_from_source(
|
||||
"已是最新在线模板"
|
||||
}
|
||||
.to_string(),
|
||||
customized: false,
|
||||
})
|
||||
}
|
||||
Err(_) => {
|
||||
@@ -557,6 +614,7 @@ fn refresh_builtin_prompt_from_source(
|
||||
"在线模板暂时不可用,且没有本地副本"
|
||||
}
|
||||
.to_string(),
|
||||
customized: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -576,9 +634,54 @@ fn bundled_prompt_status(meta: BundledPromptMeta, message: &str) -> BuiltinPromp
|
||||
sync_issue: None,
|
||||
checked_at: None,
|
||||
message: message.to_string(),
|
||||
customized: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn customized_prompt_status(id: &str, filename: &str) -> Result<BuiltinPromptStatus> {
|
||||
let mut status = if let Some(cache) = cached_builtin_prompt(id)? {
|
||||
prompt_status_from_cache(cache, "本地已修改,GitHub 同步已跳过")
|
||||
} else if let Some(meta) = bundled_prompt_meta(id) {
|
||||
bundled_prompt_status(meta, "本地已修改,GitHub 同步已跳过")
|
||||
} else {
|
||||
let (title, subtitle, badge) = prompt_display_meta(filename);
|
||||
BuiltinPromptStatus {
|
||||
id: id.to_string(),
|
||||
filename: filename.to_string(),
|
||||
title,
|
||||
subtitle,
|
||||
badge,
|
||||
source_url: builtin_prompt_source_url(filename),
|
||||
cached: false,
|
||||
updated: false,
|
||||
content_source: "local".to_string(),
|
||||
sync_issue: None,
|
||||
checked_at: None,
|
||||
message: "本地已修改,GitHub 同步已跳过".to_string(),
|
||||
customized: true,
|
||||
}
|
||||
};
|
||||
status.customized = true;
|
||||
status.message = "本地已修改,GitHub 同步已跳过".to_string();
|
||||
Ok(status)
|
||||
}
|
||||
|
||||
fn mark_customized_prompt_statuses(
|
||||
statuses: &mut [BuiltinPromptStatus],
|
||||
customized_ids: &HashSet<String>,
|
||||
) {
|
||||
for status in statuses {
|
||||
if customized_ids.contains(&status.id) {
|
||||
status.customized = true;
|
||||
status.message = "本地已修改,GitHub 同步已跳过".to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn should_sync_prompt_content(id: &str, customized_ids: &HashSet<String>) -> bool {
|
||||
!customized_ids.contains(id)
|
||||
}
|
||||
|
||||
pub(crate) fn cached_prompt_fallback_statuses(
|
||||
caches: Vec<CachedBuiltinPrompt>,
|
||||
) -> Vec<BuiltinPromptStatus> {
|
||||
@@ -621,7 +724,10 @@ pub(crate) fn cached_prompt_fallback_statuses(
|
||||
}
|
||||
|
||||
pub(crate) fn builtin_prompt_status_inner() -> Result<Vec<BuiltinPromptStatus>> {
|
||||
Ok(cached_prompt_fallback_statuses(cached_builtin_prompts()?))
|
||||
let customized_ids = builtin_prompt_override_ids_inner()?;
|
||||
let mut statuses = cached_prompt_fallback_statuses(cached_builtin_prompts()?);
|
||||
mark_customized_prompt_statuses(&mut statuses, &customized_ids);
|
||||
Ok(statuses)
|
||||
}
|
||||
|
||||
pub(crate) fn refresh_builtin_prompts_with_active(
|
||||
@@ -630,38 +736,26 @@ pub(crate) fn refresh_builtin_prompts_with_active(
|
||||
let _cache_guard = BUILTIN_PROMPT_CACHE_LOCK
|
||||
.lock()
|
||||
.map_err(|_| CodexxError::Database("提示词缓存锁已损坏".to_string()))?;
|
||||
let mut catalog = match fetch_prompt_catalog() {
|
||||
let customized_ids = builtin_prompt_override_ids_inner()?;
|
||||
let catalog = match fetch_prompt_catalog() {
|
||||
Ok(catalog) => catalog,
|
||||
Err(_) => {
|
||||
let mut statuses = builtin_prompt_status_inner()?;
|
||||
for status in &mut statuses {
|
||||
status.sync_issue = Some("catalog".to_string());
|
||||
status.message = "在线模板目录暂时不可用,已保留本地内容".to_string();
|
||||
if !status.customized {
|
||||
status.message = "在线模板目录暂时不可用,已保留本地内容".to_string();
|
||||
}
|
||||
}
|
||||
return Ok(statuses);
|
||||
}
|
||||
};
|
||||
let cached_before = cached_builtin_prompts()?;
|
||||
let mut catalog_confirmation_failed = false;
|
||||
if !catalog.authoritative {
|
||||
let cdn_ids = catalog
|
||||
.prompts
|
||||
.iter()
|
||||
.map(|(id, _)| id.clone())
|
||||
.collect::<HashSet<_>>();
|
||||
let cache_may_be_newer = cached_before
|
||||
.iter()
|
||||
.any(|cache| !cdn_ids.contains(&cache.id));
|
||||
let bundled_missing = bundled_prompt_metas()
|
||||
.into_iter()
|
||||
.any(|meta| !cdn_ids.contains(meta.id));
|
||||
if cache_may_be_newer || bundled_missing {
|
||||
match fetch_github_prompt_catalog() {
|
||||
Ok(authoritative) => catalog = authoritative,
|
||||
Err(_) => catalog_confirmation_failed = true,
|
||||
}
|
||||
}
|
||||
}
|
||||
// A successful CDN response can still contain an old @main directory. Always
|
||||
// confirm directory membership with GitHub so newly added and removed files
|
||||
// become visible immediately; keep the CDN result as the offline fallback.
|
||||
let (catalog, catalog_confirmation_failed) =
|
||||
confirm_prompt_catalog_with(catalog, fetch_github_prompt_catalog);
|
||||
let remote_ids = catalog
|
||||
.prompts
|
||||
.iter()
|
||||
@@ -674,29 +768,46 @@ pub(crate) fn refresh_builtin_prompts_with_active(
|
||||
.collect::<HashSet<_>>();
|
||||
let mut statuses = Vec::new();
|
||||
for (id, filename) in catalog.prompts {
|
||||
let bundled = bundled_prompt_meta(&id).map(|meta| meta.content);
|
||||
statuses.push(refresh_builtin_prompt_from_source(&id, &filename, bundled)?);
|
||||
if !should_sync_prompt_content(&id, &customized_ids) {
|
||||
statuses.push(customized_prompt_status(&id, &filename)?);
|
||||
} else {
|
||||
let bundled = bundled_prompt_meta(&id).map(|meta| meta.content);
|
||||
statuses.push(refresh_builtin_prompt_from_source(&id, &filename, bundled)?);
|
||||
}
|
||||
}
|
||||
for meta in bundled_prompt_metas() {
|
||||
if !remote_ids.contains(meta.id) {
|
||||
statuses.push(bundled_prompt_status(
|
||||
meta,
|
||||
"在线目录暂未提供该模板,使用软件内置版本",
|
||||
));
|
||||
statuses.push(if customized_ids.contains(meta.id) {
|
||||
customized_prompt_status(meta.id, meta.filename)?
|
||||
} else {
|
||||
bundled_prompt_status(meta, "在线目录暂未提供该模板,使用软件内置版本")
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if catalog.authoritative {
|
||||
let mut retained_ids = remote_ids.clone();
|
||||
for customized_id in &customized_ids {
|
||||
retained_ids.insert(customized_id.clone());
|
||||
if !remote_ids.contains(customized_id)
|
||||
&& !statuses.iter().any(|status| status.id == *customized_id)
|
||||
{
|
||||
if let Some(cache) = cached_builtin_prompt(customized_id)? {
|
||||
statuses.push(customized_prompt_status(customized_id, &cache.filename)?);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(active_id) = active_remote_builtin_prompt_id() {
|
||||
if !remote_ids.contains(&active_id) {
|
||||
if let Some(cache) = cached_builtin_prompt(&active_id)? {
|
||||
let mut status = prompt_status_from_cache(
|
||||
cache,
|
||||
"该在线模板已下架,当前配置继续使用本地副本",
|
||||
);
|
||||
status.content_source = "removed".to_string();
|
||||
statuses.push(status);
|
||||
if !statuses.iter().any(|status| status.id == active_id) {
|
||||
let mut status = prompt_status_from_cache(
|
||||
cache,
|
||||
"该在线模板已下架,当前配置继续使用本地副本",
|
||||
);
|
||||
status.content_source = "removed".to_string();
|
||||
statuses.push(status);
|
||||
}
|
||||
}
|
||||
retained_ids.insert(active_id);
|
||||
}
|
||||
@@ -717,6 +828,7 @@ pub(crate) fn refresh_builtin_prompts_with_active(
|
||||
if catalog_confirmation_failed {
|
||||
mark_catalog_confirmation_failed(&mut statuses);
|
||||
}
|
||||
mark_customized_prompt_statuses(&mut statuses, &customized_ids);
|
||||
let order = bundled_prompt_metas()
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
@@ -752,6 +864,14 @@ pub(crate) fn builtin_prompt_content(
|
||||
.map(|item| item.filename.clone())
|
||||
.or_else(|| bundled.map(|item| item.filename.to_string()))
|
||||
.ok_or_else(|| CodexxError::Config(format!("提示词模板不存在或尚未同步: {id}")))?;
|
||||
if let Some(content) = builtin_prompt_override_inner(id)? {
|
||||
return Ok((
|
||||
filename.clone(),
|
||||
format!("./{filename}"),
|
||||
content,
|
||||
"本地已修改".to_string(),
|
||||
));
|
||||
}
|
||||
let trust = PromptContentTrust {
|
||||
cached: cached.as_ref().map(|item| item.content.as_str()),
|
||||
bundled: bundled.map(|item| item.content),
|
||||
@@ -784,6 +904,23 @@ pub(crate) fn builtin_prompt_content(
|
||||
))
|
||||
}
|
||||
|
||||
pub(crate) fn builtin_prompt_detail_inner(template_id: &str) -> Result<BuiltinPromptDetail> {
|
||||
let id = if template_id.trim().is_empty() {
|
||||
"gpt5.5-unrestricted"
|
||||
} else {
|
||||
template_id.trim()
|
||||
};
|
||||
let (filename, _, content, _) = builtin_prompt_content(id)?;
|
||||
let (title, _, _) = prompt_display_meta(&filename);
|
||||
Ok(BuiltinPromptDetail {
|
||||
id: id.to_string(),
|
||||
filename,
|
||||
title,
|
||||
content,
|
||||
customized: builtin_prompt_override_inner(id)?.is_some(),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -853,6 +990,110 @@ mod tests {
|
||||
std::fs::remove_file(database_path).expect("remove prompt cache database");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn curated_remote_prompts_have_user_facing_metadata() {
|
||||
assert_eq!(
|
||||
prompt_display_meta("software-development-maintainer.md"),
|
||||
(
|
||||
"长期维护工程师".to_string(),
|
||||
"正式项目的最小改动、复用、测试与安全规范".to_string(),
|
||||
"软件开发".to_string(),
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
prompt_display_meta("writing-technical-docs.md").2,
|
||||
"写作辅助"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn curated_remote_prompt_ids_retain_category_prefixes() {
|
||||
assert!(stable_remote_prompt_id("software-development-debugging.md")
|
||||
.starts_with("github-software-development-debugging-"));
|
||||
assert!(stable_remote_prompt_id("writing-clarity-editor.md")
|
||||
.starts_with("github-writing-clarity-editor-"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locally_modified_prompt_is_excluded_from_content_sync() {
|
||||
let customized_ids = HashSet::from(["edited-template".to_string()]);
|
||||
|
||||
assert!(!should_sync_prompt_content(
|
||||
"edited-template",
|
||||
&customized_ids
|
||||
));
|
||||
assert!(should_sync_prompt_content(
|
||||
"new-github-template",
|
||||
&customized_ids
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locally_modified_prompt_status_is_visible_after_restart() {
|
||||
let cache = CachedBuiltinPrompt {
|
||||
id: "edited-template".to_string(),
|
||||
filename: "edited-template.md".to_string(),
|
||||
source_url: "https://example.test/edited-template.md".to_string(),
|
||||
content: "upstream cache".to_string(),
|
||||
checked_at: "2026-07-28T00:00:00Z".to_string(),
|
||||
};
|
||||
let mut statuses = cached_prompt_fallback_statuses(vec![cache]);
|
||||
let customized_ids = HashSet::from(["edited-template".to_string()]);
|
||||
|
||||
mark_customized_prompt_statuses(&mut statuses, &customized_ids);
|
||||
|
||||
let status = statuses
|
||||
.iter()
|
||||
.find(|status| status.id == "edited-template")
|
||||
.expect("customized cached prompt remains visible");
|
||||
assert!(status.customized);
|
||||
assert!(status.message.contains("同步已跳过"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_cdn_catalog_is_always_replaced_by_github_catalog() {
|
||||
let cdn = RemotePromptCatalog {
|
||||
prompts: vec![("bundled".to_string(), "bundled.md".to_string())],
|
||||
authoritative: false,
|
||||
};
|
||||
let github = RemotePromptCatalog {
|
||||
prompts: vec![
|
||||
("bundled".to_string(), "bundled.md".to_string()),
|
||||
(
|
||||
"github-software-development-debugging-test".to_string(),
|
||||
"software-development-debugging.md".to_string(),
|
||||
),
|
||||
],
|
||||
authoritative: true,
|
||||
};
|
||||
let mut calls = 0;
|
||||
|
||||
let (confirmed, failed) = confirm_prompt_catalog_with(cdn, || {
|
||||
calls += 1;
|
||||
Ok(github)
|
||||
});
|
||||
|
||||
assert_eq!(calls, 1);
|
||||
assert!(!failed);
|
||||
assert!(confirmed.authoritative);
|
||||
assert_eq!(confirmed.prompts.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unavailable_github_catalog_keeps_cdn_fallback_for_retry() {
|
||||
let cdn = RemotePromptCatalog {
|
||||
prompts: vec![("bundled".to_string(), "bundled.md".to_string())],
|
||||
authoritative: false,
|
||||
};
|
||||
|
||||
let (fallback, failed) =
|
||||
confirm_prompt_catalog_with(cdn, || Err(CodexxError::Config("offline".to_string())));
|
||||
|
||||
assert!(failed);
|
||||
assert!(!fallback.authoritative);
|
||||
assert_eq!(fallback.prompts.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn syncing_remote_prompt_updates_the_existing_sqlite_row() {
|
||||
let conn = prompt_cache_connection();
|
||||
|
||||
@@ -2,8 +2,9 @@ use crate::constants::{
|
||||
AGENTS_FILENAME, AGENTS_MANAGED_BEGIN, AGENTS_MANAGED_END, AGENTS_TEMPLATE_PREFIX,
|
||||
};
|
||||
use crate::error::{CodexxError, Result};
|
||||
use crate::file_io::{io_err, read_to_string_if_exists, write_text};
|
||||
use std::fs;
|
||||
use crate::file_io::read_to_string_if_exists;
|
||||
#[cfg(test)]
|
||||
use crate::live_config::{apply_file_change, read_file_snapshot, text_from_snapshot};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub(crate) fn agents_path(codex_dir: &Path) -> PathBuf {
|
||||
@@ -31,7 +32,7 @@ pub(crate) fn managed_agents_bounds(content: &str) -> Result<Option<(usize, usiz
|
||||
Ok(Some((begins[0], ends[0] + AGENTS_MANAGED_END.len())))
|
||||
}
|
||||
|
||||
fn remove_managed_agents_block(content: &str) -> Result<(String, bool)> {
|
||||
pub(crate) fn remove_managed_agents_block_from_content(content: &str) -> Result<(String, bool)> {
|
||||
let Some((start, end)) = managed_agents_bounds(content)? else {
|
||||
return Ok((content.to_string(), false));
|
||||
};
|
||||
@@ -46,6 +47,23 @@ fn remove_managed_agents_block(content: &str) -> Result<(String, bool)> {
|
||||
Ok((merged, true))
|
||||
}
|
||||
|
||||
pub(crate) fn install_managed_agents_block_in_content(
|
||||
existing: &str,
|
||||
template_key: &str,
|
||||
content: &str,
|
||||
) -> Result<String> {
|
||||
let (base, _) = remove_managed_agents_block_from_content(existing)?;
|
||||
let managed = format!(
|
||||
"{AGENTS_MANAGED_BEGIN}\n{AGENTS_TEMPLATE_PREFIX} {template_key} -->\n{}\n{AGENTS_MANAGED_END}",
|
||||
content.trim()
|
||||
);
|
||||
Ok(if base.trim().is_empty() {
|
||||
format!("{managed}\n")
|
||||
} else {
|
||||
format!("{}\n\n{managed}\n", base.trim_end())
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn managed_agents_template_key_from_content(content: &str) -> Option<String> {
|
||||
let (start, end) = managed_agents_bounds(content).ok().flatten()?;
|
||||
content[start..end].lines().find_map(|line| {
|
||||
@@ -64,40 +82,33 @@ pub(crate) fn managed_agents_template_key(codex_dir: &Path) -> Result<Option<Str
|
||||
Ok(managed_agents_template_key_from_content(&content))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn install_managed_agents_block(
|
||||
codex_dir: &Path,
|
||||
template_key: &str,
|
||||
content: &str,
|
||||
) -> Result<()> {
|
||||
let path = agents_path(codex_dir);
|
||||
let existing = read_to_string_if_exists(&path)?;
|
||||
let (base, _) = remove_managed_agents_block(&existing)?;
|
||||
let managed = format!(
|
||||
"{AGENTS_MANAGED_BEGIN}\n{AGENTS_TEMPLATE_PREFIX} {template_key} -->\n{}\n{AGENTS_MANAGED_END}",
|
||||
content.trim()
|
||||
);
|
||||
let next = if base.trim().is_empty() {
|
||||
format!("{managed}\n")
|
||||
} else {
|
||||
format!("{}\n\n{managed}\n", base.trim_end())
|
||||
};
|
||||
write_text(&path, &next)
|
||||
let before = read_file_snapshot(&path)?;
|
||||
let existing = text_from_snapshot(&path, before.as_deref())?;
|
||||
let next = install_managed_agents_block_in_content(&existing, template_key, content)?;
|
||||
apply_file_change(&path, before, Some(next.into_bytes()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn uninstall_managed_agents_block(codex_dir: &Path) -> Result<bool> {
|
||||
let path = agents_path(codex_dir);
|
||||
if !path.exists() {
|
||||
let before = read_file_snapshot(&path)?;
|
||||
if before.is_none() {
|
||||
return Ok(false);
|
||||
}
|
||||
let existing = read_to_string_if_exists(&path)?;
|
||||
let (next, removed) = remove_managed_agents_block(&existing)?;
|
||||
let existing = text_from_snapshot(&path, before.as_deref())?;
|
||||
let (next, removed) = remove_managed_agents_block_from_content(&existing)?;
|
||||
if !removed {
|
||||
return Ok(false);
|
||||
}
|
||||
if next.trim().is_empty() {
|
||||
fs::remove_file(&path).map_err(|e| io_err(&path, e))?;
|
||||
} else {
|
||||
write_text(&path, &next)?;
|
||||
}
|
||||
let after = (!next.trim().is_empty()).then(|| next.into_bytes());
|
||||
apply_file_change(&path, before, after)?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
@@ -4,18 +4,20 @@ mod store;
|
||||
mod types;
|
||||
|
||||
pub(crate) use catalog::{
|
||||
builtin_prompt_content, builtin_prompt_status_inner, bundled_prompt_meta, bundled_prompt_metas,
|
||||
refresh_builtin_prompts_with_active,
|
||||
builtin_prompt_content, builtin_prompt_detail_inner, builtin_prompt_status_inner,
|
||||
bundled_prompt_meta, bundled_prompt_metas, refresh_builtin_prompts_with_active,
|
||||
};
|
||||
pub(crate) use managed_agents::{
|
||||
agents_path, install_managed_agents_block, managed_agents_bounds, managed_agents_template_key,
|
||||
uninstall_managed_agents_block,
|
||||
agents_path, install_managed_agents_block_in_content, managed_agents_bounds,
|
||||
managed_agents_template_key, remove_managed_agents_block_from_content,
|
||||
};
|
||||
#[cfg(test)]
|
||||
pub(crate) use managed_agents::{install_managed_agents_block, uninstall_managed_agents_block};
|
||||
pub(crate) use store::{
|
||||
delete_prompt_inner, get_saved_prompt_inner, list_saved_prompts_inner,
|
||||
normalize_prompt_filename, save_prompt_inner,
|
||||
normalize_prompt_filename, save_builtin_prompt_override_inner, save_prompt_inner,
|
||||
};
|
||||
pub(crate) use types::{BuiltinPromptStatus, SavedPrompt};
|
||||
pub(crate) use types::{BuiltinPromptDetail, BuiltinPromptStatus, SavedPrompt};
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) use catalog::{
|
||||
|
||||
@@ -2,6 +2,7 @@ use super::types::SavedPrompt;
|
||||
use crate::error::{CodexxError, Result};
|
||||
use crate::{now_rfc3339, open_db};
|
||||
use rusqlite::params;
|
||||
use std::collections::HashSet;
|
||||
|
||||
pub(crate) fn normalize_prompt_filename(input: &str, fallback: &str) -> String {
|
||||
let raw = input.trim().trim_end_matches(".md");
|
||||
@@ -107,6 +108,68 @@ pub(crate) fn delete_prompt_inner(id: &str) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn builtin_prompt_override_from_connection(
|
||||
conn: &rusqlite::Connection,
|
||||
template_id: &str,
|
||||
) -> Result<Option<String>> {
|
||||
match conn.query_row(
|
||||
"SELECT content FROM builtin_prompt_overrides WHERE template_id = ?1",
|
||||
[template_id],
|
||||
|row| row.get(0),
|
||||
) {
|
||||
Ok(content) => Ok(Some(content)),
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
|
||||
Err(e) => Err(CodexxError::Database(e.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn builtin_prompt_override_inner(template_id: &str) -> Result<Option<String>> {
|
||||
let conn = open_db()?;
|
||||
builtin_prompt_override_from_connection(&conn, template_id.trim())
|
||||
}
|
||||
|
||||
pub(crate) fn builtin_prompt_override_ids_inner() -> Result<HashSet<String>> {
|
||||
let conn = open_db()?;
|
||||
let mut stmt = conn
|
||||
.prepare("SELECT template_id FROM builtin_prompt_overrides")
|
||||
.map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
let rows = stmt
|
||||
.query_map([], |row| row.get(0))
|
||||
.map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
rows.map(|row| row.map_err(|e| CodexxError::Database(e.to_string())))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn save_builtin_prompt_override_on_connection(
|
||||
conn: &rusqlite::Connection,
|
||||
template_id: &str,
|
||||
content: &str,
|
||||
) -> Result<()> {
|
||||
let id = template_id.trim();
|
||||
if id.is_empty() {
|
||||
return Err(CodexxError::Config("提示词模板标识不能为空".to_string()));
|
||||
}
|
||||
if content.trim().is_empty() {
|
||||
return Err(CodexxError::Config("提示词内容不能为空".to_string()));
|
||||
}
|
||||
let now = now_rfc3339();
|
||||
conn.execute(
|
||||
"INSERT INTO builtin_prompt_overrides (template_id, content, created_at, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?3)
|
||||
ON CONFLICT(template_id) DO UPDATE SET
|
||||
content = excluded.content,
|
||||
updated_at = excluded.updated_at",
|
||||
params![id, content, now],
|
||||
)
|
||||
.map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn save_builtin_prompt_override_inner(template_id: &str, content: &str) -> Result<()> {
|
||||
let conn = open_db()?;
|
||||
save_builtin_prompt_override_on_connection(&conn, template_id, content)
|
||||
}
|
||||
|
||||
fn find_saved_prompt_by_content(content: &str) -> Result<Option<SavedPrompt>> {
|
||||
let conn = open_db()?;
|
||||
let mut stmt = conn
|
||||
@@ -167,3 +230,83 @@ pub(super) fn find_saved_prompt_by_current_file(
|
||||
Err(e) => Err(CodexxError::Database(e.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rusqlite::Connection;
|
||||
|
||||
fn override_connection() -> Connection {
|
||||
let conn = Connection::open_in_memory().expect("open override database");
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE builtin_prompt_overrides (
|
||||
template_id TEXT PRIMARY KEY,
|
||||
content TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);",
|
||||
)
|
||||
.expect("create override table");
|
||||
conn
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builtin_prompt_override_can_be_edited_repeatedly() {
|
||||
let conn = override_connection();
|
||||
save_builtin_prompt_override_on_connection(&conn, "template", "first")
|
||||
.expect("save initial override");
|
||||
save_builtin_prompt_override_on_connection(&conn, "template", "second")
|
||||
.expect("update override");
|
||||
|
||||
assert_eq!(
|
||||
builtin_prompt_override_from_connection(&conn, "template")
|
||||
.expect("read override")
|
||||
.as_deref(),
|
||||
Some("second")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builtin_prompt_override_rejects_empty_content() {
|
||||
let conn = override_connection();
|
||||
let error = save_builtin_prompt_override_on_connection(&conn, "template", " \n")
|
||||
.expect_err("reject empty override");
|
||||
assert!(error.to_string().contains("内容不能为空"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builtin_prompt_override_survives_database_reopen() {
|
||||
let database_path = std::env::temp_dir().join(format!(
|
||||
"codexx-prompt-override-{}-{}.sqlite",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.expect("system time")
|
||||
.as_nanos()
|
||||
));
|
||||
{
|
||||
let conn = Connection::open(&database_path).expect("open override database");
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE builtin_prompt_overrides (
|
||||
template_id TEXT PRIMARY KEY,
|
||||
content TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);",
|
||||
)
|
||||
.expect("create override table");
|
||||
save_builtin_prompt_override_on_connection(&conn, "template", "local content")
|
||||
.expect("save override");
|
||||
}
|
||||
|
||||
let reopened = Connection::open(&database_path).expect("reopen override database");
|
||||
assert_eq!(
|
||||
builtin_prompt_override_from_connection(&reopened, "template")
|
||||
.expect("read persisted override")
|
||||
.as_deref(),
|
||||
Some("local content")
|
||||
);
|
||||
drop(reopened);
|
||||
std::fs::remove_file(database_path).expect("remove override database");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,17 @@ pub(crate) struct BuiltinPromptStatus {
|
||||
pub(crate) sync_issue: Option<String>,
|
||||
pub(crate) checked_at: Option<String>,
|
||||
pub(crate) message: String,
|
||||
pub(crate) customized: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct BuiltinPromptDetail {
|
||||
pub(crate) id: String,
|
||||
pub(crate) filename: String,
|
||||
pub(crate) title: String,
|
||||
pub(crate) content: String,
|
||||
pub(crate) customized: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
use super::{
|
||||
custom_provider_id, experimental_bearer_token_from_doc, list_saved_providers_on_connection,
|
||||
normalize_saved_provider, open_store, upsert_provider_on_connection, ProviderUpsertKind,
|
||||
ProviderUpsertMode, SavedProvider,
|
||||
consolidate_legacy_provider_duplicates_on_connection, custom_provider_id,
|
||||
experimental_bearer_token_from_doc, list_saved_providers_on_connection,
|
||||
normalize_saved_provider, open_store, provider_template_from_document,
|
||||
upsert_ccswitch_provider_on_connection, ProviderUpsertKind, SavedProvider,
|
||||
};
|
||||
use crate::ccswitch::{ccswitch_db_candidates, default_ccswitch_db_path};
|
||||
use crate::error::{CodexxError, Result};
|
||||
use crate::sqlite_utils::table_column_set;
|
||||
use crate::string_value;
|
||||
use crate::toml_utils::ensure_table;
|
||||
use rusqlite::{Connection, OpenFlags, TransactionBehavior};
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use toml_edit::{DocumentMut, Table};
|
||||
use toml_edit::{value, DocumentMut, Item, Table};
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -30,6 +32,7 @@ pub(crate) struct ImportResult {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct OfficialAuthCandidate {
|
||||
auth_json: String,
|
||||
config_text: Option<String>,
|
||||
model: Option<String>,
|
||||
source: String,
|
||||
}
|
||||
@@ -91,6 +94,7 @@ pub(crate) struct CcSwitchCodexSection {
|
||||
pub(crate) wire_api: String,
|
||||
pub(crate) requires_openai_auth: bool,
|
||||
pub(crate) experimental_bearer_token: Option<String>,
|
||||
pub(crate) provider_table: Table,
|
||||
}
|
||||
|
||||
fn table_string(table: &Table, key: &str) -> Option<String> {
|
||||
@@ -134,6 +138,7 @@ pub(super) fn codex_section_from_table(
|
||||
.and_then(|item| item.as_bool())
|
||||
.unwrap_or(false),
|
||||
experimental_bearer_token: table_string(table, "experimental_bearer_token"),
|
||||
provider_table: table.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -160,79 +165,120 @@ fn select_ccswitch_section_for_row(
|
||||
global_sections: &HashMap<String, CcSwitchCodexSection>,
|
||||
) -> Option<CcSwitchCodexSection> {
|
||||
let provider_id = custom_provider_id(&row.id);
|
||||
if let Some(section) = global_sections.get(&provider_id) {
|
||||
return Some(section.clone());
|
||||
}
|
||||
if let Some(section) = global_sections.get(row.id.trim()) {
|
||||
return Some(section.clone());
|
||||
}
|
||||
|
||||
let config_text = settings.get("config").and_then(Value::as_str).unwrap_or("");
|
||||
let doc = config_text.parse::<DocumentMut>().ok()?;
|
||||
let model = string_value(&doc, "model");
|
||||
let active_provider = string_value(&doc, "model_provider");
|
||||
let providers = doc.get("model_providers").and_then(|item| item.as_table());
|
||||
let doc = config_text.parse::<DocumentMut>().ok();
|
||||
|
||||
if let Some(providers) = providers {
|
||||
for exact_id in [provider_id.as_str(), row.id.trim()] {
|
||||
if let Some(section) = providers
|
||||
.get(exact_id)
|
||||
.and_then(|item| item.as_table())
|
||||
.and_then(|table| codex_section_from_table(exact_id, table, model.clone()))
|
||||
{
|
||||
return Some(section);
|
||||
}
|
||||
}
|
||||
if let Some(doc) = doc.as_ref() {
|
||||
let model = string_value(doc, "model");
|
||||
let active_provider = string_value(doc, "model_provider");
|
||||
let providers = doc.get("model_providers").and_then(|item| item.as_table());
|
||||
|
||||
if active_provider.as_deref() == Some(row.id.trim())
|
||||
|| active_provider.as_deref() == Some(provider_id.as_str())
|
||||
{
|
||||
if let Some(active) = active_provider.as_deref() {
|
||||
if let Some(providers) = providers {
|
||||
for exact_id in [provider_id.as_str(), row.id.trim()] {
|
||||
if let Some(section) = providers
|
||||
.get(active)
|
||||
.get(exact_id)
|
||||
.and_then(|item| item.as_table())
|
||||
.and_then(|table| codex_section_from_table(active, table, model.clone()))
|
||||
.and_then(|table| codex_section_from_table(exact_id, table, model.clone()))
|
||||
{
|
||||
return Some(section);
|
||||
}
|
||||
}
|
||||
|
||||
if active_provider.as_deref() == Some(row.id.trim())
|
||||
|| active_provider.as_deref() == Some(provider_id.as_str())
|
||||
{
|
||||
if let Some(active) = active_provider.as_deref() {
|
||||
if let Some(section) = providers
|
||||
.get(active)
|
||||
.and_then(|item| item.as_table())
|
||||
.and_then(|table| codex_section_from_table(active, table, model.clone()))
|
||||
{
|
||||
return Some(section);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy cc-switch templates store each third-party provider under
|
||||
// `[model_providers.custom]` in that row's own complete config.
|
||||
if active_provider
|
||||
.as_deref()
|
||||
.is_none_or(|active| active == "custom")
|
||||
{
|
||||
if let Some(section) = providers
|
||||
.get("custom")
|
||||
.and_then(|item| item.as_table())
|
||||
.and_then(|table| codex_section_from_table("custom", table, model.clone()))
|
||||
{
|
||||
return Some(section);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy cc-switch/custom templates often store every third-party provider
|
||||
// under `[model_providers.custom]`. Only use it when the row's own config
|
||||
// explicitly activates custom or contains no other provider identity.
|
||||
if active_provider
|
||||
.as_deref()
|
||||
.is_none_or(|active| active == "custom")
|
||||
{
|
||||
if let Some(section) = providers
|
||||
.get("custom")
|
||||
.and_then(|item| item.as_table())
|
||||
.and_then(|table| codex_section_from_table("custom", table, model.clone()))
|
||||
{
|
||||
return Some(section);
|
||||
}
|
||||
for exact_id in [provider_id.as_str(), row.id.trim()] {
|
||||
if let Some(section) = global_sections.get(exact_id) {
|
||||
return Some(section.clone());
|
||||
}
|
||||
}
|
||||
|
||||
let doc = doc?;
|
||||
let active_provider = string_value(&doc, "model_provider");
|
||||
doc.get("base_url")
|
||||
.and_then(|item| item.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|base_url| CcSwitchCodexSection {
|
||||
id: provider_id,
|
||||
name: None,
|
||||
base_url: base_url.trim_end_matches('/').to_string(),
|
||||
model,
|
||||
wire_api: "responses".to_string(),
|
||||
requires_openai_auth: false,
|
||||
experimental_bearer_token: experimental_bearer_token_from_doc(
|
||||
&doc,
|
||||
active_provider.as_deref(),
|
||||
),
|
||||
.map(|base_url| {
|
||||
let base_url = base_url.trim_end_matches('/').to_string();
|
||||
let token = experimental_bearer_token_from_doc(&doc, active_provider.as_deref());
|
||||
let mut provider_table = Table::new();
|
||||
provider_table["base_url"] = value(base_url.clone());
|
||||
provider_table["wire_api"] = value("responses");
|
||||
provider_table["requires_openai_auth"] = value(false);
|
||||
if let Some(token) = token.as_deref() {
|
||||
provider_table["experimental_bearer_token"] = value(token);
|
||||
}
|
||||
CcSwitchCodexSection {
|
||||
id: provider_id,
|
||||
name: None,
|
||||
base_url,
|
||||
model: string_value(&doc, "model"),
|
||||
wire_api: "responses".to_string(),
|
||||
requires_openai_auth: false,
|
||||
experimental_bearer_token: token,
|
||||
provider_table,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn ccswitch_provider_template(
|
||||
settings: &Value,
|
||||
section: &CcSwitchCodexSection,
|
||||
provider_name: &str,
|
||||
model: &str,
|
||||
) -> Option<String> {
|
||||
let config_text = settings.get("config").and_then(Value::as_str).unwrap_or("");
|
||||
let mut doc = if config_text.trim().is_empty() {
|
||||
DocumentMut::new()
|
||||
} else {
|
||||
config_text.parse::<DocumentMut>().ok()?
|
||||
};
|
||||
let provider_id = section.id.trim();
|
||||
if provider_id.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
doc["model_provider"] = value(provider_id);
|
||||
doc["model"] = value(model);
|
||||
let providers = ensure_table(doc.as_table_mut(), "model_providers").ok()?;
|
||||
let mut table = section.provider_table.clone();
|
||||
table["name"] = value(provider_name);
|
||||
table["base_url"] = value(section.base_url.clone());
|
||||
table["wire_api"] = value(section.wire_api.clone());
|
||||
table["requires_openai_auth"] = value(section.requires_openai_auth);
|
||||
providers.insert(provider_id, Item::Table(table));
|
||||
provider_template_from_document(&doc, provider_id, model).ok()
|
||||
}
|
||||
|
||||
pub(crate) fn build_ccswitch_codex_provider(
|
||||
row: &CcSwitchCodexRow,
|
||||
global_sections: &HashMap<String, CcSwitchCodexSection>,
|
||||
@@ -240,17 +286,23 @@ pub(crate) fn build_ccswitch_codex_provider(
|
||||
let settings: Value = serde_json::from_str(&row.settings_config).ok()?;
|
||||
let section = select_ccswitch_section_for_row(row, &settings, global_sections)?;
|
||||
let api_key = ccswitch_auth_api_key(&settings).or(section.experimental_bearer_token.clone());
|
||||
let provider_name = if row.name.trim().is_empty() {
|
||||
section.name.clone().unwrap_or_else(|| row.id.clone())
|
||||
} else {
|
||||
row.name.trim().to_string()
|
||||
};
|
||||
let model = section
|
||||
.model
|
||||
.clone()
|
||||
.unwrap_or_else(|| "gpt-5.5".to_string());
|
||||
let toml_config = ccswitch_provider_template(&settings, §ion, &provider_name, &model)?;
|
||||
Some(SavedProvider {
|
||||
id: custom_provider_id(&row.id),
|
||||
provider_name: if row.name.trim().is_empty() {
|
||||
section.name.unwrap_or_else(|| row.id.clone())
|
||||
} else {
|
||||
row.name.trim().to_string()
|
||||
},
|
||||
provider_name,
|
||||
base_url: section.base_url,
|
||||
model: section.model.unwrap_or_else(|| "gpt-5.5".to_string()),
|
||||
model,
|
||||
api_key,
|
||||
toml_config: None,
|
||||
toml_config: Some(toml_config),
|
||||
wire_api: section.wire_api,
|
||||
requires_openai_auth: section.requires_openai_auth,
|
||||
})
|
||||
@@ -314,6 +366,7 @@ pub(crate) fn import_ccswitch_codex_providers_inner(path: Option<String>) -> Res
|
||||
let transaction = local_conn
|
||||
.transaction_with_behavior(TransactionBehavior::Immediate)
|
||||
.map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
merged += consolidate_legacy_provider_duplicates_on_connection(&transaction)?;
|
||||
|
||||
for row in rows_vec {
|
||||
if is_official_ccswitch_row(&row) {
|
||||
@@ -327,11 +380,8 @@ pub(crate) fn import_ccswitch_codex_providers_inner(path: Option<String>) -> Res
|
||||
match build_ccswitch_codex_provider(&row, &global_sections) {
|
||||
Some(provider) => {
|
||||
let provider = normalize_saved_provider(provider)?;
|
||||
let result = upsert_provider_on_connection(
|
||||
&transaction,
|
||||
provider,
|
||||
ProviderUpsertMode::Imported,
|
||||
)?;
|
||||
let result =
|
||||
upsert_ccswitch_provider_on_connection(&transaction, provider, row.id.trim())?;
|
||||
match result.kind {
|
||||
ProviderUpsertKind::Added => added += 1,
|
||||
ProviderUpsertKind::Updated => updated += 1,
|
||||
@@ -348,6 +398,7 @@ pub(crate) fn import_ccswitch_codex_providers_inner(path: Option<String>) -> Res
|
||||
}
|
||||
}
|
||||
}
|
||||
merged += consolidate_legacy_provider_duplicates_on_connection(&transaction)?;
|
||||
transaction
|
||||
.commit()
|
||||
.map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
@@ -385,13 +436,22 @@ pub(crate) fn read_ccswitch_official_auth_inner(
|
||||
CodexxError::Database(format!("打开 cc-switch 数据库失败 {}: {e}", db.display()))
|
||||
})?;
|
||||
|
||||
let provider_columns = table_column_set(&conn, "providers")?;
|
||||
let official_filter = if provider_columns.contains("category") {
|
||||
"id = 'codex-official' OR category = 'official'"
|
||||
} else {
|
||||
// Older cc-switch databases predate the category column. The stable
|
||||
// codex-official id is still enough to identify the official row.
|
||||
"id = 'codex-official'"
|
||||
};
|
||||
let query = format!(
|
||||
"SELECT id, name, settings_config FROM providers
|
||||
WHERE app_type = 'codex' AND ({official_filter})
|
||||
ORDER BY CASE WHEN id = 'codex-official' THEN 0 ELSE 1 END
|
||||
LIMIT 1"
|
||||
);
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT id, name, settings_config FROM providers
|
||||
WHERE app_type = 'codex' AND (id = 'codex-official' OR category = 'official')
|
||||
ORDER BY CASE WHEN id = 'codex-official' THEN 0 ELSE 1 END
|
||||
LIMIT 1",
|
||||
)
|
||||
.prepare(&query)
|
||||
.map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
|
||||
let mut rows = stmt
|
||||
@@ -426,9 +486,12 @@ pub(crate) fn read_ccswitch_official_auth_inner(
|
||||
CodexxError::Database("cc-switch official provider 缺少 auth object".to_string())
|
||||
})?;
|
||||
|
||||
let model = settings
|
||||
let config_text = settings
|
||||
.get("config")
|
||||
.and_then(Value::as_str)
|
||||
.map(ToString::to_string);
|
||||
let model = config_text
|
||||
.as_deref()
|
||||
.and_then(|text| text.parse::<DocumentMut>().ok())
|
||||
.and_then(|doc| string_value(&doc, "model"));
|
||||
|
||||
@@ -437,7 +500,172 @@ pub(crate) fn read_ccswitch_official_auth_inner(
|
||||
|
||||
Ok(Some(OfficialAuthCandidate {
|
||||
auth_json,
|
||||
config_text,
|
||||
model,
|
||||
source: format!("cc-switch:{name}:{id}"),
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn provider_import_round_trips_complete_config_without_bearer_tokens() {
|
||||
let settings_config = json!({
|
||||
"auth": {"OPENAI_API_KEY": "sk-from-auth"},
|
||||
"config": r#"# keep-imported-comment
|
||||
model_provider = "custom"
|
||||
model = "gpt-5.5"
|
||||
model_reasoning_effort = "xhigh"
|
||||
service_tier = "priority"
|
||||
experimental_bearer_token = "sk-top-level"
|
||||
notify = ["C:\\Users\\Thy\\codex-computer-use.exe", "turn-ended"]
|
||||
|
||||
[model_providers.custom]
|
||||
name = "Stale name"
|
||||
base_url = "https://proxy.example.com/v1/"
|
||||
wire_api = "responses"
|
||||
requires_openai_auth = false
|
||||
experimental_bearer_token = "sk-from-config"
|
||||
request_max_retries = 7
|
||||
|
||||
[model_providers.other]
|
||||
name = "Other provider"
|
||||
base_url = "https://other.example.com/v1"
|
||||
experimental_bearer_token = "sk-other"
|
||||
|
||||
[projects."/work/project"]
|
||||
trust_level = "trusted"
|
||||
|
||||
[desktop]
|
||||
followUpQueueMode = "queue"
|
||||
localeOverride = "zh-CN"
|
||||
|
||||
[windows]
|
||||
sandbox = "elevated"
|
||||
shell_path = 'D:\Program Files\PowerShell\7\pwsh.exe'
|
||||
|
||||
[plugins."browser@openai-bundled"]
|
||||
enabled = true
|
||||
|
||||
[features]
|
||||
js_repl = false
|
||||
|
||||
[shell_environment_policy.set]
|
||||
CODEX_HOME = 'C:\Users\Thy\.codex'
|
||||
|
||||
[mcp_servers.docs]
|
||||
command = "docs-server"
|
||||
"#,
|
||||
})
|
||||
.to_string();
|
||||
let row = CcSwitchCodexRow {
|
||||
id: "magicai-123".to_string(),
|
||||
name: " MagicAI ".to_string(),
|
||||
settings_config,
|
||||
category: None,
|
||||
};
|
||||
|
||||
let provider =
|
||||
build_ccswitch_codex_provider(&row, &HashMap::new()).expect("build cc-switch provider");
|
||||
assert_eq!(provider.id, "magicai-123");
|
||||
assert_eq!(provider.provider_name, "MagicAI");
|
||||
assert_eq!(provider.base_url, "https://proxy.example.com/v1");
|
||||
assert_eq!(provider.model, "gpt-5.5");
|
||||
assert_eq!(provider.api_key.as_deref(), Some("sk-from-auth"));
|
||||
assert_eq!(provider.wire_api, "responses");
|
||||
assert!(!provider.requires_openai_auth);
|
||||
|
||||
let text = provider.toml_config.expect("complete provider TOML");
|
||||
let doc = text.parse::<DocumentMut>().expect("parse provider TOML");
|
||||
assert!(text.contains("# keep-imported-comment"));
|
||||
assert_eq!(doc["model_provider"].as_str(), Some("custom"));
|
||||
assert_eq!(doc["model_reasoning_effort"].as_str(), Some("xhigh"));
|
||||
assert_eq!(doc["service_tier"].as_str(), Some("priority"));
|
||||
assert_eq!(doc["notify"].as_array().map(|values| values.len()), Some(2));
|
||||
assert_eq!(
|
||||
doc["model_providers"]["custom"]["name"].as_str(),
|
||||
Some("MagicAI")
|
||||
);
|
||||
assert_eq!(
|
||||
doc["model_providers"]["custom"]["base_url"].as_str(),
|
||||
Some("https://proxy.example.com/v1")
|
||||
);
|
||||
assert_eq!(
|
||||
doc["model_providers"]["custom"]["request_max_retries"].as_integer(),
|
||||
Some(7)
|
||||
);
|
||||
assert_eq!(
|
||||
doc["model_providers"]["other"]["base_url"].as_str(),
|
||||
Some("https://other.example.com/v1")
|
||||
);
|
||||
assert_eq!(
|
||||
doc["projects"]["/work/project"]["trust_level"].as_str(),
|
||||
Some("trusted")
|
||||
);
|
||||
assert_eq!(doc["desktop"]["followUpQueueMode"].as_str(), Some("queue"));
|
||||
assert_eq!(doc["desktop"]["localeOverride"].as_str(), Some("zh-CN"));
|
||||
assert_eq!(doc["windows"]["sandbox"].as_str(), Some("elevated"));
|
||||
assert_eq!(
|
||||
doc["windows"]["shell_path"].as_str(),
|
||||
Some(r"D:\Program Files\PowerShell\7\pwsh.exe")
|
||||
);
|
||||
assert_eq!(
|
||||
doc["plugins"]["browser@openai-bundled"]["enabled"].as_bool(),
|
||||
Some(true)
|
||||
);
|
||||
assert_eq!(doc["features"]["js_repl"].as_bool(), Some(false));
|
||||
assert_eq!(
|
||||
doc["shell_environment_policy"]["set"]["CODEX_HOME"].as_str(),
|
||||
Some(r"C:\Users\Thy\.codex")
|
||||
);
|
||||
assert_eq!(
|
||||
doc["mcp_servers"]["docs"]["command"].as_str(),
|
||||
Some("docs-server")
|
||||
);
|
||||
assert!(doc.get("experimental_bearer_token").is_none());
|
||||
assert!(doc["model_providers"]
|
||||
.as_table()
|
||||
.expect("model providers table")
|
||||
.iter()
|
||||
.all(|(_, item)| item
|
||||
.as_table()
|
||||
.is_none_or(|table| table.get("experimental_bearer_token").is_none())));
|
||||
assert!(!text.contains("sk-from-auth"));
|
||||
assert!(!text.contains("experimental_bearer_token"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_row_config_does_not_create_a_sparse_provider() {
|
||||
let section = codex_sections_from_config(
|
||||
r#"model_provider = "broken-row"
|
||||
model = "gpt-5.5"
|
||||
|
||||
[model_providers.broken-row]
|
||||
name = "Recovered only from another row"
|
||||
base_url = "https://proxy.example.com/v1"
|
||||
wire_api = "responses"
|
||||
requires_openai_auth = false
|
||||
"#,
|
||||
)
|
||||
.into_iter()
|
||||
.next()
|
||||
.expect("global provider section");
|
||||
let mut global_sections = HashMap::new();
|
||||
global_sections.insert(section.id.clone(), section);
|
||||
let row = CcSwitchCodexRow {
|
||||
id: "broken-row".to_string(),
|
||||
name: "Broken row".to_string(),
|
||||
settings_config: json!({
|
||||
"auth": {"OPENAI_API_KEY": "sk-must-not-import"},
|
||||
"config": "model = ["
|
||||
})
|
||||
.to_string(),
|
||||
category: None,
|
||||
};
|
||||
|
||||
assert!(build_ccswitch_codex_provider(&row, &global_sections).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
||||
mod ccswitch;
|
||||
mod connection;
|
||||
mod live;
|
||||
mod official_auth;
|
||||
mod store;
|
||||
|
||||
use crate::error::Result;
|
||||
@@ -21,29 +22,40 @@ pub(crate) use connection::{
|
||||
fetch_provider_models_inner, test_provider_connection_inner, ProviderConnectionResult,
|
||||
ProviderModelsResult,
|
||||
};
|
||||
pub(crate) use live::detected_live_custom_provider;
|
||||
pub(crate) use live::{
|
||||
build_provider_toml_draft_inner, delete_saved_provider_inner, reset_official_provider_inner,
|
||||
restore_official_provider_inner, save_active_provider_inner, save_official_config_inner,
|
||||
save_provider_toml_config_inner, switch_official_provider_inner, switch_provider_inner,
|
||||
OfficialConfigInput, ProviderInput, ProviderTomlInput,
|
||||
};
|
||||
#[cfg(test)]
|
||||
pub(crate) use live::{
|
||||
detected_live_custom_provider, save_provider_toml_config_with_pre_persist,
|
||||
switch_official_provider_with_pre_persist, switch_provider_with_pre_persist,
|
||||
save_provider_toml_config_with_pre_persist, switch_official_provider_with_pre_persist,
|
||||
switch_provider_with_pre_persist,
|
||||
};
|
||||
pub(crate) use live::{
|
||||
save_official_config_inner, save_provider_toml_config_inner, switch_official_provider_inner,
|
||||
switch_provider_inner, OfficialConfigInput, ProviderInput, ProviderTomlInput,
|
||||
#[cfg(test)]
|
||||
pub(crate) use official_auth::{capture_live_chatgpt_config, official_snapshot_path_for_test};
|
||||
pub(crate) use official_auth::{
|
||||
document_is_official, get_official_config_draft_inner, official_auth_available,
|
||||
OfficialConfigDraft,
|
||||
};
|
||||
#[cfg(test)]
|
||||
pub(crate) use store::{
|
||||
canonical_provider_base_url, merge_duplicate_provider_identities, provider_by_id_on_connection,
|
||||
provider_identity, save_manual_provider_on_connection,
|
||||
canonical_provider_base_url, provider_by_id_on_connection, provider_identity,
|
||||
save_manual_provider_on_connection, upsert_provider_on_connection, ProviderUpsertMode,
|
||||
};
|
||||
pub(crate) use store::{
|
||||
custom_provider_id, delete_provider_inner, experimental_bearer_token_from_doc,
|
||||
list_saved_providers_inner, list_saved_providers_on_connection, normalize_saved_provider,
|
||||
reserved_codex_provider_id, save_detected_provider_inner, save_provider_inner,
|
||||
upsert_provider_on_connection, ProviderUpsertKind, ProviderUpsertMode, SavedProvider,
|
||||
consolidate_legacy_provider_duplicates_on_connection, custom_provider_id,
|
||||
delete_provider_inner, experimental_bearer_token_from_doc, is_placeholder_provider,
|
||||
list_saved_providers_inner, list_saved_providers_on_connection,
|
||||
matching_saved_provider_ids_for_live, normalize_saved_provider,
|
||||
provider_template_from_document, reserved_codex_provider_id, rollback_provider_store_inner,
|
||||
save_provider_inner, save_provider_with_rollback_inner, strip_provider_bearer_tokens,
|
||||
unique_saved_provider_id_for_live, upsert_ccswitch_provider_on_connection,
|
||||
ProviderStoreRollback, ProviderUpsertKind, SavedProvider,
|
||||
};
|
||||
|
||||
pub(crate) fn open_store() -> Result<Connection> {
|
||||
let mut conn = crate::app_db::open()?;
|
||||
store::merge_duplicate_provider_identities(&mut conn)?;
|
||||
Ok(conn)
|
||||
crate::app_db::open()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,804 @@
|
||||
use crate::backups::{action_backup_root, BackupMeta};
|
||||
use crate::error::{CodexxError, Result};
|
||||
use crate::file_io::{ensure_directory, io_err, parse_toml_document, write_private_json};
|
||||
use crate::paths::app_home;
|
||||
use crate::toml_utils::ensure_table;
|
||||
use crate::{auth_path, config_path, string_value};
|
||||
use chrono::Local;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use toml_edit::value;
|
||||
|
||||
const SNAPSHOT_VERSION: u32 = 3;
|
||||
const MODEL_ONLY_SNAPSHOT_VERSION: u32 = 2;
|
||||
const LEGACY_SNAPSHOT_VERSION: u32 = 1;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct OfficialConfigCandidate {
|
||||
pub(crate) auth: Option<Value>,
|
||||
pub(crate) config_text: Option<String>,
|
||||
pub(crate) model: Option<String>,
|
||||
pub(crate) source: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct OfficialConfigDraft {
|
||||
auth_json: String,
|
||||
config_text: String,
|
||||
model: Option<String>,
|
||||
source: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct OfficialConfigSnapshot {
|
||||
version: u32,
|
||||
codex_dir: String,
|
||||
captured_at: String,
|
||||
model: Option<String>,
|
||||
#[serde(default)]
|
||||
config: Option<String>,
|
||||
#[serde(default)]
|
||||
auth: Option<Value>,
|
||||
}
|
||||
|
||||
enum SnapshotState {
|
||||
Missing,
|
||||
Reset(OfficialConfigCandidate),
|
||||
Ready(OfficialConfigCandidate),
|
||||
}
|
||||
|
||||
fn canonical_identity(path: &Path) -> String {
|
||||
fs::canonicalize(path)
|
||||
.unwrap_or_else(|_| path.to_path_buf())
|
||||
.to_string_lossy()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
pub(crate) fn official_snapshot_path(codex_dir: &Path) -> Result<PathBuf> {
|
||||
let identity = canonical_identity(codex_dir);
|
||||
let digest = Sha256::digest(identity.as_bytes());
|
||||
Ok(app_home()?
|
||||
.join("official-configs")
|
||||
.join(format!("{digest:x}.json")))
|
||||
}
|
||||
|
||||
fn value_has_material(value: &Value) -> bool {
|
||||
match value {
|
||||
Value::Null => false,
|
||||
Value::String(value) => !value.trim().is_empty(),
|
||||
Value::Array(values) => values.iter().any(value_has_material),
|
||||
Value::Object(values) => values.values().any(value_has_material),
|
||||
Value::Bool(_) | Value::Number(_) => true,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn auth_value_has_material(value: &Value) -> bool {
|
||||
value.as_object().is_some_and(|auth| {
|
||||
auth.iter()
|
||||
.filter(|(key, _)| key.as_str() != "auth_mode")
|
||||
.any(|(_, value)| value_has_material(value))
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn is_chatgpt_auth(value: &Value) -> bool {
|
||||
// Older Codex releases wrote OAuth tokens without auth_mode. Accept that
|
||||
// unambiguous legacy shape, but reject any explicitly non-ChatGPT mode.
|
||||
let chatgpt_mode = match value.get("auth_mode") {
|
||||
None => true,
|
||||
Some(Value::String(mode)) => mode.eq_ignore_ascii_case("chatgpt"),
|
||||
Some(_) => false,
|
||||
};
|
||||
let has_api_key = value
|
||||
.get("OPENAI_API_KEY")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|key| !key.trim().is_empty());
|
||||
chatgpt_mode
|
||||
&& !has_api_key
|
||||
&& value
|
||||
.get("tokens")
|
||||
.and_then(Value::as_object)
|
||||
.is_some_and(|tokens| {
|
||||
["access_token", "refresh_token", "id_token"]
|
||||
.iter()
|
||||
.any(|key| {
|
||||
tokens
|
||||
.get(*key)
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|value| !value.trim().is_empty())
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn has_openai_api_key(value: &Value) -> bool {
|
||||
value
|
||||
.get("OPENAI_API_KEY")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|key| !key.trim().is_empty())
|
||||
}
|
||||
|
||||
fn read_auth_value(path: &Path) -> Result<Option<Value>> {
|
||||
if !path.is_file() {
|
||||
return Ok(None);
|
||||
}
|
||||
let text = fs::read_to_string(path).map_err(|error| io_err(path, error))?;
|
||||
// A malformed live auth file is not trusted authentication. Treat it as
|
||||
// absent so state reads and provider switches can continue and repair it.
|
||||
// Snapshot parsing remains strict in load_snapshot.
|
||||
let value: Value = match serde_json::from_str(&text) {
|
||||
Ok(value) => value,
|
||||
Err(_) => return Ok(None),
|
||||
};
|
||||
if !value.is_object() || !auth_value_has_material(&value) {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(value))
|
||||
}
|
||||
|
||||
fn model_from_config(path: &Path, text: &str) -> Result<Option<String>> {
|
||||
let doc = parse_toml_document(path, text)?;
|
||||
Ok(string_value(&doc, "model"))
|
||||
}
|
||||
|
||||
fn live_official_config_text(codex_dir: &Path) -> Result<Option<String>> {
|
||||
let path = config_path(codex_dir);
|
||||
if !path.is_file() {
|
||||
return Ok(None);
|
||||
}
|
||||
let text = fs::read_to_string(&path).map_err(|error| io_err(&path, error))?;
|
||||
let doc = parse_toml_document(&path, &text)?;
|
||||
Ok(document_is_official(&doc).then_some(text))
|
||||
}
|
||||
|
||||
fn remove_bearer_tokens(doc: &mut toml_edit::DocumentMut) {
|
||||
doc.as_table_mut().remove("experimental_bearer_token");
|
||||
if let Some(providers) = doc
|
||||
.get_mut("model_providers")
|
||||
.and_then(|item| item.as_table_mut())
|
||||
{
|
||||
for (_, item) in providers.iter_mut() {
|
||||
if let Some(table) = item.as_table_mut() {
|
||||
table.remove("experimental_bearer_token");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn build_official_config_text(
|
||||
codex_dir: &Path,
|
||||
model: Option<&str>,
|
||||
clear_model_if_none: bool,
|
||||
) -> Result<String> {
|
||||
let path = config_path(codex_dir);
|
||||
let text = if path.is_file() {
|
||||
fs::read_to_string(&path).map_err(|error| io_err(&path, error))?
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
let mut doc = parse_toml_document(&path, &text)?;
|
||||
|
||||
doc["model_provider"] = value("custom");
|
||||
doc.as_table_mut().remove("base_url");
|
||||
remove_bearer_tokens(&mut doc);
|
||||
let providers = ensure_table(doc.as_table_mut(), "model_providers")?;
|
||||
providers.remove("custom");
|
||||
let official = ensure_table(providers, "custom")?;
|
||||
official["name"] = value("OpenAI");
|
||||
official["requires_openai_auth"] = value(true);
|
||||
official["supports_websockets"] = value(true);
|
||||
official["wire_api"] = value("responses");
|
||||
|
||||
if let Some(model) = model.map(str::trim).filter(|model| !model.is_empty()) {
|
||||
doc["model"] = value(model);
|
||||
} else if clear_model_if_none {
|
||||
doc.as_table_mut().remove("model");
|
||||
}
|
||||
|
||||
Ok(doc.to_string())
|
||||
}
|
||||
|
||||
pub(crate) fn validate_official_config_text(
|
||||
codex_dir: &Path,
|
||||
config_text: &str,
|
||||
model: Option<&str>,
|
||||
) -> Result<(String, Option<String>)> {
|
||||
let path = config_path(codex_dir);
|
||||
let mut doc = parse_toml_document(&path, config_text)?;
|
||||
let has_legacy_proxy_endpoint = doc
|
||||
.get("base_url")
|
||||
.and_then(|item| item.as_str())
|
||||
.is_some_and(|value| !value.trim().is_empty());
|
||||
if !document_is_official(&doc) || has_legacy_proxy_endpoint {
|
||||
return Err(CodexxError::Config(
|
||||
"官方 config.toml 必须使用 OpenAI 官方路由,不能包含第三方 base_url".to_string(),
|
||||
));
|
||||
}
|
||||
remove_bearer_tokens(&mut doc);
|
||||
// A supplied complete TOML document is authoritative. The separate model
|
||||
// field only fills a missing value for compatibility with older clients.
|
||||
if string_value(&doc, "model").is_none() {
|
||||
if let Some(model) = model.map(str::trim).filter(|model| !model.is_empty()) {
|
||||
doc["model"] = value(model);
|
||||
}
|
||||
}
|
||||
let text = doc.to_string();
|
||||
let model = string_value(&doc, "model");
|
||||
Ok((text, model))
|
||||
}
|
||||
|
||||
pub(crate) fn live_config_is_official(codex_dir: &Path) -> Result<bool> {
|
||||
let path = config_path(codex_dir);
|
||||
if !path.is_file() {
|
||||
return Ok(true);
|
||||
}
|
||||
let text = fs::read_to_string(&path).map_err(|error| io_err(&path, error))?;
|
||||
let doc = parse_toml_document(&path, &text)?;
|
||||
Ok(document_is_official(&doc))
|
||||
}
|
||||
|
||||
pub(crate) fn document_is_official(doc: &toml_edit::DocumentMut) -> bool {
|
||||
let Some(provider) = string_value(doc, "model_provider") else {
|
||||
return true;
|
||||
};
|
||||
if provider.eq_ignore_ascii_case("openai") {
|
||||
return true;
|
||||
}
|
||||
if provider != "custom" {
|
||||
return false;
|
||||
}
|
||||
doc.get("model_providers")
|
||||
.and_then(|item| item.as_table())
|
||||
.and_then(|providers| providers.get("custom"))
|
||||
.and_then(|item| item.as_table())
|
||||
.is_some_and(|table| {
|
||||
let has_no_endpoint = table
|
||||
.get("base_url")
|
||||
.and_then(|item| item.as_str())
|
||||
.is_none_or(|value| value.trim().is_empty());
|
||||
let is_openai = table
|
||||
.get("name")
|
||||
.and_then(|item| item.as_str())
|
||||
.is_some_and(|value| value.trim().eq_ignore_ascii_case("openai"));
|
||||
has_no_endpoint
|
||||
&& is_openai
|
||||
&& table
|
||||
.get("requires_openai_auth")
|
||||
.and_then(|item| item.as_bool())
|
||||
== Some(true)
|
||||
})
|
||||
}
|
||||
|
||||
fn write_snapshot(
|
||||
codex_dir: &Path,
|
||||
config: Option<String>,
|
||||
model: Option<String>,
|
||||
auth: Option<Value>,
|
||||
) -> Result<()> {
|
||||
let path = official_snapshot_path(codex_dir)?;
|
||||
if let Some(parent) = path.parent() {
|
||||
ensure_directory(parent)?;
|
||||
}
|
||||
let snapshot = OfficialConfigSnapshot {
|
||||
version: SNAPSHOT_VERSION,
|
||||
codex_dir: canonical_identity(codex_dir),
|
||||
captured_at: Local::now().to_rfc3339(),
|
||||
model,
|
||||
config,
|
||||
auth,
|
||||
};
|
||||
let value = serde_json::to_value(snapshot)
|
||||
.map_err(|error| CodexxError::Config(format!("序列化官方配置快照失败: {error}")))?;
|
||||
write_private_json(&path, &value)
|
||||
}
|
||||
|
||||
pub(crate) fn save_official_config_snapshot(
|
||||
codex_dir: &Path,
|
||||
config: Option<String>,
|
||||
model: Option<String>,
|
||||
auth: &Value,
|
||||
) -> Result<()> {
|
||||
if !auth.is_object() || !auth_value_has_material(auth) {
|
||||
return Err(CodexxError::Config(
|
||||
"官方 auth.json 没有可用认证信息,请先完成官方登录".to_string(),
|
||||
));
|
||||
}
|
||||
write_snapshot(codex_dir, config, model, Some(auth.clone()))
|
||||
}
|
||||
|
||||
pub(crate) fn mark_official_config_reset(
|
||||
codex_dir: &Path,
|
||||
config: Option<String>,
|
||||
model: Option<String>,
|
||||
) -> Result<()> {
|
||||
write_snapshot(codex_dir, config, model, None)
|
||||
}
|
||||
|
||||
pub(crate) fn capture_live_official_config_before_provider_switch(
|
||||
codex_dir: &Path,
|
||||
) -> Result<bool> {
|
||||
if !live_config_is_official(codex_dir)? {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let live_config = live_official_config_text(codex_dir)?;
|
||||
let live_model = live_config
|
||||
.as_deref()
|
||||
.map(|text| model_from_config(&config_path(codex_dir), text))
|
||||
.transpose()?
|
||||
.flatten();
|
||||
let has_explicit_official_route = live_config
|
||||
.as_deref()
|
||||
.map(|text| {
|
||||
let doc = parse_toml_document(&config_path(codex_dir), text)?;
|
||||
Ok::<_, CodexxError>(string_value(&doc, "model_provider").is_some())
|
||||
})
|
||||
.transpose()?
|
||||
.unwrap_or(false);
|
||||
let live_auth = read_auth_value(&auth_path(codex_dir))?.filter(|auth| {
|
||||
is_chatgpt_auth(auth) || (has_explicit_official_route && has_openai_api_key(auth))
|
||||
});
|
||||
|
||||
let previous = match load_snapshot(codex_dir)? {
|
||||
SnapshotState::Ready(candidate) | SnapshotState::Reset(candidate) => Some(candidate),
|
||||
SnapshotState::Missing => None,
|
||||
};
|
||||
let config = live_config.or_else(|| {
|
||||
previous
|
||||
.as_ref()
|
||||
.and_then(|candidate| candidate.config_text.clone())
|
||||
});
|
||||
let model = live_model.or_else(|| {
|
||||
previous
|
||||
.as_ref()
|
||||
.and_then(|candidate| candidate.model.clone())
|
||||
});
|
||||
let previous_auth = previous
|
||||
.as_ref()
|
||||
.and_then(|candidate| candidate.auth.clone());
|
||||
let auth = prefer_chatgpt_auth(live_auth, previous_auth);
|
||||
if config.is_none() && auth.is_none() {
|
||||
return Ok(false);
|
||||
}
|
||||
write_snapshot(codex_dir, config, model, auth)?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn capture_live_official_auth(
|
||||
codex_dir: &Path,
|
||||
is_trusted: impl FnOnce(&Value) -> bool,
|
||||
) -> Result<bool> {
|
||||
if !live_config_is_official(codex_dir)? {
|
||||
return Ok(false);
|
||||
}
|
||||
let Some(auth) = read_auth_value(&auth_path(codex_dir))? else {
|
||||
return Ok(false);
|
||||
};
|
||||
if !is_trusted(&auth) {
|
||||
return Ok(false);
|
||||
}
|
||||
let config = live_official_config_text(codex_dir)?;
|
||||
let model = config
|
||||
.as_deref()
|
||||
.map(|text| model_from_config(&config_path(codex_dir), text))
|
||||
.transpose()?
|
||||
.flatten();
|
||||
save_official_config_snapshot(codex_dir, config, model, &auth)?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn capture_live_chatgpt_config(codex_dir: &Path) -> Result<bool> {
|
||||
capture_live_official_auth(codex_dir, is_chatgpt_auth)
|
||||
}
|
||||
|
||||
fn load_snapshot(codex_dir: &Path) -> Result<SnapshotState> {
|
||||
let path = official_snapshot_path(codex_dir)?;
|
||||
if !path.is_file() {
|
||||
return Ok(SnapshotState::Missing);
|
||||
}
|
||||
let text = fs::read_to_string(&path).map_err(|error| io_err(&path, error))?;
|
||||
let snapshot: OfficialConfigSnapshot = match serde_json::from_str(&text) {
|
||||
Ok(snapshot) => snapshot,
|
||||
Err(_) => return Ok(SnapshotState::Missing),
|
||||
};
|
||||
// This is an app-owned recovery cache, not the live source of truth. A
|
||||
// truncated, stale, or incompatible cache must never block startup or a
|
||||
// provider switch; valid live auth/history can repair it on the next write.
|
||||
Ok(snapshot_state(codex_dir, &path, snapshot).unwrap_or(SnapshotState::Missing))
|
||||
}
|
||||
|
||||
fn prefer_chatgpt_auth(primary: Option<Value>, fallback: Option<Value>) -> Option<Value> {
|
||||
if primary.as_ref().is_some_and(is_chatgpt_auth) {
|
||||
return primary;
|
||||
}
|
||||
if fallback.as_ref().is_some_and(is_chatgpt_auth) {
|
||||
return fallback;
|
||||
}
|
||||
primary.or(fallback)
|
||||
}
|
||||
|
||||
fn snapshot_state(
|
||||
codex_dir: &Path,
|
||||
path: &Path,
|
||||
snapshot: OfficialConfigSnapshot,
|
||||
) -> Result<SnapshotState> {
|
||||
if !matches!(
|
||||
snapshot.version,
|
||||
SNAPSHOT_VERSION | MODEL_ONLY_SNAPSHOT_VERSION | LEGACY_SNAPSHOT_VERSION
|
||||
) || snapshot.codex_dir != canonical_identity(codex_dir)
|
||||
{
|
||||
return Err(CodexxError::Config(format!(
|
||||
"官方配置快照与当前 CODEX_HOME 不匹配: {}",
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
let source = "Codex-X 官方配置快照".to_string();
|
||||
let Some(auth) = snapshot.auth else {
|
||||
return Ok(SnapshotState::Reset(OfficialConfigCandidate {
|
||||
auth: None,
|
||||
config_text: snapshot.config,
|
||||
model: snapshot.model,
|
||||
source,
|
||||
}));
|
||||
};
|
||||
if !auth.is_object() || !auth_value_has_material(&auth) {
|
||||
return Err(CodexxError::Config(format!(
|
||||
"官方配置快照不包含可用认证: {}",
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
// Version 1 could be populated automatically from a proxy API key. Its
|
||||
// API-key-only snapshots are ambiguous, so never restore or promote them.
|
||||
if snapshot.version == LEGACY_SNAPSHOT_VERSION && !is_chatgpt_auth(&auth) {
|
||||
return Ok(SnapshotState::Missing);
|
||||
}
|
||||
Ok(SnapshotState::Ready(OfficialConfigCandidate {
|
||||
auth: Some(auth),
|
||||
config_text: snapshot.config,
|
||||
model: snapshot.model,
|
||||
source,
|
||||
}))
|
||||
}
|
||||
|
||||
fn backup_config_is_official(dir: &Path, meta: &BackupMeta) -> bool {
|
||||
if !meta.had_config {
|
||||
return true;
|
||||
}
|
||||
let path = dir.join("config.toml");
|
||||
let Ok(text) = fs::read_to_string(&path) else {
|
||||
return false;
|
||||
};
|
||||
let Ok(doc) = parse_toml_document(&path, &text) else {
|
||||
return false;
|
||||
};
|
||||
document_is_official(&doc)
|
||||
}
|
||||
|
||||
fn backup_config(dir: &Path, meta: &BackupMeta) -> Option<String> {
|
||||
if !meta.had_config {
|
||||
return None;
|
||||
}
|
||||
let path = dir.join("config.toml");
|
||||
fs::read_to_string(&path).ok()
|
||||
}
|
||||
|
||||
fn latest_official_backup(codex_dir: &Path) -> Result<Option<OfficialConfigCandidate>> {
|
||||
let root = action_backup_root(codex_dir)?;
|
||||
if !root.is_dir() {
|
||||
return Ok(None);
|
||||
}
|
||||
let identity = canonical_identity(codex_dir);
|
||||
let mut candidates = Vec::new();
|
||||
for entry in fs::read_dir(&root).map_err(|error| io_err(&root, error))? {
|
||||
let entry = entry.map_err(|error| io_err(&root, error))?;
|
||||
let dir = entry.path();
|
||||
if !dir.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let meta_path = dir.join("meta.json");
|
||||
let Ok(meta_text) = fs::read_to_string(&meta_path) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(meta) = serde_json::from_str::<BackupMeta>(&meta_text) else {
|
||||
continue;
|
||||
};
|
||||
if !meta.had_auth
|
||||
|| canonical_identity(Path::new(&meta.codex_dir)) != identity
|
||||
|| !backup_config_is_official(&dir, &meta)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let Ok(Some(auth)) = read_auth_value(&dir.join("auth.json")) else {
|
||||
continue;
|
||||
};
|
||||
// Old Codex-X versions could mark config.toml as official while leaving
|
||||
// a proxy API key in auth.json. Historical auto-recovery therefore only
|
||||
// trusts unambiguous ChatGPT login backups. Official API keys remain
|
||||
// supported through an explicit Codex-X snapshot/save.
|
||||
if !is_chatgpt_auth(&auth) {
|
||||
continue;
|
||||
}
|
||||
let config_text = backup_config(&dir, &meta);
|
||||
let model = config_text.as_deref().and_then(|text| {
|
||||
model_from_config(&dir.join("config.toml"), text)
|
||||
.ok()
|
||||
.flatten()
|
||||
});
|
||||
candidates.push((meta.created_at.clone(), auth, config_text, model));
|
||||
}
|
||||
candidates.sort_by(|left, right| right.0.cmp(&left.0));
|
||||
Ok(candidates
|
||||
.into_iter()
|
||||
.next()
|
||||
.map(
|
||||
|(created_at, auth, config_text, model)| OfficialConfigCandidate {
|
||||
auth: Some(auth),
|
||||
config_text,
|
||||
model,
|
||||
source: format!("Codex-X 历史备份 {created_at}"),
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
fn live_auth_candidate(
|
||||
codex_dir: &Path,
|
||||
require_official_route: bool,
|
||||
is_trusted: impl FnOnce(&Value) -> bool,
|
||||
) -> Result<Option<OfficialConfigCandidate>> {
|
||||
let is_official = live_config_is_official(codex_dir)?;
|
||||
if require_official_route && !is_official {
|
||||
return Ok(None);
|
||||
}
|
||||
let Some(auth) = read_auth_value(&auth_path(codex_dir))? else {
|
||||
return Ok(None);
|
||||
};
|
||||
if !is_trusted(&auth) {
|
||||
return Ok(None);
|
||||
}
|
||||
let config_text = if is_official {
|
||||
live_official_config_text(codex_dir)?
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let model = config_text
|
||||
.as_deref()
|
||||
.map(|text| model_from_config(&config_path(codex_dir), text))
|
||||
.transpose()?
|
||||
.flatten();
|
||||
Ok(Some(OfficialConfigCandidate {
|
||||
auth: Some(auth),
|
||||
config_text,
|
||||
model,
|
||||
source: "当前 OpenAI 官方认证".to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
fn live_chatgpt_candidate(codex_dir: &Path) -> Result<Option<OfficialConfigCandidate>> {
|
||||
live_auth_candidate(codex_dir, false, is_chatgpt_auth)
|
||||
}
|
||||
|
||||
fn live_official_auth_candidate(codex_dir: &Path) -> Result<Option<OfficialConfigCandidate>> {
|
||||
live_auth_candidate(codex_dir, true, |auth| {
|
||||
is_chatgpt_auth(auth) || has_openai_api_key(auth)
|
||||
})
|
||||
}
|
||||
|
||||
fn complete_candidate_config(
|
||||
codex_dir: &Path,
|
||||
mut candidate: OfficialConfigCandidate,
|
||||
backup: Option<&OfficialConfigCandidate>,
|
||||
) -> Result<OfficialConfigCandidate> {
|
||||
if candidate.config_text.is_none() {
|
||||
candidate.config_text = live_official_config_text(codex_dir)?
|
||||
.or_else(|| backup.and_then(|value| value.config_text.clone()));
|
||||
}
|
||||
if candidate.config_text.is_none() {
|
||||
candidate.config_text = Some(build_official_config_text(
|
||||
codex_dir,
|
||||
candidate.model.as_deref(),
|
||||
false,
|
||||
)?);
|
||||
}
|
||||
if let Some(config_text) = candidate.config_text.as_deref() {
|
||||
candidate.model =
|
||||
model_from_config(&config_path(codex_dir), config_text)?.or(candidate.model);
|
||||
}
|
||||
Ok(candidate)
|
||||
}
|
||||
|
||||
pub(crate) fn official_config_candidate(
|
||||
codex_dir: &Path,
|
||||
include_history_after_reset: bool,
|
||||
) -> Result<Option<OfficialConfigCandidate>> {
|
||||
match load_snapshot(codex_dir)? {
|
||||
SnapshotState::Ready(candidate) => {
|
||||
// A third-party route may deliberately keep the user's ChatGPT
|
||||
// login in auth.json and carry its own key in
|
||||
// experimental_bearer_token. Prefer that live OAuth value so a
|
||||
// token refresh during proxy use is not replaced by an older
|
||||
// official snapshot when switching back.
|
||||
if let Some(live) = live_chatgpt_candidate(codex_dir)? {
|
||||
return complete_candidate_config(codex_dir, live, Some(&candidate)).map(Some);
|
||||
}
|
||||
// Never let an API-key-only live file silently downgrade a trusted
|
||||
// OAuth snapshot. Older Codex-X builds could leave exactly that
|
||||
// polluted state while config.toml already pointed at OpenAI.
|
||||
if candidate.auth.as_ref().is_some_and(is_chatgpt_auth) {
|
||||
return complete_candidate_config(codex_dir, candidate, None).map(Some);
|
||||
}
|
||||
if let Some(live) = live_official_auth_candidate(codex_dir)? {
|
||||
return complete_candidate_config(codex_dir, live, Some(&candidate)).map(Some);
|
||||
}
|
||||
return complete_candidate_config(codex_dir, candidate, None).map(Some);
|
||||
}
|
||||
SnapshotState::Reset(reset) if !include_history_after_reset => {
|
||||
if let Some(live) = live_official_auth_candidate(codex_dir)? {
|
||||
return complete_candidate_config(codex_dir, live, Some(&reset)).map(Some);
|
||||
}
|
||||
if let Some(live) = live_chatgpt_candidate(codex_dir)? {
|
||||
return complete_candidate_config(codex_dir, live, Some(&reset)).map(Some);
|
||||
}
|
||||
return complete_candidate_config(codex_dir, reset, None).map(Some);
|
||||
}
|
||||
SnapshotState::Reset(reset) => {
|
||||
if let Some(live) = live_official_auth_candidate(codex_dir)? {
|
||||
return complete_candidate_config(codex_dir, live, Some(&reset)).map(Some);
|
||||
}
|
||||
if let Some(live) = live_chatgpt_candidate(codex_dir)? {
|
||||
return complete_candidate_config(codex_dir, live, Some(&reset)).map(Some);
|
||||
}
|
||||
if let Some(mut backup) = latest_official_backup(codex_dir)? {
|
||||
if reset.config_text.is_some() {
|
||||
backup.config_text = reset.config_text;
|
||||
backup.model = reset.model.or(backup.model);
|
||||
}
|
||||
return complete_candidate_config(codex_dir, backup, None).map(Some);
|
||||
}
|
||||
return complete_candidate_config(codex_dir, reset, None).map(Some);
|
||||
}
|
||||
SnapshotState::Missing => {}
|
||||
}
|
||||
|
||||
let backup = latest_official_backup(codex_dir)?;
|
||||
if let Some(candidate) = live_chatgpt_candidate(codex_dir)? {
|
||||
return complete_candidate_config(codex_dir, candidate, backup.as_ref()).map(Some);
|
||||
}
|
||||
backup
|
||||
.map(|candidate| complete_candidate_config(codex_dir, candidate, None))
|
||||
.transpose()
|
||||
}
|
||||
|
||||
pub(crate) fn official_auth_available(codex_dir: &Path) -> Result<bool> {
|
||||
match load_snapshot(codex_dir)? {
|
||||
SnapshotState::Ready(_) => return Ok(true),
|
||||
SnapshotState::Reset(_) => return Ok(live_official_auth_candidate(codex_dir)?.is_some()),
|
||||
SnapshotState::Missing => {}
|
||||
}
|
||||
if live_official_auth_candidate(codex_dir)?.is_some() {
|
||||
return Ok(true);
|
||||
}
|
||||
if let Some(auth) = read_auth_value(&auth_path(codex_dir))? {
|
||||
if is_chatgpt_auth(&auth) {
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
// Historical backup discovery is intentionally deferred to restore. A
|
||||
// status refresh must not traverse every backup on the startup path.
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
pub(crate) fn get_official_config_draft_inner(
|
||||
config_dir: Option<String>,
|
||||
) -> Result<Option<OfficialConfigDraft>> {
|
||||
let codex_dir = crate::resolve_codex_dir(config_dir)?;
|
||||
let candidate = match official_config_candidate(&codex_dir, false)? {
|
||||
Some(candidate) => candidate,
|
||||
None => {
|
||||
let config_text = build_official_config_text(&codex_dir, None, false)?;
|
||||
let model = model_from_config(&config_path(&codex_dir), &config_text)?;
|
||||
OfficialConfigCandidate {
|
||||
auth: None,
|
||||
config_text: Some(config_text),
|
||||
model,
|
||||
source: "根据当前 config.toml 生成".to_string(),
|
||||
}
|
||||
}
|
||||
};
|
||||
official_config_draft(candidate).map(Some)
|
||||
}
|
||||
|
||||
fn official_config_draft(candidate: OfficialConfigCandidate) -> Result<OfficialConfigDraft> {
|
||||
let auth_json = candidate
|
||||
.auth
|
||||
.as_ref()
|
||||
.map(serde_json::to_string_pretty)
|
||||
.transpose()
|
||||
.map_err(|error| CodexxError::Config(format!("格式化官方配置快照失败: {error}")))?
|
||||
.unwrap_or_default();
|
||||
Ok(OfficialConfigDraft {
|
||||
auth_json,
|
||||
config_text: candidate.config_text.unwrap_or_default(),
|
||||
model: candidate.model,
|
||||
source: candidate.source,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn official_snapshot_path_for_test(codex_dir: &Path) -> Result<PathBuf> {
|
||||
official_snapshot_path(codex_dir)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn legacy_oauth_without_auth_mode_is_trusted() {
|
||||
assert!(is_chatgpt_auth(&json!({
|
||||
"OPENAI_API_KEY": null,
|
||||
"tokens": {"access_token": "legacy-access"},
|
||||
"last_refresh": "2026-08-11T00:00:00Z"
|
||||
})));
|
||||
assert!(!is_chatgpt_auth(&json!({
|
||||
"auth_mode": "apikey",
|
||||
"OPENAI_API_KEY": null,
|
||||
"tokens": {"access_token": "must-not-trust"}
|
||||
})));
|
||||
assert!(!is_chatgpt_auth(&json!({
|
||||
"tokens": {"access_token": "legacy-access"},
|
||||
"OPENAI_API_KEY": "sk-third-party"
|
||||
})));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v3_reset_snapshot_keeps_complete_config_without_restoring_auth() {
|
||||
let codex_dir = std::env::temp_dir().join(format!(
|
||||
"codex-x-official-reset-snapshot-{}",
|
||||
std::process::id()
|
||||
));
|
||||
fs::create_dir_all(&codex_dir).expect("create test Codex directory");
|
||||
let config = r#"# keep-reset-config
|
||||
model_provider = "openai"
|
||||
model = "official-model"
|
||||
approval_policy = "on-request"
|
||||
|
||||
[features]
|
||||
js_repl = false
|
||||
"#;
|
||||
let snapshot = OfficialConfigSnapshot {
|
||||
version: SNAPSHOT_VERSION,
|
||||
codex_dir: canonical_identity(&codex_dir),
|
||||
captured_at: "2026-08-11T00:00:00+08:00".to_string(),
|
||||
model: Some("official-model".to_string()),
|
||||
config: Some(config.to_string()),
|
||||
auth: None,
|
||||
};
|
||||
|
||||
let candidate = match snapshot_state(&codex_dir, Path::new("snapshot.json"), snapshot)
|
||||
.expect("classify reset snapshot")
|
||||
{
|
||||
SnapshotState::Reset(candidate) => candidate,
|
||||
SnapshotState::Missing | SnapshotState::Ready(_) => {
|
||||
panic!("v3 auth-less snapshot must remain an explicit reset")
|
||||
}
|
||||
};
|
||||
let candidate = complete_candidate_config(&codex_dir, candidate, None)
|
||||
.expect("complete reset candidate");
|
||||
assert!(candidate.auth.is_none());
|
||||
assert_eq!(candidate.model.as_deref(), Some("official-model"));
|
||||
assert_eq!(candidate.config_text.as_deref(), Some(config));
|
||||
|
||||
let draft = official_config_draft(candidate).expect("build reset draft");
|
||||
assert!(draft.auth_json.is_empty());
|
||||
assert_eq!(draft.model.as_deref(), Some("official-model"));
|
||||
assert_eq!(draft.config_text, config);
|
||||
|
||||
fs::remove_dir_all(codex_dir).expect("remove test Codex directory");
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -200,17 +200,6 @@ pub(super) fn create_provider_sync_backup(
|
||||
existed: path.exists(),
|
||||
});
|
||||
}
|
||||
for name in [
|
||||
"config.toml",
|
||||
".codex-global-state.json",
|
||||
".codex-global-state.json.bak",
|
||||
] {
|
||||
snapshots.push(copy_file_to_backup(
|
||||
codex_dir,
|
||||
&backup_dir,
|
||||
&codex_dir.join(name),
|
||||
)?);
|
||||
}
|
||||
for path in changed_rollouts {
|
||||
snapshots.push(copy_file_to_backup(codex_dir, &backup_dir, path)?);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,9 @@
|
||||
use super::app_server::delete_sessions_via_codex_app_server;
|
||||
use super::storage::{
|
||||
current_model_provider, discover_sqlite_databases, ensure_sqlite_discovery_writable,
|
||||
scan_rollouts, split_line_ending, sqlite_subagent_thread_ids, sqlite_thread_needs_alignment,
|
||||
SqliteDiscovery, SqliteThreadIndexState,
|
||||
is_canonical_rollout_storage_path, rollout_filename_matches_id, scan_rollouts,
|
||||
split_line_ending, sqlite_subagent_thread_ids, sqlite_thread_needs_alignment, SqliteDiscovery,
|
||||
SqliteThreadIndexState,
|
||||
};
|
||||
use super::sync::{acquire_session_maintenance_lock, session_sync_status_with_discovery};
|
||||
use super::types::SessionSyncStatus;
|
||||
@@ -387,30 +388,6 @@ fn collect_rollout_storage_paths(root: &Path, out: &mut Vec<PathBuf>) {
|
||||
}
|
||||
}
|
||||
|
||||
fn rollout_filename_matches_id(path: &Path, id: &str) -> bool {
|
||||
path.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.is_some_and(|name| {
|
||||
name.ends_with(&format!("-{id}.jsonl")) || name.ends_with(&format!("-{id}.jsonl.zst"))
|
||||
})
|
||||
}
|
||||
|
||||
fn canonical_rollout_storage_roots(codex_dir: &Path) -> Vec<PathBuf> {
|
||||
[
|
||||
codex_dir.join("sessions"),
|
||||
codex_dir.join("archived_sessions"),
|
||||
]
|
||||
.into_iter()
|
||||
.filter_map(|root| root.canonicalize().ok())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn is_canonical_rollout_storage_path(codex_dir: &Path, path: &Path) -> bool {
|
||||
canonical_rollout_storage_roots(codex_dir)
|
||||
.iter()
|
||||
.any(|root| path.starts_with(root))
|
||||
}
|
||||
|
||||
fn canonical_rollout_path(codex_dir: &Path, value: &str, id: &str) -> Result<Option<PathBuf>> {
|
||||
let raw = PathBuf::from(value.trim());
|
||||
let path = if raw.is_absolute() {
|
||||
@@ -932,6 +909,11 @@ pub(crate) fn delete_codex_sessions_inner(
|
||||
.intersection(&storage_before.subagent_ids)
|
||||
.count();
|
||||
let deleted_top_level = deleted_active_ids.len().saturating_sub(deleted_subagents);
|
||||
let deleted_mismatched_sessions = fallback
|
||||
.sessions
|
||||
.iter()
|
||||
.filter(|item| item.needs_sync && deleted_active_ids.contains(&item.id))
|
||||
.count();
|
||||
fallback
|
||||
.sessions
|
||||
.retain(|item| !counts.deleted_ids.contains(&item.id));
|
||||
@@ -944,7 +926,10 @@ pub(crate) fn delete_codex_sessions_inner(
|
||||
fallback.mismatched_threads = fallback
|
||||
.mismatched_threads
|
||||
.saturating_sub(deleted_mismatched);
|
||||
fallback.needs_sync = fallback.mismatched_threads > 0;
|
||||
fallback.mismatched_sessions = fallback
|
||||
.mismatched_sessions
|
||||
.saturating_sub(deleted_mismatched_sessions);
|
||||
fallback.needs_sync = fallback.mismatched_sessions > 0;
|
||||
fallback.warnings.push(message);
|
||||
fallback
|
||||
}
|
||||
|
||||
@@ -1,17 +1,3 @@
|
||||
use crate::error::{CodexxError, Result};
|
||||
use crate::file_io::{atomic_write, io_err, json_err, write_text};
|
||||
use serde_json::{json, Map, Value};
|
||||
use std::collections::HashSet;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(super) struct GlobalStateWrite {
|
||||
pub(super) path: std::path::PathBuf,
|
||||
pub(super) original_bytes: Option<Vec<u8>>,
|
||||
pub(super) written_bytes: Vec<u8>,
|
||||
}
|
||||
|
||||
pub(super) fn normalize_workspace_path(value: &str) -> Option<String> {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty() {
|
||||
@@ -26,313 +12,3 @@ pub(super) fn normalize_workspace_path(value: &str) -> Option<String> {
|
||||
}
|
||||
Some(trimmed.to_string())
|
||||
}
|
||||
|
||||
fn load_global_state(path: &Path) -> Result<Map<String, Value>> {
|
||||
if !path.exists() {
|
||||
return Ok(Map::new());
|
||||
}
|
||||
let text = fs::read_to_string(path).map_err(|error| io_err(path, error))?;
|
||||
let value = serde_json::from_str::<Value>(&text).map_err(|error| json_err(path, error))?;
|
||||
Ok(value.as_object().cloned().unwrap_or_default())
|
||||
}
|
||||
|
||||
pub(super) fn projectless_thread_ids(path: &Path) -> Result<HashSet<String>> {
|
||||
let state = load_global_state(path)?;
|
||||
Ok(state
|
||||
.get("projectless-thread-ids")
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|id| !id.is_empty())
|
||||
.map(ToString::to_string)
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn normalized_path_array(value: &Value) -> Vec<String> {
|
||||
if let Some(items) = value.as_array() {
|
||||
items
|
||||
.iter()
|
||||
.filter_map(Value::as_str)
|
||||
.filter_map(normalize_workspace_path)
|
||||
.collect()
|
||||
} else {
|
||||
value
|
||||
.as_str()
|
||||
.and_then(normalize_workspace_path)
|
||||
.into_iter()
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
fn dedupe_workspace_paths(paths: Vec<String>) -> Vec<String> {
|
||||
let mut seen = HashSet::new();
|
||||
paths
|
||||
.into_iter()
|
||||
.filter(|path| {
|
||||
seen.insert(
|
||||
path.replace('/', r"\")
|
||||
.trim_end_matches('\\')
|
||||
.to_ascii_lowercase(),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn normalized_global_state(state: &Map<String, Value>) -> Map<String, Value> {
|
||||
let mut next = Map::new();
|
||||
for key in ["electron-saved-workspace-roots", "project-order"] {
|
||||
if let Some(value) = state.get(key) {
|
||||
next.insert(
|
||||
key.to_string(),
|
||||
json!(dedupe_workspace_paths(normalized_path_array(value))),
|
||||
);
|
||||
}
|
||||
}
|
||||
if let Some(value) = state.get("active-workspace-roots") {
|
||||
let normalized = dedupe_workspace_paths(normalized_path_array(value));
|
||||
let next_value = if value.is_array() {
|
||||
json!(normalized)
|
||||
} else if let Some(first) = normalized.first() {
|
||||
json!(first)
|
||||
} else {
|
||||
value.clone()
|
||||
};
|
||||
next.insert("active-workspace-roots".to_string(), next_value);
|
||||
}
|
||||
if let Some(labels) = state
|
||||
.get("electron-workspace-root-labels")
|
||||
.and_then(Value::as_object)
|
||||
{
|
||||
let mut normalized = Map::new();
|
||||
for (path, value) in labels {
|
||||
normalized.insert(
|
||||
normalize_workspace_path(path).unwrap_or_else(|| path.clone()),
|
||||
value.clone(),
|
||||
);
|
||||
}
|
||||
next.insert(
|
||||
"electron-workspace-root-labels".to_string(),
|
||||
Value::Object(normalized),
|
||||
);
|
||||
}
|
||||
if let Some(open_targets) = state
|
||||
.get("open-in-target-preferences")
|
||||
.and_then(Value::as_object)
|
||||
{
|
||||
let mut normalized = open_targets.clone();
|
||||
if let Some(per_path) = open_targets.get("perPath").and_then(Value::as_object) {
|
||||
let mut normalized_per_path = Map::new();
|
||||
for (path, value) in per_path {
|
||||
normalized_per_path.insert(
|
||||
normalize_workspace_path(path).unwrap_or_else(|| path.clone()),
|
||||
value.clone(),
|
||||
);
|
||||
}
|
||||
normalized.insert("perPath".to_string(), Value::Object(normalized_per_path));
|
||||
}
|
||||
next.insert(
|
||||
"open-in-target-preferences".to_string(),
|
||||
Value::Object(normalized),
|
||||
);
|
||||
}
|
||||
next
|
||||
}
|
||||
|
||||
pub(super) fn count_global_state_updates(path: &Path) -> Result<usize> {
|
||||
let state = load_global_state(path)?;
|
||||
let next = normalized_global_state(&state);
|
||||
Ok(next
|
||||
.iter()
|
||||
.filter(|(key, value)| state.get(*key) != Some(*value))
|
||||
.count())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn apply_global_state_update(path: &Path) -> Result<usize> {
|
||||
apply_global_state_update_with_journal(path, &mut Vec::new(), &mut || Ok(()))
|
||||
}
|
||||
|
||||
fn read_optional_bytes(path: &Path) -> Result<Option<Vec<u8>>> {
|
||||
match fs::read(path) {
|
||||
Ok(bytes) => Ok(Some(bytes)),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
||||
Err(error) => Err(io_err(path, error)),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn apply_global_state_update_with_journal<F>(
|
||||
path: &Path,
|
||||
writes: &mut Vec<GlobalStateWrite>,
|
||||
after_main_write: &mut F,
|
||||
) -> Result<usize>
|
||||
where
|
||||
F: FnMut() -> Result<()>,
|
||||
{
|
||||
let original_bytes = read_optional_bytes(path)?;
|
||||
let mut state = match &original_bytes {
|
||||
Some(bytes) => serde_json::from_slice::<Value>(bytes)
|
||||
.map_err(|error| json_err(path, error))?
|
||||
.as_object()
|
||||
.cloned()
|
||||
.unwrap_or_default(),
|
||||
None => Map::new(),
|
||||
};
|
||||
let next = normalized_global_state(&state);
|
||||
let updated = next
|
||||
.iter()
|
||||
.filter(|(key, value)| state.get(*key) != Some(*value))
|
||||
.count();
|
||||
if updated > 0 {
|
||||
for (key, value) in next {
|
||||
state.insert(key, value);
|
||||
}
|
||||
let text = serde_json::to_string_pretty(&Value::Object(state))
|
||||
.map_err(|error| json_err(path, error))?;
|
||||
let written_bytes = text.as_bytes().to_vec();
|
||||
if read_optional_bytes(path)? != original_bytes {
|
||||
return Err(CodexxError::Config(
|
||||
"全局状态已发生变化,请重试。".to_string(),
|
||||
));
|
||||
}
|
||||
write_text(path, &text)?;
|
||||
writes.push(GlobalStateWrite {
|
||||
path: path.to_path_buf(),
|
||||
original_bytes,
|
||||
written_bytes: written_bytes.clone(),
|
||||
});
|
||||
after_main_write()?;
|
||||
if let Some(parent) = path.parent() {
|
||||
let backup = parent.join(".codex-global-state.json.bak");
|
||||
let original_bytes = read_optional_bytes(&backup)?;
|
||||
if read_optional_bytes(&backup)? != original_bytes {
|
||||
return Err(CodexxError::Config(
|
||||
"全局状态已发生变化,请重试。".to_string(),
|
||||
));
|
||||
}
|
||||
write_text(&backup, &text)?;
|
||||
writes.push(GlobalStateWrite {
|
||||
path: backup,
|
||||
original_bytes,
|
||||
written_bytes,
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(updated)
|
||||
}
|
||||
|
||||
pub(super) fn restore_global_write(write: &GlobalStateWrite) -> Result<()> {
|
||||
match fs::read(&write.path) {
|
||||
Ok(current) if current == write.written_bytes => {}
|
||||
Ok(_) => {
|
||||
return Err(CodexxError::Config(format!(
|
||||
"全局状态已发生变化,无法安全恢复: {}",
|
||||
write.path.display()
|
||||
)));
|
||||
}
|
||||
Err(error)
|
||||
if error.kind() == std::io::ErrorKind::NotFound && write.original_bytes.is_none() =>
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
Err(error) => return Err(io_err(&write.path, error)),
|
||||
}
|
||||
|
||||
if let Some(original) = &write.original_bytes {
|
||||
atomic_write(&write.path, original)
|
||||
} else {
|
||||
fs::remove_file(&write.path).map_err(|error| io_err(&write.path, error))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
fn temp_dir(name: &str) -> PathBuf {
|
||||
static COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"codex-x-global-state-{name}-{}-{}",
|
||||
std::process::id(),
|
||||
COUNTER.fetch_add(1, Ordering::Relaxed),
|
||||
));
|
||||
let _ = fs::remove_dir_all(&path);
|
||||
fs::create_dir_all(&path).expect("create test directory");
|
||||
path
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn global_state_update_keeps_unknown_fields_and_matches_backup() {
|
||||
let dir = temp_dir("update");
|
||||
let path = dir.join(".codex-global-state.json");
|
||||
fs::write(
|
||||
&path,
|
||||
r#"{
|
||||
"electron-saved-workspace-roots": "/tmp/project",
|
||||
"unrelated-setting": { "enabled": true }
|
||||
}"#,
|
||||
)
|
||||
.expect("write original global state");
|
||||
|
||||
assert_eq!(
|
||||
apply_global_state_update(&path).expect("update global state"),
|
||||
1
|
||||
);
|
||||
|
||||
let main_text = fs::read_to_string(&path).expect("read global state");
|
||||
let backup_path = dir.join(".codex-global-state.json.bak");
|
||||
let backup_text = fs::read_to_string(&backup_path).expect("read global state backup");
|
||||
assert_eq!(main_text, backup_text);
|
||||
let state: Value = serde_json::from_str(&main_text).expect("parse global state");
|
||||
assert_eq!(
|
||||
state.get("electron-saved-workspace-roots"),
|
||||
Some(&json!(["/tmp/project"]))
|
||||
);
|
||||
assert_eq!(
|
||||
state.get("unrelated-setting"),
|
||||
Some(&json!({ "enabled": true }))
|
||||
);
|
||||
|
||||
fs::remove_dir_all(dir).expect("remove test directory");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restores_distinct_existing_global_main_and_backup() {
|
||||
let dir = temp_dir("distinct-snapshots");
|
||||
let main = dir.join(".codex-global-state.json");
|
||||
let backup = dir.join(".codex-global-state.json.bak");
|
||||
let original_main = br#"{"source":"main","value":1}"#;
|
||||
let original_backup = br#"{"source":"backup","value":2}"#;
|
||||
let written_text = r#"{"source":"mutated"}"#.to_string();
|
||||
fs::write(&main, original_main).expect("write original main");
|
||||
fs::write(&backup, original_backup).expect("write original backup");
|
||||
write_text(&main, &written_text).expect("mutate global main");
|
||||
write_text(&backup, &written_text).expect("mutate global backup");
|
||||
let writes = [
|
||||
GlobalStateWrite {
|
||||
path: main.clone(),
|
||||
original_bytes: Some(original_main.to_vec()),
|
||||
written_bytes: written_text.as_bytes().to_vec(),
|
||||
},
|
||||
GlobalStateWrite {
|
||||
path: backup.clone(),
|
||||
original_bytes: Some(original_backup.to_vec()),
|
||||
written_bytes: written_text.into_bytes(),
|
||||
},
|
||||
];
|
||||
|
||||
for write in writes.iter().rev() {
|
||||
restore_global_write(write).expect("restore global write");
|
||||
}
|
||||
assert_eq!(fs::read(&main).expect("read restored main"), original_main);
|
||||
assert_eq!(
|
||||
fs::read(&backup).expect("read restored backup"),
|
||||
original_backup
|
||||
);
|
||||
|
||||
fs::remove_dir_all(dir).expect("remove test directory");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
mod app_server;
|
||||
mod backup;
|
||||
mod catalog;
|
||||
mod delete;
|
||||
mod global_state;
|
||||
mod storage;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::global_state::{normalize_workspace_path, projectless_thread_ids};
|
||||
use super::global_state::normalize_workspace_path;
|
||||
use super::types::{RolloutScan, SessionFileChange, SessionPreview, SqliteScan};
|
||||
use crate::error::{CodexxError, Result};
|
||||
use crate::file_io::{
|
||||
@@ -9,7 +9,7 @@ use crate::sqlite_utils::{sql_select_column, sqlite_has_table, table_column_set}
|
||||
use crate::{config_path, string_value};
|
||||
use rusqlite::{Connection, OpenFlags};
|
||||
use serde_json::Value;
|
||||
use std::collections::HashSet;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::fs;
|
||||
use std::io::Read;
|
||||
use std::path::{Path, PathBuf};
|
||||
@@ -35,26 +35,130 @@ fn is_rollout_file(path: &Path) -> bool {
|
||||
.is_some_and(|name| name.starts_with("rollout-") && name.ends_with(".jsonl"))
|
||||
}
|
||||
|
||||
fn collect_rollout_paths(root: &Path, out: &mut Vec<PathBuf>, warnings: &mut Vec<String>) {
|
||||
let Ok(entries) = fs::read_dir(root) else {
|
||||
if root.exists() {
|
||||
warnings.push(format!("无法读取目录: {}", root.display()));
|
||||
pub(super) fn rollout_filename_matches_id(path: &Path, id: &str) -> bool {
|
||||
path.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.is_some_and(|name| {
|
||||
name.ends_with(&format!("-{id}.jsonl")) || name.ends_with(&format!("-{id}.jsonl.zst"))
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn canonical_rollout_storage_roots(codex_dir: &Path) -> Vec<PathBuf> {
|
||||
[
|
||||
codex_dir.join("sessions"),
|
||||
codex_dir.join("archived_sessions"),
|
||||
]
|
||||
.into_iter()
|
||||
.filter_map(|root| root.canonicalize().ok())
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(super) fn is_canonical_rollout_storage_path(codex_dir: &Path, path: &Path) -> bool {
|
||||
canonical_rollout_storage_roots(codex_dir)
|
||||
.iter()
|
||||
.any(|root| path.starts_with(root))
|
||||
}
|
||||
|
||||
fn referenced_rollout_paths(
|
||||
codex_dir: &Path,
|
||||
rollout_paths_by_thread_id: &HashMap<String, String>,
|
||||
failures: &mut Vec<String>,
|
||||
) -> HashMap<PathBuf, HashSet<String>> {
|
||||
let mut referenced = HashMap::<PathBuf, HashSet<String>>::new();
|
||||
for (thread_id, value) in rollout_paths_by_thread_id {
|
||||
let raw = PathBuf::from(value.trim());
|
||||
let path = if raw.is_absolute() {
|
||||
raw
|
||||
} else {
|
||||
codex_dir.join(raw)
|
||||
};
|
||||
let metadata = match fs::symlink_metadata(&path) {
|
||||
Ok(metadata) => metadata,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
||||
failures.push(format!(
|
||||
"活动 SQLite 引用的会话文件不存在: {}",
|
||||
path.display()
|
||||
));
|
||||
continue;
|
||||
}
|
||||
Err(error) => {
|
||||
failures.push(format!(
|
||||
"无法检查活动会话文件: {} ({error})",
|
||||
path.display()
|
||||
));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if metadata.file_type().is_symlink() || !metadata.is_file() || !is_rollout_file(&path) {
|
||||
failures.push(format!(
|
||||
"活动 SQLite 引用了不受支持的会话文件: {}",
|
||||
path.display()
|
||||
));
|
||||
continue;
|
||||
}
|
||||
let canonical = match path.canonicalize() {
|
||||
Ok(canonical) => canonical,
|
||||
Err(error) => {
|
||||
failures.push(format!(
|
||||
"无法解析活动会话文件路径: {} ({error})",
|
||||
path.display()
|
||||
));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if !is_canonical_rollout_storage_path(codex_dir, &canonical) {
|
||||
failures.push(format!(
|
||||
"活动 SQLite 引用的会话文件超出 Codex 会话目录: {}",
|
||||
path.display()
|
||||
));
|
||||
continue;
|
||||
}
|
||||
let expected_thread_ids = referenced.entry(canonical.clone()).or_default();
|
||||
expected_thread_ids.insert(thread_id.clone());
|
||||
if expected_thread_ids.len() > 1 {
|
||||
failures.push(format!(
|
||||
"活动 SQLite 的多个线程引用了同一个会话文件: {}",
|
||||
canonical.display()
|
||||
));
|
||||
}
|
||||
}
|
||||
referenced
|
||||
}
|
||||
|
||||
fn collect_rollout_paths(root: &Path, out: &mut Vec<PathBuf>, failures: &mut Vec<String>) {
|
||||
let entries = match fs::read_dir(root) {
|
||||
Ok(entries) => entries,
|
||||
Err(error) => {
|
||||
if error.kind() != std::io::ErrorKind::NotFound {
|
||||
failures.push(format!("无法读取会话目录: {} ({error})", root.display()));
|
||||
}
|
||||
return;
|
||||
}
|
||||
return;
|
||||
};
|
||||
for entry in entries {
|
||||
let Ok(entry) = entry else {
|
||||
continue;
|
||||
let entry = match entry {
|
||||
Ok(entry) => entry,
|
||||
Err(error) => {
|
||||
failures.push(format!("无法读取会话目录项: {} ({error})", root.display()));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let path = entry.path();
|
||||
let Ok(file_type) = entry.file_type() else {
|
||||
continue;
|
||||
let file_type = match entry.file_type() {
|
||||
Ok(file_type) => file_type,
|
||||
Err(error) => {
|
||||
failures.push(format!(
|
||||
"无法读取会话文件类型: {} ({error})",
|
||||
path.display()
|
||||
));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if file_type.is_symlink() {
|
||||
continue;
|
||||
}
|
||||
if file_type.is_dir() {
|
||||
collect_rollout_paths(&path, out, warnings);
|
||||
collect_rollout_paths(&path, out, failures);
|
||||
} else if file_type.is_file() && is_rollout_file(&path) {
|
||||
out.push(path);
|
||||
}
|
||||
@@ -77,27 +181,87 @@ fn is_locked_io_error(error: &std::io::Error) -> bool {
|
||||
}
|
||||
|
||||
pub(crate) fn scan_rollouts(codex_dir: &Path, target_provider: &str) -> Result<RolloutScan> {
|
||||
scan_rollouts_with_thread_filter(codex_dir, target_provider, None, None)
|
||||
}
|
||||
|
||||
pub(super) fn scan_rollouts_for_thread_ids(
|
||||
codex_dir: &Path,
|
||||
target_provider: &str,
|
||||
thread_ids: &HashSet<String>,
|
||||
rollout_paths_by_thread_id: &HashMap<String, String>,
|
||||
) -> Result<RolloutScan> {
|
||||
scan_rollouts_with_thread_filter(
|
||||
codex_dir,
|
||||
target_provider,
|
||||
Some(thread_ids),
|
||||
Some(rollout_paths_by_thread_id),
|
||||
)
|
||||
}
|
||||
|
||||
fn scan_rollouts_with_thread_filter(
|
||||
codex_dir: &Path,
|
||||
target_provider: &str,
|
||||
allowed_thread_ids: Option<&HashSet<String>>,
|
||||
rollout_paths_by_thread_id: Option<&HashMap<String, String>>,
|
||||
) -> Result<RolloutScan> {
|
||||
let mut paths = Vec::new();
|
||||
let mut scan = RolloutScan::default();
|
||||
let mut referenced = HashMap::new();
|
||||
for dir in ["sessions", "archived_sessions"] {
|
||||
collect_rollout_paths(&codex_dir.join(dir), &mut paths, &mut scan.warnings);
|
||||
collect_rollout_paths(&codex_dir.join(dir), &mut paths, &mut scan.scan_failures);
|
||||
}
|
||||
paths.sort();
|
||||
scan.discovered_rollout_files = paths.len();
|
||||
if let Some(allowed_thread_ids) = allowed_thread_ids {
|
||||
let empty_rollout_paths = HashMap::new();
|
||||
let rollout_paths_by_thread_id = rollout_paths_by_thread_id.unwrap_or(&empty_rollout_paths);
|
||||
let explicitly_referenced_thread_ids = rollout_paths_by_thread_id
|
||||
.keys()
|
||||
.filter(|id| allowed_thread_ids.contains(*id))
|
||||
.cloned()
|
||||
.collect::<HashSet<_>>();
|
||||
referenced = referenced_rollout_paths(
|
||||
codex_dir,
|
||||
rollout_paths_by_thread_id,
|
||||
&mut scan.scan_failures,
|
||||
);
|
||||
paths.retain(|path| {
|
||||
let is_unreferenced_thread_rollout = allowed_thread_ids
|
||||
.iter()
|
||||
.filter(|id| !explicitly_referenced_thread_ids.contains(*id))
|
||||
.any(|id| rollout_filename_matches_id(path, id));
|
||||
is_unreferenced_thread_rollout
|
||||
|| path
|
||||
.canonicalize()
|
||||
.is_ok_and(|canonical| referenced.contains_key(&canonical))
|
||||
});
|
||||
}
|
||||
scan.rollout_files = paths.len();
|
||||
|
||||
for path in paths {
|
||||
let expected_thread_ids = path
|
||||
.canonicalize()
|
||||
.ok()
|
||||
.and_then(|canonical| referenced.get(&canonical));
|
||||
let text = match fs::read_to_string(&path) {
|
||||
Ok(text) => text,
|
||||
Err(error) if is_locked_io_error(&error) => {
|
||||
scan.warnings
|
||||
.push(format!("跳过被占用/无权限会话文件: {}", path.display()));
|
||||
Err(error) => {
|
||||
let reason = if is_locked_io_error(&error) {
|
||||
"会话文件被占用或无权限读取"
|
||||
} else {
|
||||
"无法读取会话文件"
|
||||
};
|
||||
scan.scan_failures
|
||||
.push(format!("{reason}: {} ({error})", path.display()));
|
||||
continue;
|
||||
}
|
||||
Err(error) => return Err(io_err(&path, error)),
|
||||
};
|
||||
let mut next_text = String::with_capacity(text.len());
|
||||
let mut rewrite_needed = false;
|
||||
let mut file_session_meta_count = 0usize;
|
||||
let mut file_mismatched_session_meta = 0usize;
|
||||
let mut invalid_json_lines = 0usize;
|
||||
let mut invalid_session_meta = 0usize;
|
||||
let mut thread_id = None;
|
||||
let mut cwd = None;
|
||||
|
||||
@@ -111,7 +275,6 @@ pub(crate) fn scan_rollouts(codex_dir: &Path, target_provider: &str) -> Result<R
|
||||
record.get_mut("payload").and_then(Value::as_object_mut)
|
||||
{
|
||||
file_session_meta_count += 1;
|
||||
scan.session_meta_count += 1;
|
||||
if thread_id.is_none() {
|
||||
thread_id = payload
|
||||
.get("id")
|
||||
@@ -134,26 +297,69 @@ pub(crate) fn scan_rollouts(codex_dir: &Path, target_provider: &str) -> Result<R
|
||||
next_line = serde_json::to_string(&record)
|
||||
.map_err(|error| json_err(&path, error))?;
|
||||
rewrite_needed = true;
|
||||
scan.mismatched_session_meta += 1;
|
||||
file_mismatched_session_meta += 1;
|
||||
}
|
||||
} else {
|
||||
invalid_session_meta += 1;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
invalid_json_lines += 1;
|
||||
}
|
||||
}
|
||||
next_text.push_str(&next_line);
|
||||
next_text.push_str(line_ending);
|
||||
}
|
||||
|
||||
if invalid_json_lines > 0 {
|
||||
scan.scan_failures.push(format!(
|
||||
"会话文件包含 {invalid_json_lines} 行无法解析的 JSON: {}",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
if invalid_session_meta > 0 {
|
||||
scan.scan_failures.push(format!(
|
||||
"会话文件包含 {invalid_session_meta} 条无法读取的 session_meta: {}",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
|
||||
if file_session_meta_count == 0 {
|
||||
if expected_thread_ids.is_some() {
|
||||
scan.scan_failures.push(format!(
|
||||
"活动 SQLite 引用的会话文件缺少 session_meta: {}",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if let Some(thread_id) = thread_id {
|
||||
if let Some(cwd) = cwd {
|
||||
scan.cwd_by_thread_id.insert(thread_id, cwd);
|
||||
let Some(thread_id) = thread_id else {
|
||||
scan.scan_failures.push(format!(
|
||||
"会话文件的 session_meta 缺少 id: {}",
|
||||
path.display()
|
||||
));
|
||||
continue;
|
||||
};
|
||||
if let Some(expected_thread_ids) = expected_thread_ids {
|
||||
if expected_thread_ids.len() != 1 || !expected_thread_ids.contains(&thread_id) {
|
||||
scan.scan_failures.push(format!(
|
||||
"活动 SQLite 引用的会话文件与线程 ID 不一致: {}",
|
||||
path.display()
|
||||
));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if allowed_thread_ids.is_some_and(|allowed| !allowed.contains(&thread_id)) {
|
||||
continue;
|
||||
}
|
||||
scan.session_meta_count += file_session_meta_count;
|
||||
if let Some(cwd) = cwd {
|
||||
scan.cwd_by_thread_id.insert(thread_id.clone(), cwd);
|
||||
}
|
||||
if rewrite_needed {
|
||||
scan.mismatched_rollouts += 1;
|
||||
scan.mismatched_session_meta += file_mismatched_session_meta;
|
||||
scan.mismatched_thread_ids.insert(thread_id);
|
||||
scan.changes.push(SessionFileChange {
|
||||
original_mtime: fs::metadata(&path)
|
||||
.and_then(|metadata| metadata.modified())
|
||||
@@ -164,9 +370,6 @@ pub(crate) fn scan_rollouts(codex_dir: &Path, target_provider: &str) -> Result<R
|
||||
});
|
||||
}
|
||||
}
|
||||
let projectless = projectless_thread_ids(&codex_dir.join(".codex-global-state.json"))?;
|
||||
scan.cwd_by_thread_id
|
||||
.retain(|thread_id, _| !projectless.contains(thread_id));
|
||||
Ok(scan)
|
||||
}
|
||||
|
||||
@@ -307,6 +510,7 @@ fn configured_sqlite_home(codex_dir: &Path) -> Option<PathBuf> {
|
||||
#[derive(Debug)]
|
||||
struct SqliteStorageRoot {
|
||||
path: PathBuf,
|
||||
is_active: bool,
|
||||
active_priority: usize,
|
||||
session_priority: usize,
|
||||
allow_custom_names: bool,
|
||||
@@ -314,9 +518,11 @@ struct SqliteStorageRoot {
|
||||
|
||||
fn sqlite_storage_roots(codex_dir: &Path) -> Vec<SqliteStorageRoot> {
|
||||
let mut roots = Vec::new();
|
||||
if let Some(configured) = configured_sqlite_home(codex_dir) {
|
||||
let configured = configured_sqlite_home(codex_dir);
|
||||
if let Some(configured) = configured.as_ref() {
|
||||
roots.push(SqliteStorageRoot {
|
||||
path: configured,
|
||||
path: configured.clone(),
|
||||
is_active: true,
|
||||
active_priority: 0,
|
||||
session_priority: 0,
|
||||
allow_custom_names: true,
|
||||
@@ -324,12 +530,14 @@ fn sqlite_storage_roots(codex_dir: &Path) -> Vec<SqliteStorageRoot> {
|
||||
}
|
||||
roots.push(SqliteStorageRoot {
|
||||
path: codex_dir.to_path_buf(),
|
||||
is_active: configured.is_none(),
|
||||
active_priority: 1,
|
||||
session_priority: 2,
|
||||
allow_custom_names: false,
|
||||
});
|
||||
roots.push(SqliteStorageRoot {
|
||||
path: codex_dir.join("sqlite"),
|
||||
is_active: false,
|
||||
active_priority: 2,
|
||||
session_priority: 1,
|
||||
allow_custom_names: true,
|
||||
@@ -364,6 +572,7 @@ pub(super) struct SqliteDiscovery {
|
||||
pub(super) session_paths: Vec<PathBuf>,
|
||||
pub(super) related_paths: Vec<PathBuf>,
|
||||
pub(super) unreadable_paths: Vec<PathBuf>,
|
||||
pub(super) active_scan_failures: Vec<String>,
|
||||
}
|
||||
|
||||
impl SqliteDiscovery {
|
||||
@@ -379,6 +588,7 @@ impl SqliteDiscovery {
|
||||
#[derive(Debug)]
|
||||
struct DiscoveredSqlite {
|
||||
path: PathBuf,
|
||||
is_active: bool,
|
||||
active_priority: usize,
|
||||
session_priority: usize,
|
||||
state_version: Option<u64>,
|
||||
@@ -462,6 +672,17 @@ fn primary_paths_first(primary: &[PathBuf], paths: &[PathBuf]) -> Vec<PathBuf> {
|
||||
ordered
|
||||
}
|
||||
|
||||
fn compare_sqlite_filenames(left: &Path, right: &Path) -> std::cmp::Ordering {
|
||||
left.file_name()
|
||||
.is_none_or(|name| name != std::ffi::OsStr::new("codex-dev.db"))
|
||||
.cmp(
|
||||
&right
|
||||
.file_name()
|
||||
.is_none_or(|name| name != std::ffi::OsStr::new("codex-dev.db")),
|
||||
)
|
||||
.then_with(|| left.file_name().cmp(&right.file_name()))
|
||||
}
|
||||
|
||||
pub(super) fn ensure_sqlite_discovery_writable(discovery: &SqliteDiscovery) -> Result<()> {
|
||||
if discovery.unreadable_paths.is_empty() {
|
||||
Ok(())
|
||||
@@ -484,18 +705,7 @@ fn ordered_database_paths(
|
||||
matches.sort_by(|left, right| {
|
||||
priority(left)
|
||||
.cmp(&priority(right))
|
||||
.then_with(|| {
|
||||
left.path
|
||||
.file_name()
|
||||
.is_none_or(|name| name != std::ffi::OsStr::new("codex-dev.db"))
|
||||
.cmp(
|
||||
&right
|
||||
.path
|
||||
.file_name()
|
||||
.is_none_or(|name| name != std::ffi::OsStr::new("codex-dev.db")),
|
||||
)
|
||||
})
|
||||
.then_with(|| left.path.file_name().cmp(&right.path.file_name()))
|
||||
.then_with(|| compare_sqlite_filenames(&left.path, &right.path))
|
||||
});
|
||||
matches
|
||||
.into_iter()
|
||||
@@ -507,20 +717,56 @@ pub(super) fn discover_sqlite_databases(codex_dir: &Path) -> SqliteDiscovery {
|
||||
let mut databases = Vec::new();
|
||||
let mut seen_paths = HashSet::new();
|
||||
let mut unreadable_paths = Vec::new();
|
||||
let mut active_scan_failures = Vec::new();
|
||||
|
||||
for root in sqlite_storage_roots(codex_dir) {
|
||||
let Ok(entries) = fs::read_dir(&root.path) else {
|
||||
continue;
|
||||
let entries = match fs::read_dir(&root.path) {
|
||||
Ok(entries) => entries,
|
||||
Err(error) => {
|
||||
if root.is_active && error.kind() != std::io::ErrorKind::NotFound {
|
||||
active_scan_failures.push(format!(
|
||||
"无法读取当前会话数据库目录: {} ({error})",
|
||||
root.path.display()
|
||||
));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let mut paths = entries
|
||||
.flatten()
|
||||
.filter_map(|entry| {
|
||||
let file_type = entry.file_type().ok()?;
|
||||
(file_type.is_file() && !file_type.is_symlink()).then(|| entry.path())
|
||||
})
|
||||
.filter(|path| is_sqlite_file(path))
|
||||
.filter(|path| root.allow_custom_names || is_root_codex_sqlite_file(path))
|
||||
.collect::<Vec<_>>();
|
||||
let mut paths = Vec::new();
|
||||
for entry in entries {
|
||||
let entry = match entry {
|
||||
Ok(entry) => entry,
|
||||
Err(error) => {
|
||||
if root.is_active {
|
||||
active_scan_failures.push(format!(
|
||||
"无法读取当前会话数据库目录项: {} ({error})",
|
||||
root.path.display()
|
||||
));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let path = entry.path();
|
||||
let file_type = match entry.file_type() {
|
||||
Ok(file_type) => file_type,
|
||||
Err(error) => {
|
||||
if root.is_active {
|
||||
active_scan_failures.push(format!(
|
||||
"无法读取当前会话数据库文件类型: {} ({error})",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if file_type.is_file()
|
||||
&& !file_type.is_symlink()
|
||||
&& is_sqlite_file(&path)
|
||||
&& (root.allow_custom_names || is_root_codex_sqlite_file(&path))
|
||||
{
|
||||
paths.push(path);
|
||||
}
|
||||
}
|
||||
paths.sort();
|
||||
|
||||
for path in paths {
|
||||
@@ -529,7 +775,18 @@ pub(super) fn discover_sqlite_databases(codex_dir: &Path) -> SqliteDiscovery {
|
||||
}
|
||||
let codex_named = is_root_codex_sqlite_file(&path);
|
||||
let Some(tables) = sqlite_table_names(&path) else {
|
||||
if has_sqlite_header(&path) {
|
||||
let header_is_sqlite = has_sqlite_header(&path);
|
||||
let read_error = fs::File::open(&path).err();
|
||||
if header_is_sqlite || read_error.is_some() {
|
||||
if root.is_active {
|
||||
let detail = read_error
|
||||
.map(|error| format!(" ({error})"))
|
||||
.unwrap_or_default();
|
||||
active_scan_failures.push(format!(
|
||||
"无法读取当前活动会话数据库: {}{detail}",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
unreadable_paths.push(path);
|
||||
}
|
||||
continue;
|
||||
@@ -544,6 +801,7 @@ pub(super) fn discover_sqlite_databases(codex_dir: &Path) -> SqliteDiscovery {
|
||||
databases.push(DiscoveredSqlite {
|
||||
state_version: sqlite_state_version(&path),
|
||||
path,
|
||||
is_active: root.is_active,
|
||||
active_priority: root.active_priority,
|
||||
session_priority: root.session_priority,
|
||||
has_threads,
|
||||
@@ -555,12 +813,12 @@ pub(super) fn discover_sqlite_databases(codex_dir: &Path) -> SqliteDiscovery {
|
||||
|
||||
let active_path = databases
|
||||
.iter()
|
||||
.filter(|database| database.has_threads && database.state_version.is_some())
|
||||
.filter(|database| database.is_active && database.has_threads)
|
||||
.min_by(|left, right| {
|
||||
left.active_priority
|
||||
.cmp(&right.active_priority)
|
||||
.then_with(|| right.state_version.cmp(&left.state_version))
|
||||
.then_with(|| right.path.file_name().cmp(&left.path.file_name()))
|
||||
.then_with(|| compare_sqlite_filenames(&left.path, &right.path))
|
||||
})
|
||||
.map(|database| database.path.clone());
|
||||
|
||||
@@ -582,6 +840,7 @@ pub(super) fn discover_sqlite_databases(codex_dir: &Path) -> SqliteDiscovery {
|
||||
|database| database.active_priority,
|
||||
),
|
||||
unreadable_paths,
|
||||
active_scan_failures,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -720,6 +979,7 @@ pub(super) fn scan_sqlite_with_paths(
|
||||
) -> Result<SqliteScan> {
|
||||
let mut scan = SqliteScan::default();
|
||||
let mut thread_ids = HashSet::new();
|
||||
let mut rollout_paths_by_thread_id = HashMap::new();
|
||||
let mut subagent_ids = HashSet::new();
|
||||
let mut mismatched_ids = HashSet::new();
|
||||
for path in sqlite_paths {
|
||||
@@ -729,8 +989,8 @@ pub(super) fn scan_sqlite_with_paths(
|
||||
) {
|
||||
Ok(conn) => conn,
|
||||
Err(e) => {
|
||||
scan.warnings
|
||||
.push(format!("无法读取 SQLite: {} ({e})", path.display()));
|
||||
scan.scan_failures
|
||||
.push(format!("无法读取当前活动 SQLite: {} ({e})", path.display()));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
@@ -739,15 +999,17 @@ pub(super) fn scan_sqlite_with_paths(
|
||||
}
|
||||
let cols = table_column_set(&conn, "threads")?;
|
||||
if !cols.contains("id") || !cols.contains("model_provider") {
|
||||
scan.warnings.push(format!(
|
||||
"SQLite threads 缺少 id 或 model_provider 字段: {}",
|
||||
scan.scan_failures.push(format!(
|
||||
"当前活动 SQLite 的 threads 表缺少 id 或 model_provider 字段: {}",
|
||||
path.display()
|
||||
));
|
||||
continue;
|
||||
}
|
||||
scan.sqlite_dbs += 1;
|
||||
let cwd_col = sql_select_column(&cols, "cwd", "NULL");
|
||||
let query = format!("SELECT \"id\", \"model_provider\", {cwd_col} FROM threads");
|
||||
let rollout_col = sql_select_column(&cols, "rollout_path", "NULL");
|
||||
let query =
|
||||
format!("SELECT \"id\", \"model_provider\", {cwd_col}, {rollout_col} FROM threads");
|
||||
let mut stmt = conn
|
||||
.prepare(&query)
|
||||
.map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
@@ -757,12 +1019,20 @@ pub(super) fn scan_sqlite_with_paths(
|
||||
row.get::<_, String>(0)?,
|
||||
row.get::<_, Option<String>>(1)?,
|
||||
row.get::<_, Option<String>>(2)?,
|
||||
row.get::<_, Option<String>>(3)?,
|
||||
))
|
||||
})
|
||||
.map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
for row in rows {
|
||||
let (id, provider, cwd) = row.map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
let (id, provider, cwd, rollout_path) =
|
||||
row.map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
thread_ids.insert(id.clone());
|
||||
if let Some(rollout_path) = rollout_path
|
||||
.map(|path| path.trim().to_string())
|
||||
.filter(|path| !path.is_empty())
|
||||
{
|
||||
rollout_paths_by_thread_id.insert(id.clone(), rollout_path);
|
||||
}
|
||||
if sqlite_thread_needs_alignment(
|
||||
rollouts,
|
||||
target_provider,
|
||||
@@ -783,6 +1053,9 @@ pub(super) fn scan_sqlite_with_paths(
|
||||
scan.subagent_threads = subagent_ids.len();
|
||||
scan.top_level_threads = thread_ids.len().saturating_sub(subagent_ids.len());
|
||||
scan.mismatched_threads = mismatched_ids.len();
|
||||
scan.thread_ids = thread_ids;
|
||||
scan.rollout_paths_by_thread_id = rollout_paths_by_thread_id;
|
||||
scan.mismatched_thread_ids = mismatched_ids;
|
||||
Ok(scan)
|
||||
}
|
||||
|
||||
@@ -887,16 +1160,17 @@ pub(super) fn list_session_previews_with_paths(
|
||||
.as_ref()
|
||||
.map(|v| v.trim().to_string())
|
||||
.filter(|v| !v.is_empty());
|
||||
let needs_sync = sqlite_thread_needs_alignment(
|
||||
rollouts,
|
||||
target_provider,
|
||||
&SqliteThreadIndexState {
|
||||
thread_id: &id,
|
||||
provider: normalized_provider.as_deref(),
|
||||
cwd: normalized_cwd.as_deref(),
|
||||
cwd_column: cols.contains("cwd"),
|
||||
},
|
||||
);
|
||||
let needs_sync = rollouts.mismatched_thread_ids.contains(&id)
|
||||
|| sqlite_thread_needs_alignment(
|
||||
rollouts,
|
||||
target_provider,
|
||||
&SqliteThreadIndexState {
|
||||
thread_id: &id,
|
||||
provider: normalized_provider.as_deref(),
|
||||
cwd: normalized_cwd.as_deref(),
|
||||
cwd_column: cols.contains("cwd"),
|
||||
},
|
||||
);
|
||||
let is_subagent = subagent_thread_ids.contains(&id);
|
||||
Ok(SessionPreview {
|
||||
id,
|
||||
@@ -979,10 +1253,15 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn root_state_10_is_listed_and_synchronized() {
|
||||
fn root_state_10_honors_an_explicit_provider_target() {
|
||||
let codex_dir = temp_codex_dir("root-state-10");
|
||||
let database = codex_dir.join("state_10.sqlite");
|
||||
let id = "019f6000-0000-7000-8000-000000000301";
|
||||
fs::write(
|
||||
codex_dir.join("config.toml"),
|
||||
"model_provider = \"custom\"\n",
|
||||
)
|
||||
.expect("write shared provider config");
|
||||
create_thread_database(&database, id, "openai");
|
||||
|
||||
let discovery = discover_sqlite_databases(&codex_dir);
|
||||
@@ -999,12 +1278,21 @@ mod tests {
|
||||
assert_eq!(sessions.len(), 1);
|
||||
assert_eq!(sessions[0].id, id);
|
||||
|
||||
let status = crate::sessions::sync::session_sync_status_inner(
|
||||
Some(codex_dir.display().to_string()),
|
||||
Some("openai".to_string()),
|
||||
)
|
||||
.expect("read explicit provider status");
|
||||
assert_eq!(status.target_provider, "openai");
|
||||
assert!(!status.needs_sync);
|
||||
|
||||
let result = crate::sessions::sync::sync_sessions_provider_inner(
|
||||
Some(codex_dir.display().to_string()),
|
||||
Some("custom".to_string()),
|
||||
Some("openai".to_string()),
|
||||
)
|
||||
.expect("sync root state_10");
|
||||
assert_eq!(result.updated_threads, 1);
|
||||
assert_eq!(result.status.target_provider, "openai");
|
||||
assert_eq!(result.updated_threads, 0);
|
||||
let provider: String = Connection::open(&database)
|
||||
.expect("reopen state_10")
|
||||
.query_row(
|
||||
@@ -1013,7 +1301,92 @@ mod tests {
|
||||
|row| row.get(0),
|
||||
)
|
||||
.expect("read updated provider");
|
||||
assert_eq!(provider, "custom");
|
||||
assert_eq!(provider, "openai");
|
||||
|
||||
let _ = fs::remove_dir_all(codex_dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn root_codex_dev_database_is_active_without_state_database() {
|
||||
let codex_dir = temp_codex_dir("root-codex-dev");
|
||||
let database = codex_dir.join("codex-dev.db");
|
||||
create_thread_database(&database, "019f6000-0000-7000-8000-000000000302", "openai");
|
||||
|
||||
let discovery = discover_sqlite_databases(&codex_dir);
|
||||
assert_eq!(discovery.active_paths, vec![database.clone()]);
|
||||
assert_eq!(discovery.thread_paths, vec![database]);
|
||||
|
||||
let _ = fs::remove_dir_all(codex_dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configured_sqlite_home_custom_database_is_active() {
|
||||
let codex_dir = temp_codex_dir("configured-custom-active");
|
||||
fs::write(
|
||||
codex_dir.join("config.toml"),
|
||||
"sqlite_home = \"session-store\"\n",
|
||||
)
|
||||
.expect("write sqlite_home config");
|
||||
let database = codex_dir.join("session-store/custom-name.db");
|
||||
let later_database = codex_dir.join("session-store/later-name.sqlite3");
|
||||
create_thread_database(&database, "019f6000-0000-7000-8000-000000000303", "openai");
|
||||
create_thread_database(
|
||||
&later_database,
|
||||
"019f6000-0000-7000-8000-000000000304",
|
||||
"openai",
|
||||
);
|
||||
|
||||
let discovery = discover_sqlite_databases(&codex_dir);
|
||||
assert_eq!(discovery.active_paths, vec![database.clone()]);
|
||||
assert_eq!(discovery.thread_paths, vec![database, later_database]);
|
||||
|
||||
let _ = fs::remove_dir_all(codex_dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configured_sqlite_home_prefers_codex_dev_before_custom_database() {
|
||||
let codex_dir = temp_codex_dir("configured-codex-dev-precedence");
|
||||
fs::write(
|
||||
codex_dir.join("config.toml"),
|
||||
"sqlite_home = \"session-store\"\n",
|
||||
)
|
||||
.expect("write sqlite_home config");
|
||||
let storage = codex_dir.join("session-store");
|
||||
let codex_dev = storage.join("codex-dev.db");
|
||||
let custom = storage.join("custom-name.db");
|
||||
create_thread_database(&codex_dev, "019f6000-0000-7000-8000-000000000305", "openai");
|
||||
create_thread_database(&custom, "019f6000-0000-7000-8000-000000000306", "openai");
|
||||
|
||||
let discovery = discover_sqlite_databases(&codex_dir);
|
||||
assert_eq!(discovery.active_paths, vec![codex_dev]);
|
||||
|
||||
let _ = fs::remove_dir_all(codex_dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configured_sqlite_home_prefers_latest_state_database() {
|
||||
let codex_dir = temp_codex_dir("configured-state-precedence");
|
||||
fs::write(
|
||||
codex_dir.join("config.toml"),
|
||||
"sqlite_home = \"session-store\"\n",
|
||||
)
|
||||
.expect("write sqlite_home config");
|
||||
let storage = codex_dir.join("session-store");
|
||||
let custom = storage.join("custom-name.db");
|
||||
let codex_dev = storage.join("codex-dev.db");
|
||||
let state_5 = storage.join("state_5.sqlite");
|
||||
let state_10 = storage.join("state_10.sqlite");
|
||||
for (database, id) in [
|
||||
(&custom, "019f6000-0000-7000-8000-000000000307"),
|
||||
(&codex_dev, "019f6000-0000-7000-8000-000000000308"),
|
||||
(&state_5, "019f6000-0000-7000-8000-000000000309"),
|
||||
(&state_10, "019f6000-0000-7000-8000-000000000310"),
|
||||
] {
|
||||
create_thread_database(database, id, "openai");
|
||||
}
|
||||
|
||||
let discovery = discover_sqlite_databases(&codex_dir);
|
||||
assert_eq!(discovery.active_paths, vec![state_10]);
|
||||
|
||||
let _ = fs::remove_dir_all(codex_dir);
|
||||
}
|
||||
@@ -1088,10 +1461,7 @@ mod tests {
|
||||
fn unrelated_root_sqlite_is_not_classified_as_codex_storage() {
|
||||
let codex_dir = temp_codex_dir("unrelated-root");
|
||||
let unrelated = codex_dir.join("unrelated.sqlite");
|
||||
let conn = Connection::open(&unrelated).expect("create unrelated sqlite");
|
||||
conn.execute("CREATE TABLE logs (thread_id TEXT)", [])
|
||||
.expect("create unrelated logs table");
|
||||
drop(conn);
|
||||
create_thread_database(&unrelated, "019f6000-0000-7000-8000-000000000341", "openai");
|
||||
|
||||
let discovery = discover_sqlite_databases(&codex_dir);
|
||||
assert!(!discovery.related_paths.contains(&unrelated));
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,6 @@
|
||||
use super::global_state::{
|
||||
apply_global_state_update_with_journal, restore_global_write, GlobalStateWrite,
|
||||
use super::catalog::{
|
||||
apply_catalog_updates, catalog_columns, create_catalog_rollback_tables,
|
||||
restore_catalog_updates, CatalogRepairThread,
|
||||
};
|
||||
use super::storage::{apply_session_changes, restore_session_changes};
|
||||
use super::types::{RolloutScan, SessionFileChange};
|
||||
@@ -7,7 +8,7 @@ use crate::error::{CodexxError, Result};
|
||||
use crate::file_io::io_err;
|
||||
use crate::sqlite_utils::{sqlite_has_table, table_column_set};
|
||||
use rusqlite::{Connection, OpenFlags};
|
||||
use std::collections::HashSet;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -15,16 +16,18 @@ use std::time::Duration;
|
||||
pub(super) struct SqliteUpdateCounts {
|
||||
provider_rows: usize,
|
||||
cwd_rows: usize,
|
||||
catalog_insert_rows: usize,
|
||||
}
|
||||
|
||||
impl SqliteUpdateCounts {
|
||||
pub(super) fn total(&self) -> usize {
|
||||
self.provider_rows + self.cwd_rows
|
||||
self.provider_rows + self.cwd_rows + self.catalog_insert_rows
|
||||
}
|
||||
|
||||
fn add(&mut self, other: Self) {
|
||||
self.provider_rows += other.provider_rows;
|
||||
self.cwd_rows += other.cwd_rows;
|
||||
self.catalog_insert_rows += other.catalog_insert_rows;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,7 +35,6 @@ impl SqliteUpdateCounts {
|
||||
pub(super) struct MutationJournal {
|
||||
applied_rollouts: Vec<SessionFileChange>,
|
||||
sqlite_restore_attempts: Vec<SqliteRestoreAttempt>,
|
||||
global_writes: Vec<GlobalStateWrite>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -45,7 +47,8 @@ pub(super) struct PendingSqliteUpdate {
|
||||
path: PathBuf,
|
||||
conn: Connection,
|
||||
observer: Connection,
|
||||
columns: HashSet<String>,
|
||||
thread_columns: HashSet<String>,
|
||||
catalog_columns: HashSet<String>,
|
||||
counts: SqliteUpdateCounts,
|
||||
transaction_open: bool,
|
||||
}
|
||||
@@ -80,7 +83,8 @@ fn create_sqlite_rollback_table(conn: &Connection) -> Result<()> {
|
||||
cwd_changed INTEGER NOT NULL DEFAULT 0
|
||||
);",
|
||||
)
|
||||
.map_err(|error| CodexxError::Database(error.to_string()))
|
||||
.map_err(|error| CodexxError::Database(error.to_string()))?;
|
||||
create_catalog_rollback_tables(conn)
|
||||
}
|
||||
|
||||
pub(super) fn prepare_sqlite_updates(sqlite_paths: &[PathBuf]) -> Result<Vec<PendingSqliteUpdate>> {
|
||||
@@ -107,11 +111,17 @@ pub(super) fn prepare_sqlite_updates(sqlite_paths: &[PathBuf]) -> Result<Vec<Pen
|
||||
})?;
|
||||
conn.busy_timeout(Duration::from_secs(5))
|
||||
.map_err(|error| CodexxError::Database(error.to_string()))?;
|
||||
if !sqlite_has_table(&conn, "threads")? {
|
||||
continue;
|
||||
}
|
||||
let columns = table_column_set(&conn, "threads")?;
|
||||
if !columns.contains("id") || !columns.contains("model_provider") {
|
||||
let thread_columns = if sqlite_has_table(&conn, "threads")? {
|
||||
table_column_set(&conn, "threads")?
|
||||
} else {
|
||||
HashSet::new()
|
||||
};
|
||||
let catalog_columns = catalog_columns(&conn)?;
|
||||
let supports_thread_updates =
|
||||
thread_columns.contains("id") && thread_columns.contains("model_provider");
|
||||
let supports_catalog_updates =
|
||||
catalog_columns.contains("thread_id") && catalog_columns.contains("model_provider");
|
||||
if !supports_thread_updates && !supports_catalog_updates {
|
||||
continue;
|
||||
}
|
||||
conn.execute_batch("BEGIN IMMEDIATE")
|
||||
@@ -147,7 +157,8 @@ pub(super) fn prepare_sqlite_updates(sqlite_paths: &[PathBuf]) -> Result<Vec<Pen
|
||||
path: identity,
|
||||
conn,
|
||||
observer,
|
||||
columns,
|
||||
thread_columns,
|
||||
catalog_columns,
|
||||
counts: SqliteUpdateCounts::default(),
|
||||
transaction_open: true,
|
||||
});
|
||||
@@ -165,30 +176,35 @@ fn apply_sqlite_updates(
|
||||
pending: &mut [PendingSqliteUpdate],
|
||||
rollouts: &RolloutScan,
|
||||
target_provider: &str,
|
||||
catalog_sources: &HashMap<String, CatalogRepairThread>,
|
||||
) -> Result<()> {
|
||||
for update in pending.iter_mut() {
|
||||
update
|
||||
.conn
|
||||
.execute(
|
||||
"INSERT INTO temp.codexx_session_rollback
|
||||
(id, model_provider, provider_changed)
|
||||
SELECT id, model_provider, 1 FROM threads
|
||||
WHERE COALESCE(model_provider, '') <> ?1
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
model_provider = excluded.model_provider,
|
||||
provider_changed = 1",
|
||||
[target_provider],
|
||||
)
|
||||
.map_err(|error| CodexxError::Database(error.to_string()))?;
|
||||
update.counts.provider_rows = update
|
||||
.conn
|
||||
.execute(
|
||||
"UPDATE threads SET model_provider = ?1 WHERE COALESCE(model_provider, '') <> ?1",
|
||||
[target_provider],
|
||||
)
|
||||
.map_err(|error| CodexxError::Database(error.to_string()))?;
|
||||
if update.thread_columns.contains("id") && update.thread_columns.contains("model_provider")
|
||||
{
|
||||
update
|
||||
.conn
|
||||
.execute(
|
||||
"INSERT INTO temp.codexx_session_rollback
|
||||
(id, model_provider, provider_changed)
|
||||
SELECT id, model_provider, 1 FROM threads
|
||||
WHERE COALESCE(model_provider, '') <> ?1
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
model_provider = excluded.model_provider,
|
||||
provider_changed = 1",
|
||||
[target_provider],
|
||||
)
|
||||
.map_err(|error| CodexxError::Database(error.to_string()))?;
|
||||
update.counts.provider_rows += update
|
||||
.conn
|
||||
.execute(
|
||||
"UPDATE threads SET model_provider = ?1 \
|
||||
WHERE COALESCE(model_provider, '') <> ?1",
|
||||
[target_provider],
|
||||
)
|
||||
.map_err(|error| CodexxError::Database(error.to_string()))?;
|
||||
}
|
||||
|
||||
if update.columns.contains("id") && update.columns.contains("cwd") {
|
||||
if update.thread_columns.contains("id") && update.thread_columns.contains("cwd") {
|
||||
for (thread_id, cwd) in &rollouts.cwd_by_thread_id {
|
||||
update
|
||||
.conn
|
||||
@@ -212,6 +228,14 @@ fn apply_sqlite_updates(
|
||||
.map_err(|error| CodexxError::Database(error.to_string()))?;
|
||||
}
|
||||
}
|
||||
let catalog_counts = apply_catalog_updates(
|
||||
&update.conn,
|
||||
&update.catalog_columns,
|
||||
target_provider,
|
||||
catalog_sources,
|
||||
)?;
|
||||
update.counts.provider_rows += catalog_counts.provider_rows;
|
||||
update.counts.catalog_insert_rows += catalog_counts.inserted_rows;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -273,18 +297,23 @@ fn restore_sqlite_update(
|
||||
)));
|
||||
}
|
||||
|
||||
let mut statements = vec![
|
||||
"UPDATE threads
|
||||
SET model_provider = (
|
||||
SELECT rollback.model_provider
|
||||
FROM temp.codexx_session_rollback AS rollback
|
||||
WHERE rollback.id = threads.id
|
||||
)
|
||||
WHERE id IN (
|
||||
SELECT id FROM temp.codexx_session_rollback WHERE provider_changed = 1
|
||||
)",
|
||||
];
|
||||
if update.columns.contains("cwd") {
|
||||
restore_catalog_updates(&update.conn, &update.catalog_columns)?;
|
||||
let mut statements = Vec::new();
|
||||
if update.thread_columns.contains("id") && update.thread_columns.contains("model_provider")
|
||||
{
|
||||
statements.push(
|
||||
"UPDATE threads
|
||||
SET model_provider = (
|
||||
SELECT rollback.model_provider
|
||||
FROM temp.codexx_session_rollback AS rollback
|
||||
WHERE rollback.id = threads.id
|
||||
)
|
||||
WHERE id IN (
|
||||
SELECT id FROM temp.codexx_session_rollback WHERE provider_changed = 1
|
||||
)",
|
||||
);
|
||||
}
|
||||
if update.thread_columns.contains("id") && update.thread_columns.contains("cwd") {
|
||||
statements.push(
|
||||
"UPDATE threads
|
||||
SET cwd = (
|
||||
@@ -321,11 +350,6 @@ pub(super) fn rollback_mutation(
|
||||
pending_sqlite: &mut [PendingSqliteUpdate],
|
||||
) -> Vec<String> {
|
||||
let mut errors = Vec::new();
|
||||
for write in journal.global_writes.iter().rev() {
|
||||
if let Err(error) = restore_global_write(write) {
|
||||
errors.push(error.to_string());
|
||||
}
|
||||
}
|
||||
for attempt in journal.sqlite_restore_attempts.iter().rev() {
|
||||
let Some(update) = pending_sqlite
|
||||
.iter_mut()
|
||||
@@ -358,7 +382,6 @@ pub(super) fn mutation_error(original: CodexxError, recovery_errors: Vec<String>
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(super) enum MutationPoint {
|
||||
AfterSqliteCommit(usize),
|
||||
AfterGlobalMainWrite,
|
||||
}
|
||||
|
||||
pub(super) struct MutationResult {
|
||||
@@ -371,7 +394,7 @@ pub(super) fn execute_provider_sync_mutation<F>(
|
||||
rollouts: &RolloutScan,
|
||||
pending_sqlite: &mut [PendingSqliteUpdate],
|
||||
target_provider: &str,
|
||||
global_state_path: &Path,
|
||||
catalog_sources: &HashMap<String, CatalogRepairThread>,
|
||||
journal: &mut MutationJournal,
|
||||
hook: &mut F,
|
||||
) -> Result<MutationResult>
|
||||
@@ -381,12 +404,7 @@ where
|
||||
let result = (|| -> Result<MutationResult> {
|
||||
let (applied_rollouts, skipped_rollouts) = apply_session_changes(&rollouts.changes)?;
|
||||
journal.applied_rollouts = applied_rollouts;
|
||||
apply_sqlite_updates(pending_sqlite, rollouts, target_provider)?;
|
||||
apply_global_state_update_with_journal(
|
||||
global_state_path,
|
||||
&mut journal.global_writes,
|
||||
&mut || hook(MutationPoint::AfterGlobalMainWrite),
|
||||
)?;
|
||||
apply_sqlite_updates(pending_sqlite, rollouts, target_provider, catalog_sources)?;
|
||||
let sqlite_updates = commit_sqlite_updates(pending_sqlite, journal, hook)?;
|
||||
Ok(MutationResult {
|
||||
applied_rollouts: journal.applied_rollouts.len(),
|
||||
|
||||
@@ -18,6 +18,8 @@ fn temp_dir(name: &str) -> PathBuf {
|
||||
));
|
||||
let _ = fs::remove_dir_all(&path);
|
||||
fs::create_dir_all(&path).expect("create test directory");
|
||||
fs::write(path.join("config.toml"), "model_provider = \"custom\"\n")
|
||||
.expect("write shared provider config");
|
||||
path
|
||||
}
|
||||
|
||||
@@ -176,7 +178,8 @@ fn failed_sqlite_commit_rolls_back_without_snapshot_restore() {
|
||||
path: database.clone(),
|
||||
conn,
|
||||
observer,
|
||||
columns: HashSet::new(),
|
||||
thread_columns: HashSet::new(),
|
||||
catalog_columns: HashSet::new(),
|
||||
counts: SqliteUpdateCounts::default(),
|
||||
transaction_open: true,
|
||||
}];
|
||||
@@ -332,53 +335,7 @@ fn sqlite_prepare_deduplicates_symlink_aliases() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn global_main_failpoint_restores_prior_mutations_without_touching_backup() {
|
||||
let codex_dir = temp_dir("global-main-failpoint");
|
||||
let id = "019f6000-0000-7000-8000-000000000408";
|
||||
let rollout = codex_dir.join(format!("sessions/rollout-test-{id}.jsonl"));
|
||||
let original_rollout = write_rollout(&rollout, id);
|
||||
let database = codex_dir.join("state_10.sqlite");
|
||||
create_thread_database(&database, id, &rollout);
|
||||
let global = codex_dir.join(".codex-global-state.json");
|
||||
let global_backup = codex_dir.join(".codex-global-state.json.bak");
|
||||
let original_global = br#"{"electron-saved-workspace-roots":"/tmp/project"}"#;
|
||||
fs::write(&global, original_global).expect("write original global state");
|
||||
assert!(!global_backup.exists());
|
||||
|
||||
let error = sync_sessions_provider_with_hook(
|
||||
Some(codex_dir.display().to_string()),
|
||||
Some("custom".to_string()),
|
||||
|point| match point {
|
||||
MutationPoint::AfterGlobalMainWrite => {
|
||||
assert_eq!(thread_provider(&database, id), "openai");
|
||||
assert!(fs::read_to_string(&rollout)
|
||||
.expect("read mutated rollout")
|
||||
.contains("\"model_provider\":\"custom\""));
|
||||
assert!(!global_backup.exists());
|
||||
Err(CodexxError::Config("主状态写入后注入失败".to_string()))
|
||||
}
|
||||
_ => Ok(()),
|
||||
},
|
||||
)
|
||||
.expect_err("fail after global main write");
|
||||
|
||||
assert_eq!(error.to_string(), "配置错误: 主状态写入后注入失败");
|
||||
assert_eq!(thread_provider(&database, id), "openai");
|
||||
assert_eq!(
|
||||
fs::read(&rollout).expect("read restored rollout"),
|
||||
original_rollout
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read(&global).expect("read restored global"),
|
||||
original_global
|
||||
);
|
||||
assert!(!global_backup.exists());
|
||||
|
||||
fs::remove_dir_all(codex_dir).expect("remove test directory");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn injected_failure_restores_sqlite_jsonl_and_global_state() {
|
||||
fn injected_failure_restores_sqlite_and_jsonl_without_touching_global_state() {
|
||||
let codex_dir = temp_dir("full-mutation-rollback");
|
||||
let id = "019f6000-0000-7000-8000-000000000411";
|
||||
let rollout = codex_dir.join(format!("sessions/rollout-test-{id}.jsonl"));
|
||||
@@ -440,10 +397,11 @@ fn injected_failure_restores_sqlite_jsonl_and_global_state() {
|
||||
assert!(fs::read_to_string(&rollout)
|
||||
.expect("read mutated rollout")
|
||||
.contains("\"model_provider\":\"custom\""));
|
||||
let main = fs::read_to_string(&global).expect("read mutated global state");
|
||||
let backup =
|
||||
fs::read_to_string(&global_backup).expect("read mutated global backup");
|
||||
assert_eq!(main, backup);
|
||||
assert_eq!(
|
||||
fs::read(&global).expect("read untouched global state"),
|
||||
original_global
|
||||
);
|
||||
assert!(!global_backup.exists());
|
||||
Err(CodexxError::Config("测试注入失败".to_string()))
|
||||
}
|
||||
_ => Ok(()),
|
||||
@@ -478,10 +436,18 @@ fn injected_failure_restores_sqlite_jsonl_and_global_state() {
|
||||
original_global
|
||||
);
|
||||
assert!(!global_backup.exists());
|
||||
assert!(fs::read_dir(provider_sync_backup_root(&codex_dir))
|
||||
let retained_backup = fs::read_dir(provider_sync_backup_root(&codex_dir))
|
||||
.expect("read retained provider sync backup")
|
||||
.next()
|
||||
.is_some());
|
||||
.expect("retained provider sync backup")
|
||||
.expect("read retained provider sync backup entry")
|
||||
.path();
|
||||
let metadata = fs::read_to_string(retained_backup.join("metadata.json"))
|
||||
.expect("read retained provider sync metadata");
|
||||
assert!(!metadata.contains("config.toml"));
|
||||
assert!(!metadata.contains(".codex-global-state.json"));
|
||||
assert!(!retained_backup.join("config.toml").exists());
|
||||
assert!(!retained_backup.join(".codex-global-state.json").exists());
|
||||
|
||||
drop(wal_guard);
|
||||
fs::remove_dir_all(codex_dir).expect("remove test directory");
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use serde::Serialize;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::PathBuf;
|
||||
use std::time::SystemTime;
|
||||
|
||||
@@ -33,7 +33,10 @@ pub(crate) struct SessionSyncStatus {
|
||||
pub(crate) top_level_threads: usize,
|
||||
pub(crate) subagent_threads: usize,
|
||||
pub(crate) mismatched_threads: usize,
|
||||
pub(crate) mismatched_sessions: usize,
|
||||
pub(crate) needs_sync: bool,
|
||||
pub(crate) scan_complete: bool,
|
||||
pub(crate) scan_failures: Vec<String>,
|
||||
pub(crate) backup_dir: Option<String>,
|
||||
pub(crate) warnings: Vec<String>,
|
||||
pub(crate) sessions: Vec<SessionPreview>,
|
||||
@@ -50,13 +53,16 @@ pub(crate) struct SessionSyncResult {
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub(crate) struct RolloutScan {
|
||||
pub(crate) discovered_rollout_files: usize,
|
||||
pub(crate) rollout_files: usize,
|
||||
pub(crate) session_meta_count: usize,
|
||||
pub(crate) mismatched_rollouts: usize,
|
||||
pub(crate) mismatched_session_meta: usize,
|
||||
pub(crate) changes: Vec<SessionFileChange>,
|
||||
pub(crate) cwd_by_thread_id: HashMap<String, String>,
|
||||
pub(crate) mismatched_thread_ids: HashSet<String>,
|
||||
pub(crate) warnings: Vec<String>,
|
||||
pub(crate) scan_failures: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -74,5 +80,9 @@ pub(crate) struct SqliteScan {
|
||||
pub(crate) top_level_threads: usize,
|
||||
pub(crate) subagent_threads: usize,
|
||||
pub(crate) mismatched_threads: usize,
|
||||
pub(crate) thread_ids: HashSet<String>,
|
||||
pub(crate) rollout_paths_by_thread_id: HashMap<String, String>,
|
||||
pub(crate) mismatched_thread_ids: HashSet<String>,
|
||||
pub(crate) warnings: Vec<String>,
|
||||
pub(crate) scan_failures: Vec<String>,
|
||||
}
|
||||
|
||||
@@ -1,16 +1,25 @@
|
||||
use super::build_skills_mcp_state_inner;
|
||||
use super::types::{ManagedMcpServer, SkillsMcpState};
|
||||
use crate::backups::create_backup;
|
||||
use crate::ccswitch::default_ccswitch_db_path;
|
||||
use crate::error::{CodexxError, Result};
|
||||
use crate::file_io::{ensure_directory, parse_toml_document, read_to_string_if_exists, write_text};
|
||||
use crate::file_io::{ensure_directory, parse_toml_document, read_to_string_if_exists};
|
||||
use crate::live_config::{
|
||||
acquire_live_config_lock, apply_file_change, fail_with_file_rollback, read_file_snapshot,
|
||||
text_from_snapshot,
|
||||
};
|
||||
use crate::toml_utils::ensure_table;
|
||||
use crate::{config_path, now_rfc3339, open_db, resolve_codex_dir};
|
||||
use rusqlite::{params, Connection, OpenFlags};
|
||||
use rusqlite::{
|
||||
params, Connection, OpenFlags, OptionalExtension, Transaction, TransactionBehavior,
|
||||
};
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::HashSet;
|
||||
use std::path::Path;
|
||||
use toml_edit::{value, Item, Table};
|
||||
|
||||
type CcSwitchMcpCandidate = (String, String, Value, bool);
|
||||
|
||||
fn toml_value_to_json(value: &toml_edit::Value) -> Value {
|
||||
if let Some(s) = value.as_str() {
|
||||
return json!(s);
|
||||
@@ -134,8 +143,13 @@ pub(super) fn mcp_summary(config: &Value) -> (String, Option<String>, Option<Str
|
||||
(transport, command, url, summary)
|
||||
}
|
||||
|
||||
pub(super) fn save_managed_mcp(id: &str, name: &str, config: &Value, enabled: bool) -> Result<()> {
|
||||
let conn = open_db()?;
|
||||
fn save_managed_mcp_on_connection(
|
||||
conn: &Connection,
|
||||
id: &str,
|
||||
name: &str,
|
||||
config: &Value,
|
||||
enabled: bool,
|
||||
) -> Result<()> {
|
||||
conn.execute(
|
||||
"INSERT INTO managed_mcp_servers (id, name, server_config, enabled, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5)
|
||||
@@ -156,6 +170,83 @@ pub(super) fn save_managed_mcp(id: &str, name: &str, config: &Value, enabled: bo
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn save_managed_mcp(id: &str, name: &str, config: &Value, enabled: bool) -> Result<()> {
|
||||
let conn = open_db()?;
|
||||
save_managed_mcp_on_connection(&conn, id, name, config, enabled)
|
||||
}
|
||||
|
||||
fn managed_mcp_on_connection(conn: &Connection, id: &str) -> Result<Option<(String, Value, bool)>> {
|
||||
conn.query_row(
|
||||
"SELECT name, server_config, enabled FROM managed_mcp_servers WHERE id = ?1 LIMIT 1",
|
||||
[id],
|
||||
|row| {
|
||||
let config_text: String = row.get(1)?;
|
||||
Ok((
|
||||
row.get(0)?,
|
||||
serde_json::from_str(&config_text).unwrap_or(Value::Object(Default::default())),
|
||||
row.get(2)?,
|
||||
))
|
||||
},
|
||||
)
|
||||
.optional()
|
||||
.map_err(|error| CodexxError::Database(error.to_string()))
|
||||
}
|
||||
|
||||
fn document_mcp_ids(doc: &toml_edit::DocumentMut) -> HashSet<String> {
|
||||
doc.get("mcp_servers")
|
||||
.and_then(|item| item.as_table())
|
||||
.map(|table| {
|
||||
table
|
||||
.iter()
|
||||
.filter(|(_, item)| item.is_table())
|
||||
.map(|(id, _)| id.to_string())
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn document_bytes(snapshot: Option<&[u8]>, doc: &toml_edit::DocumentMut) -> Option<Vec<u8>> {
|
||||
let bytes = doc.to_string().into_bytes();
|
||||
if snapshot.is_none() && bytes.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(bytes)
|
||||
}
|
||||
}
|
||||
|
||||
fn commit_mcp_transaction_with_config<BeforeApply, BeforeCommit>(
|
||||
transaction: Transaction<'_>,
|
||||
codex_dir: &Path,
|
||||
before: Option<Vec<u8>>,
|
||||
after: Option<Vec<u8>>,
|
||||
backup_action: &str,
|
||||
before_apply: BeforeApply,
|
||||
before_commit: BeforeCommit,
|
||||
) -> Result<()>
|
||||
where
|
||||
BeforeApply: FnOnce(&Path) -> Result<()>,
|
||||
BeforeCommit: FnOnce(&Transaction<'_>) -> Result<()>,
|
||||
{
|
||||
let cfg = config_path(codex_dir);
|
||||
let mut changes = Vec::new();
|
||||
if before != after {
|
||||
create_backup(codex_dir, backup_action)?;
|
||||
before_apply(&cfg)?;
|
||||
changes.push(apply_file_change(&cfg, before, after)?);
|
||||
}
|
||||
|
||||
if let Err(error) = before_commit(&transaction) {
|
||||
return fail_with_file_rollback(error, &changes);
|
||||
}
|
||||
if let Err(error) = transaction
|
||||
.commit()
|
||||
.map_err(|error| CodexxError::Database(error.to_string()))
|
||||
{
|
||||
return fail_with_file_rollback(error, &changes);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn db_managed_mcp() -> Result<Vec<(String, String, Value, bool)>> {
|
||||
let conn = open_db()?;
|
||||
let mut stmt = conn.prepare("SELECT id, name, server_config, enabled FROM managed_mcp_servers ORDER BY name ASC, id ASC")
|
||||
@@ -264,36 +355,76 @@ pub(super) fn import_ccswitch_mcp_servers_for_codex(
|
||||
))
|
||||
})
|
||||
.map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
|
||||
let cfg = config_path(codex_dir);
|
||||
let text = read_to_string_if_exists(&cfg)?;
|
||||
let mut doc = parse_toml_document(&cfg, &text)?;
|
||||
let live_enabled = list_mcp_from_config(codex_dir)?
|
||||
.into_iter()
|
||||
.map(|server| server.id)
|
||||
.collect::<HashSet<_>>();
|
||||
let mut imported = 0usize;
|
||||
let mut changed_config = false;
|
||||
let mut candidates = Vec::new();
|
||||
for row in rows {
|
||||
let (id, name, config_text, enabled_codex) =
|
||||
row.map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
let config: Value =
|
||||
serde_json::from_str(&config_text).unwrap_or(Value::Object(Default::default()));
|
||||
if !imported_ids.insert(id.clone()) {
|
||||
continue;
|
||||
}
|
||||
let enabled = enabled_codex || live_enabled.contains(&id);
|
||||
save_managed_mcp(&id, &name, &config, enabled)?;
|
||||
if enabled_codex && !live_enabled.contains(&id) {
|
||||
ensure_table(doc.as_table_mut(), "mcp_servers")?
|
||||
.insert(&id, json_to_toml_item(&config));
|
||||
changed_config = true;
|
||||
}
|
||||
imported += 1;
|
||||
candidates.push((id, name, config, enabled_codex));
|
||||
}
|
||||
if changed_config {
|
||||
write_text(&cfg, &doc.to_string())?;
|
||||
|
||||
import_ccswitch_mcp_candidates_with_hooks(
|
||||
codex_dir,
|
||||
imported_ids,
|
||||
candidates,
|
||||
|_| Ok(()),
|
||||
|_| Ok(()),
|
||||
)
|
||||
}
|
||||
|
||||
fn import_ccswitch_mcp_candidates_with_hooks<BeforeApply, BeforeCommit>(
|
||||
codex_dir: &Path,
|
||||
imported_ids: &mut HashSet<String>,
|
||||
candidates: Vec<CcSwitchMcpCandidate>,
|
||||
before_apply: BeforeApply,
|
||||
before_commit: BeforeCommit,
|
||||
) -> Result<usize>
|
||||
where
|
||||
BeforeApply: FnOnce(&Path) -> Result<()>,
|
||||
BeforeCommit: FnOnce(&Transaction<'_>) -> Result<()>,
|
||||
{
|
||||
let mut staged_ids = HashSet::new();
|
||||
let candidates = candidates
|
||||
.into_iter()
|
||||
.filter(|(id, _, _, _)| !imported_ids.contains(id) && staged_ids.insert(id.clone()))
|
||||
.collect::<Vec<_>>();
|
||||
if candidates.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
ensure_directory(codex_dir)?;
|
||||
let _live_lock = acquire_live_config_lock(codex_dir)?;
|
||||
let cfg = config_path(codex_dir);
|
||||
let before = read_file_snapshot(&cfg)?;
|
||||
let text = text_from_snapshot(&cfg, before.as_deref())?;
|
||||
let mut doc = parse_toml_document(&cfg, &text)?;
|
||||
let live_enabled = document_mcp_ids(&doc);
|
||||
|
||||
let mut app_conn = open_db()?;
|
||||
let transaction = app_conn
|
||||
.transaction_with_behavior(TransactionBehavior::Immediate)
|
||||
.map_err(|error| CodexxError::Database(error.to_string()))?;
|
||||
for (id, name, config, enabled_codex) in &candidates {
|
||||
let enabled = *enabled_codex || live_enabled.contains(id);
|
||||
save_managed_mcp_on_connection(&transaction, id, name, config, enabled)?;
|
||||
if *enabled_codex && !live_enabled.contains(id) {
|
||||
ensure_table(doc.as_table_mut(), "mcp_servers")?.insert(id, json_to_toml_item(config));
|
||||
}
|
||||
}
|
||||
let after = document_bytes(before.as_deref(), &doc);
|
||||
commit_mcp_transaction_with_config(
|
||||
transaction,
|
||||
codex_dir,
|
||||
before,
|
||||
after,
|
||||
"import-ccswitch-mcp",
|
||||
before_apply,
|
||||
before_commit,
|
||||
)?;
|
||||
|
||||
let imported = candidates.len();
|
||||
imported_ids.extend(staged_ids);
|
||||
Ok(imported)
|
||||
}
|
||||
|
||||
@@ -368,31 +499,287 @@ pub(crate) fn toggle_codex_mcp_inner(
|
||||
id: String,
|
||||
enabled: bool,
|
||||
) -> Result<SkillsMcpState> {
|
||||
toggle_codex_mcp_with_hooks(config_dir, id, enabled, |_| Ok(()), |_| Ok(()))
|
||||
}
|
||||
|
||||
fn toggle_codex_mcp_with_hooks<BeforeApply, BeforeCommit>(
|
||||
config_dir: Option<String>,
|
||||
id: String,
|
||||
enabled: bool,
|
||||
before_apply: BeforeApply,
|
||||
before_commit: BeforeCommit,
|
||||
) -> Result<SkillsMcpState>
|
||||
where
|
||||
BeforeApply: FnOnce(&Path) -> Result<()>,
|
||||
BeforeCommit: FnOnce(&Transaction<'_>) -> Result<()>,
|
||||
{
|
||||
let codex_dir = resolve_codex_dir(config_dir.clone())?;
|
||||
ensure_directory(&codex_dir)?;
|
||||
let _live_lock = acquire_live_config_lock(&codex_dir)?;
|
||||
let cfg = config_path(&codex_dir);
|
||||
let text = read_to_string_if_exists(&cfg)?;
|
||||
let before = read_file_snapshot(&cfg)?;
|
||||
let text = text_from_snapshot(&cfg, before.as_deref())?;
|
||||
let mut doc = parse_toml_document(&cfg, &text)?;
|
||||
|
||||
let mut conn = open_db()?;
|
||||
let transaction = conn
|
||||
.transaction_with_behavior(TransactionBehavior::Immediate)
|
||||
.map_err(|error| CodexxError::Database(error.to_string()))?;
|
||||
let stored = managed_mcp_on_connection(&transaction, &id)?;
|
||||
if enabled {
|
||||
let db = db_managed_mcp()?
|
||||
.into_iter()
|
||||
.find(|(sid, _, _, _)| sid == &id)
|
||||
.ok_or_else(|| CodexxError::Config(format!("未找到 MCP: {id}")))?;
|
||||
ensure_table(doc.as_table_mut(), "mcp_servers")?.insert(&id, json_to_toml_item(&db.2));
|
||||
save_managed_mcp(&id, &db.1, &db.2, true)?;
|
||||
let (name, config, _) =
|
||||
stored.ok_or_else(|| CodexxError::Config(format!("未找到 MCP: {id}")))?;
|
||||
ensure_table(doc.as_table_mut(), "mcp_servers")?.insert(&id, json_to_toml_item(&config));
|
||||
save_managed_mcp_on_connection(&transaction, &id, &name, &config, true)?;
|
||||
} else {
|
||||
if let Some(item) = doc
|
||||
let live_config = doc
|
||||
.get("mcp_servers")
|
||||
.and_then(|m| m.as_table())
|
||||
.and_then(|tbl| tbl.get(&id))
|
||||
{
|
||||
let config = toml_item_to_json(item);
|
||||
save_managed_mcp(&id, &id, &config, false)?;
|
||||
.map(toml_item_to_json);
|
||||
if let Some(config) = live_config {
|
||||
let name = stored
|
||||
.as_ref()
|
||||
.map(|(name, _, _)| name.as_str())
|
||||
.unwrap_or(&id);
|
||||
save_managed_mcp_on_connection(&transaction, &id, name, &config, false)?;
|
||||
} else if let Some((name, config, _)) = stored {
|
||||
save_managed_mcp_on_connection(&transaction, &id, &name, &config, false)?;
|
||||
}
|
||||
if let Some(tbl) = doc.get_mut("mcp_servers").and_then(|m| m.as_table_mut()) {
|
||||
tbl.remove(&id);
|
||||
}
|
||||
}
|
||||
write_text(&cfg, &doc.to_string())?;
|
||||
|
||||
let after = document_bytes(before.as_deref(), &doc);
|
||||
commit_mcp_transaction_with_config(
|
||||
transaction,
|
||||
&codex_dir,
|
||||
before,
|
||||
after,
|
||||
"toggle-mcp",
|
||||
before_apply,
|
||||
before_commit,
|
||||
)?;
|
||||
build_skills_mcp_state_inner(config_dir)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
fn test_case(name: &str) -> (PathBuf, String) {
|
||||
static COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
let suffix = COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"codex-x-mcp-{name}-{}-{suffix}",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
fs::create_dir_all(&dir).expect("create MCP test directory");
|
||||
(dir, format!("test-mcp-{name}-{suffix}"))
|
||||
}
|
||||
|
||||
fn database_error(error: rusqlite::Error) -> CodexxError {
|
||||
CodexxError::Database(error.to_string())
|
||||
}
|
||||
|
||||
fn remove_test_mcp(id: &str) {
|
||||
open_db()
|
||||
.expect("open test database")
|
||||
.execute("DELETE FROM managed_mcp_servers WHERE id = ?1", [id])
|
||||
.expect("remove test MCP");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toggle_updates_only_the_target_mcp_and_keeps_database_in_sync() {
|
||||
let _db_guard = crate::app_db::test_db_guard();
|
||||
let (codex_dir, id) = test_case("success");
|
||||
let cfg = config_path(&codex_dir);
|
||||
let original = b"# keep this comment\nmodel = \"gpt-5.5\"\n\n[features]\njs_repl = false\n\n[mcp_servers.existing]\ncommand = \"existing\"\n";
|
||||
fs::write(&cfg, original).expect("seed original config");
|
||||
save_managed_mcp(&id, "Managed", &json!({ "command": "managed" }), false)
|
||||
.expect("seed managed MCP");
|
||||
|
||||
let enabled_state =
|
||||
toggle_codex_mcp_inner(Some(codex_dir.display().to_string()), id.clone(), true)
|
||||
.expect("enable managed MCP");
|
||||
assert!(enabled_state
|
||||
.mcp_servers
|
||||
.iter()
|
||||
.any(|server| server.id == id && server.enabled));
|
||||
let enabled_text = fs::read_to_string(&cfg).expect("read enabled config");
|
||||
let enabled_doc = enabled_text
|
||||
.parse::<toml_edit::DocumentMut>()
|
||||
.expect("parse enabled config");
|
||||
assert!(enabled_text.contains("# keep this comment"));
|
||||
assert_eq!(enabled_doc["features"]["js_repl"].as_bool(), Some(false));
|
||||
assert_eq!(
|
||||
enabled_doc["mcp_servers"]["existing"]["command"].as_str(),
|
||||
Some("existing")
|
||||
);
|
||||
assert_eq!(
|
||||
enabled_doc["mcp_servers"][&id]["command"].as_str(),
|
||||
Some("managed")
|
||||
);
|
||||
let conn = open_db().expect("open test database");
|
||||
assert!(
|
||||
managed_mcp_on_connection(&conn, &id)
|
||||
.expect("read enabled MCP")
|
||||
.expect("enabled MCP exists")
|
||||
.2
|
||||
);
|
||||
drop(conn);
|
||||
|
||||
toggle_codex_mcp_inner(Some(codex_dir.display().to_string()), id.clone(), false)
|
||||
.expect("disable managed MCP");
|
||||
let disabled_text = fs::read_to_string(&cfg).expect("read disabled config");
|
||||
let disabled_doc = disabled_text
|
||||
.parse::<toml_edit::DocumentMut>()
|
||||
.expect("parse disabled config");
|
||||
assert!(disabled_text.contains("# keep this comment"));
|
||||
assert!(disabled_doc["mcp_servers"].get(&id).is_none());
|
||||
assert_eq!(
|
||||
disabled_doc["mcp_servers"]["existing"]["command"].as_str(),
|
||||
Some("existing")
|
||||
);
|
||||
let conn = open_db().expect("open test database");
|
||||
let (name, config, enabled) = managed_mcp_on_connection(&conn, &id)
|
||||
.expect("read disabled MCP")
|
||||
.expect("disabled MCP exists");
|
||||
assert_eq!(name, "Managed");
|
||||
assert_eq!(config["command"].as_str(), Some("managed"));
|
||||
assert!(!enabled);
|
||||
drop(conn);
|
||||
assert!(codex_dir.join(".codexx-test-backups").is_dir());
|
||||
|
||||
remove_test_mcp(&id);
|
||||
fs::remove_dir_all(codex_dir).expect("remove MCP test directory");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toggle_rejects_a_stale_snapshot_without_changing_database_or_external_config() {
|
||||
let _db_guard = crate::app_db::test_db_guard();
|
||||
let (codex_dir, id) = test_case("stale");
|
||||
let cfg = config_path(&codex_dir);
|
||||
let original = b"# original\nmodel = \"gpt-5.5\"\n";
|
||||
let external = b"# external update\nmodel = \"gpt-5.5\"\n\n[mcp_servers.external]\ncommand = \"external\"\n";
|
||||
fs::write(&cfg, original).expect("seed original config");
|
||||
save_managed_mcp(&id, "Managed", &json!({ "command": "managed" }), false)
|
||||
.expect("seed managed MCP");
|
||||
|
||||
let error = toggle_codex_mcp_with_hooks(
|
||||
Some(codex_dir.display().to_string()),
|
||||
id.clone(),
|
||||
true,
|
||||
|path| {
|
||||
fs::write(path, external).map_err(|error| CodexxError::Config(error.to_string()))
|
||||
},
|
||||
|_| Ok(()),
|
||||
)
|
||||
.expect_err("stale MCP toggle must fail");
|
||||
|
||||
assert!(error.to_string().contains("已被其他程序修改"));
|
||||
assert_eq!(fs::read(&cfg).expect("read external config"), external);
|
||||
let conn = open_db().expect("open test database");
|
||||
let (_, config, enabled) = managed_mcp_on_connection(&conn, &id)
|
||||
.expect("read managed MCP")
|
||||
.expect("managed MCP still exists");
|
||||
assert_eq!(config["command"].as_str(), Some("managed"));
|
||||
assert!(!enabled);
|
||||
drop(conn);
|
||||
|
||||
remove_test_mcp(&id);
|
||||
fs::remove_dir_all(codex_dir).expect("remove MCP test directory");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toggle_rolls_back_config_when_database_commit_fails() {
|
||||
let _db_guard = crate::app_db::test_db_guard();
|
||||
let (codex_dir, id) = test_case("commit");
|
||||
let cfg = config_path(&codex_dir);
|
||||
let original = b"# keep exact bytes\nmodel = \"gpt-5.5\"\n\n[features]\njs_repl = false\n";
|
||||
fs::write(&cfg, original).expect("seed original config");
|
||||
save_managed_mcp(&id, "Managed", &json!({ "command": "managed" }), false)
|
||||
.expect("seed managed MCP");
|
||||
|
||||
toggle_codex_mcp_with_hooks(
|
||||
Some(codex_dir.display().to_string()),
|
||||
id.clone(),
|
||||
true,
|
||||
|_| Ok(()),
|
||||
|transaction| {
|
||||
transaction
|
||||
.execute_batch("ROLLBACK")
|
||||
.map_err(database_error)
|
||||
},
|
||||
)
|
||||
.expect_err("database commit failure must fail the toggle");
|
||||
|
||||
assert_eq!(fs::read(&cfg).expect("read rolled-back config"), original);
|
||||
let conn = open_db().expect("open test database");
|
||||
let (_, _, enabled) = managed_mcp_on_connection(&conn, &id)
|
||||
.expect("read managed MCP")
|
||||
.expect("managed MCP still exists");
|
||||
assert!(!enabled);
|
||||
drop(conn);
|
||||
assert!(codex_dir.join(".codexx-test-backups").is_dir());
|
||||
|
||||
remove_test_mcp(&id);
|
||||
fs::remove_dir_all(codex_dir).expect("remove MCP test directory");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ccswitch_import_rolls_back_file_database_and_ids_when_commit_fails() {
|
||||
let _db_guard = crate::app_db::test_db_guard();
|
||||
let (codex_dir, id) = test_case("import-commit");
|
||||
let second_id = format!("{id}-second");
|
||||
let cfg = config_path(&codex_dir);
|
||||
let original = b"# import baseline\nmodel = \"gpt-5.5\"\n";
|
||||
fs::write(&cfg, original).expect("seed original config");
|
||||
let mut imported_ids = HashSet::new();
|
||||
|
||||
import_ccswitch_mcp_candidates_with_hooks(
|
||||
&codex_dir,
|
||||
&mut imported_ids,
|
||||
vec![
|
||||
(
|
||||
id.clone(),
|
||||
"Imported".to_string(),
|
||||
json!({ "command": "imported" }),
|
||||
true,
|
||||
),
|
||||
(
|
||||
second_id.clone(),
|
||||
"Imported second".to_string(),
|
||||
json!({ "command": "imported-second" }),
|
||||
true,
|
||||
),
|
||||
],
|
||||
|_| Ok(()),
|
||||
|transaction| {
|
||||
transaction
|
||||
.execute_batch("ROLLBACK")
|
||||
.map_err(database_error)
|
||||
},
|
||||
)
|
||||
.expect_err("database commit failure must fail the import");
|
||||
|
||||
assert_eq!(fs::read(&cfg).expect("read rolled-back config"), original);
|
||||
assert!(imported_ids.is_empty());
|
||||
let conn = open_db().expect("open test database");
|
||||
assert!(managed_mcp_on_connection(&conn, &id)
|
||||
.expect("read imported MCP")
|
||||
.is_none());
|
||||
assert!(managed_mcp_on_connection(&conn, &second_id)
|
||||
.expect("read second imported MCP")
|
||||
.is_none());
|
||||
drop(conn);
|
||||
assert!(codex_dir.join(".codexx-test-backups").is_dir());
|
||||
|
||||
fs::remove_dir_all(codex_dir).expect("remove MCP test directory");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +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::{io_err, json_err, parse_toml_document, read_to_string_if_exists};
|
||||
use crate::prompts::{
|
||||
agents_path, managed_agents_template_key, prompt_template_key_for_instruction,
|
||||
};
|
||||
use crate::providers::{list_saved_providers_inner, SavedProvider};
|
||||
use crate::providers::{
|
||||
detected_live_custom_provider, document_is_official, list_saved_providers_inner,
|
||||
official_auth_available, unique_saved_provider_id_for_live, SavedProvider,
|
||||
};
|
||||
use crate::{auth_path, config_path, string_value};
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
@@ -35,6 +39,7 @@ pub(crate) struct CodexState {
|
||||
official_auth_available: bool,
|
||||
pub(crate) model: Option<String>,
|
||||
pub(crate) model_provider: Option<String>,
|
||||
pub(crate) is_official_provider: bool,
|
||||
instruction_file: Option<String>,
|
||||
pub(crate) instruction_enabled: bool,
|
||||
pub(crate) instruction_injection_mode: Option<String>,
|
||||
@@ -62,7 +67,13 @@ fn redacted_auth_preview(path: &Path) -> Result<Option<Value>> {
|
||||
return Ok(None);
|
||||
}
|
||||
let text = fs::read_to_string(path).map_err(|e| io_err(path, e))?;
|
||||
let mut value: Value = serde_json::from_str(&text).map_err(|e| json_err(path, e))?;
|
||||
// Keep the UI responsive when another tool or an interrupted write leaves
|
||||
// auth.json malformed. Mutating flows validate or replace auth explicitly;
|
||||
// the read-only state path should still report the rest of the config.
|
||||
let mut value: Value = match serde_json::from_str(&text) {
|
||||
Ok(value) => value,
|
||||
Err(_) => return Ok(None),
|
||||
};
|
||||
if let Some(obj) = value.as_object_mut() {
|
||||
for (key, val) in obj.iter_mut() {
|
||||
let lower = key.to_ascii_lowercase();
|
||||
@@ -168,14 +179,20 @@ pub(crate) fn active_saved_provider_id_from_config(
|
||||
(matches.len() == 1).then(|| matches[0].id.clone())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn build_state(codex_dir: PathBuf) -> Result<CodexState> {
|
||||
migrate_legacy_prompt_config(&codex_dir)?;
|
||||
build_state_after_migration(codex_dir)
|
||||
}
|
||||
|
||||
pub(crate) fn build_state_after_migration(codex_dir: PathBuf) -> Result<CodexState> {
|
||||
let cfg = config_path(&codex_dir);
|
||||
let auth = auth_path(&codex_dir);
|
||||
migrate_legacy_prompt_config(&codex_dir)?;
|
||||
let text = read_to_string_if_exists(&cfg)?;
|
||||
let doc = parse_toml_document(&cfg, &text)?;
|
||||
let model = string_value(&doc, "model");
|
||||
let model_provider = string_value(&doc, "model_provider");
|
||||
let is_official_provider = document_is_official(&doc);
|
||||
let instruction_file = string_value(&doc, "model_instructions_file");
|
||||
let model_template_key = instruction_file
|
||||
.as_deref()
|
||||
@@ -193,10 +210,13 @@ pub(crate) fn build_state(codex_dir: PathBuf) -> Result<CodexState> {
|
||||
};
|
||||
let instruction_enabled = instruction_template_key.is_some();
|
||||
let providers = extract_providers(&doc, model_provider.as_deref());
|
||||
let active_saved_provider_id = if model_provider.as_deref() == Some("openai") {
|
||||
let saved_providers = list_saved_providers_inner()?;
|
||||
let active_saved_provider_id = if is_official_provider {
|
||||
None
|
||||
} else if let Some(live) = detected_live_custom_provider(&codex_dir)? {
|
||||
unique_saved_provider_id_for_live(&live, &saved_providers)
|
||||
} else {
|
||||
active_saved_provider_id_from_config(&text, &list_saved_providers_inner()?)
|
||||
active_saved_provider_id_from_config(&text, &saved_providers)
|
||||
};
|
||||
|
||||
Ok(CodexState {
|
||||
@@ -205,9 +225,10 @@ pub(crate) fn build_state(codex_dir: PathBuf) -> Result<CodexState> {
|
||||
auth_path: auth.display().to_string(),
|
||||
config_exists: cfg.exists(),
|
||||
auth_exists: auth.exists(),
|
||||
official_auth_available: auth_has_material(&auth)?,
|
||||
official_auth_available: official_auth_available(&codex_dir)?,
|
||||
model,
|
||||
model_provider,
|
||||
is_official_provider,
|
||||
instruction_file,
|
||||
instruction_enabled,
|
||||
instruction_injection_mode,
|
||||
@@ -218,6 +239,8 @@ pub(crate) fn build_state(codex_dir: PathBuf) -> Result<CodexState> {
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
+2257
-99
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Codex-X",
|
||||
"version": "0.3.0",
|
||||
"version": "0.3.11",
|
||||
"identifier": "com.yynxxxxx.codexx",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
|
||||
@@ -366,6 +366,7 @@ export function StartupWizardDialog({
|
||||
value={configDir || diagnostics.codexDir}
|
||||
onChange={(event) => onConfigDirChange(event.target.value)}
|
||||
placeholder="~/.codex"
|
||||
disabled={loading}
|
||||
spellCheck={false}
|
||||
/>
|
||||
<Button
|
||||
|
||||
@@ -111,7 +111,6 @@ export function PromptCategoryManager({
|
||||
expand: (name: string) => `Expand ${name}`,
|
||||
collapse: (name: string) => `Collapse ${name}`,
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
if (categoryToDelete) {
|
||||
setCategoryToDelete(null);
|
||||
|
||||
+780
-306
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,11 @@
|
||||
import {
|
||||
ArrowUpRight,
|
||||
CheckCircle2,
|
||||
CircleAlert,
|
||||
Code2,
|
||||
FileText,
|
||||
KeyRound,
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
@@ -14,6 +16,7 @@ export type OverviewLanguage = "zh" | "en";
|
||||
|
||||
export type OverviewPageProps = {
|
||||
lang: OverviewLanguage;
|
||||
ready: boolean;
|
||||
model?: string | null;
|
||||
configDir: string;
|
||||
resolvedCodexDir: string;
|
||||
@@ -21,6 +24,7 @@ export type OverviewPageProps = {
|
||||
providerLabel?: string | null;
|
||||
instructionEnabled: boolean;
|
||||
authExists: boolean;
|
||||
officialAuthAvailable: boolean;
|
||||
configPath?: string | null;
|
||||
modelProvider?: string | null;
|
||||
instructionPath?: string | null;
|
||||
@@ -69,6 +73,7 @@ function ConfigRow({ label, value }: ConfigRowProps) {
|
||||
|
||||
export function OverviewPage({
|
||||
lang,
|
||||
ready,
|
||||
model,
|
||||
configDir,
|
||||
resolvedCodexDir,
|
||||
@@ -76,6 +81,7 @@ export function OverviewPage({
|
||||
providerLabel,
|
||||
instructionEnabled,
|
||||
authExists,
|
||||
officialAuthAvailable,
|
||||
configPath,
|
||||
modelProvider,
|
||||
instructionPath,
|
||||
@@ -103,8 +109,9 @@ export function OverviewPage({
|
||||
instruction: "指令提示词",
|
||||
enabled: "已启用",
|
||||
disabled: "未启用",
|
||||
auth: "认证文件",
|
||||
authFile: "auth.json",
|
||||
auth: "认证状态",
|
||||
authFile: "auth.json 已找到",
|
||||
officialAuth: "官方认证已保存",
|
||||
noAuth: "未找到",
|
||||
updateFound: "发现新版本",
|
||||
updateAvailable: (version: string) => `Codex-X ${version} 已发布`,
|
||||
@@ -118,6 +125,8 @@ export function OverviewPage({
|
||||
model: "模型",
|
||||
providerName: "供应商标识",
|
||||
instructionFile: "指令文件",
|
||||
reading: "正在读取",
|
||||
readFailed: "读取失败",
|
||||
}
|
||||
: {
|
||||
eyebrow: "CODEX CONFIG MANAGER",
|
||||
@@ -134,8 +143,9 @@ export function OverviewPage({
|
||||
instruction: "Instructions",
|
||||
enabled: "Enabled",
|
||||
disabled: "Disabled",
|
||||
auth: "Auth file",
|
||||
authFile: "auth.json",
|
||||
auth: "Authentication",
|
||||
authFile: "auth.json found",
|
||||
officialAuth: "Official auth saved",
|
||||
noAuth: "Not found",
|
||||
updateFound: "New version available",
|
||||
updateAvailable: (version: string) => `Codex-X ${version} is available`,
|
||||
@@ -149,16 +159,29 @@ export function OverviewPage({
|
||||
model: "Model",
|
||||
providerName: "Provider",
|
||||
instructionFile: "Instruction file",
|
||||
reading: "Loading",
|
||||
readFailed: "Load failed",
|
||||
};
|
||||
|
||||
const displayModel = model?.trim() || text.modelMissing;
|
||||
const displayProvider = providerLabel?.trim() || modelProvider?.trim() || text.official;
|
||||
const displayModelProvider = modelProvider?.trim() || text.notConfigured;
|
||||
const displayDirectory = resolvedCodexDir.trim() || configDir.trim() || text.notConfigured;
|
||||
const displayConfigPath = configPath?.trim() || text.notConfigured;
|
||||
const displayInstructionPath = instructionPath?.trim() || text.notConfigured;
|
||||
const unresolvedStatus = loading ? text.reading : text.readFailed;
|
||||
const displayModel = ready ? (model?.trim() || text.modelMissing) : unresolvedStatus;
|
||||
const displayProvider = ready
|
||||
? (providerLabel?.trim() || modelProvider?.trim() || text.official)
|
||||
: unresolvedStatus;
|
||||
const displayModelProvider = ready ? (modelProvider?.trim() || text.notConfigured) : unresolvedStatus;
|
||||
const displayDirectory = ready
|
||||
? (resolvedCodexDir.trim() || configDir.trim() || text.notConfigured)
|
||||
: (configDir.trim() || unresolvedStatus);
|
||||
const displayConfigPath = ready ? (configPath?.trim() || text.notConfigured) : unresolvedStatus;
|
||||
const displayInstructionPath = ready ? (instructionPath?.trim() || text.notConfigured) : unresolvedStatus;
|
||||
const updateVersion = latestVersion?.trim() || "";
|
||||
const homeInputValue = configDir || resolvedCodexDir;
|
||||
const homeInputValue = configDir;
|
||||
const authAvailable = ready && (authExists || officialAuthAvailable);
|
||||
const authStatus = ready
|
||||
? authExists
|
||||
? text.authFile
|
||||
: officialAuthAvailable ? text.officialAuth : text.noAuth
|
||||
: unresolvedStatus;
|
||||
|
||||
return (
|
||||
<section className="cx-overview-page" aria-label={isChinese ? "概览" : "Overview"}>
|
||||
@@ -179,6 +202,7 @@ export function OverviewPage({
|
||||
value={homeInputValue}
|
||||
onChange={(event) => onConfigDirChange(event.target.value)}
|
||||
placeholder={text.directoryPlaceholder}
|
||||
disabled={loading}
|
||||
spellCheck={false}
|
||||
aria-label={text.codexHome}
|
||||
/>
|
||||
@@ -209,26 +233,26 @@ export function OverviewPage({
|
||||
<StatusCard
|
||||
icon={FileText}
|
||||
label={text.config}
|
||||
value={configExists ? text.found : text.missing}
|
||||
tone={configExists ? "success" : "muted"}
|
||||
value={ready ? (configExists ? text.found : text.missing) : unresolvedStatus}
|
||||
tone={ready && configExists ? "success" : "muted"}
|
||||
/>
|
||||
<StatusCard
|
||||
icon={Code2}
|
||||
label={text.provider}
|
||||
value={displayProvider}
|
||||
tone={modelProvider ? "active" : "muted"}
|
||||
tone={ready && modelProvider ? "active" : "muted"}
|
||||
/>
|
||||
<StatusCard
|
||||
icon={Sparkles}
|
||||
label={text.instruction}
|
||||
value={instructionEnabled ? text.enabled : text.disabled}
|
||||
tone={instructionEnabled ? "success" : "muted"}
|
||||
value={ready ? (instructionEnabled ? text.enabled : text.disabled) : unresolvedStatus}
|
||||
tone={ready && instructionEnabled ? "success" : "muted"}
|
||||
/>
|
||||
<StatusCard
|
||||
icon={KeyRound}
|
||||
label={text.auth}
|
||||
value={authExists ? text.authFile : text.noAuth}
|
||||
tone={authExists ? "success" : "muted"}
|
||||
value={authStatus}
|
||||
tone={authAvailable ? "success" : "muted"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -238,9 +262,13 @@ export function OverviewPage({
|
||||
<p className="cx-overview-section-label">{text.liveStatus}</p>
|
||||
<h3>{text.currentConfig}</h3>
|
||||
</div>
|
||||
<span className={`cx-overview-instruction-pill${instructionEnabled ? " cx-overview-instruction-pill--active" : ""}`}>
|
||||
<CheckCircle2 size={14} strokeWidth={2} aria-hidden="true" />
|
||||
{instructionEnabled ? text.on : text.off}
|
||||
<span className={`cx-overview-instruction-pill${ready && instructionEnabled ? " cx-overview-instruction-pill--active" : ""}`}>
|
||||
{!ready && loading
|
||||
? <Loader2 className="cx-overview-spin" size={14} strokeWidth={2} aria-hidden="true" />
|
||||
: !ready
|
||||
? <CircleAlert size={14} strokeWidth={2} aria-hidden="true" />
|
||||
: <CheckCircle2 size={14} strokeWidth={2} aria-hidden="true" />}
|
||||
{ready ? (instructionEnabled ? text.on : text.off) : unresolvedStatus}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ import type { PromptCategoryItem } from "../components/PromptCategoryManager";
|
||||
import { Button, IconButton, StatusBadge, Toggle, cx } from "../components/ui";
|
||||
import { usePromptCategories } from "../promptCategories";
|
||||
import type {
|
||||
BuiltinPromptDetail,
|
||||
BuiltinPromptStatus,
|
||||
InstructionMode,
|
||||
InstructionTemplate,
|
||||
@@ -53,6 +54,7 @@ export type PromptsPageProps = {
|
||||
instructionMode: InstructionMode;
|
||||
promptForm: SavedPrompt;
|
||||
editingPromptId: string | null;
|
||||
editingBuiltinPrompt: BuiltinPromptDetail | null;
|
||||
loading: boolean;
|
||||
actionBusy: string;
|
||||
promptSyncing: boolean;
|
||||
@@ -84,6 +86,7 @@ export type PromptsPageProps = {
|
||||
onEnableSavedPrompt: (id: string) => void | Promise<void>;
|
||||
onDisableExternalPrompt: MaybeAsyncAction;
|
||||
onEditPrompt: (prompt: SavedPrompt) => void;
|
||||
onEditBuiltinPrompt: (id: string) => void | Promise<void>;
|
||||
onDeletePrompt: (id: string) => void | Promise<void>;
|
||||
onPromptFormFieldChange: (field: PromptFormField, value: string) => void;
|
||||
onSavePrompt: MaybeAsyncAction;
|
||||
@@ -129,11 +132,17 @@ function getCopy(lang: Lang) {
|
||||
preservedDescription: "用户原有提示词,追加模式下继续生效。",
|
||||
existingPrompt: "用户原有指令提示词",
|
||||
edit: "编辑",
|
||||
editBuiltin: "查看/编辑本地模板",
|
||||
remove: "删除",
|
||||
formEyebrow: "CUSTOM PROMPT",
|
||||
addFormTitle: "添加提示词",
|
||||
editFormTitle: "编辑提示词",
|
||||
formDescription: "保存为 Markdown 文件,之后可在列表中单独启用。",
|
||||
builtinFormEyebrow: "LOCAL TEMPLATE",
|
||||
builtinFormTitle: "查看与编辑本地模板",
|
||||
builtinFormDescription: "名称和文件名跟随模板目录;内容修改在下次启用时生效,之后 GitHub 同步将永久跳过这份模板。",
|
||||
locallyModified: "本地已修改",
|
||||
saveLocalChanges: "保存本地修改",
|
||||
back: "返回",
|
||||
promptDetails: "提示词详情",
|
||||
promptName: "提示词名称",
|
||||
@@ -183,11 +192,17 @@ function getCopy(lang: Lang) {
|
||||
preservedDescription: "Existing user prompt preserved by append mode.",
|
||||
existingPrompt: "Existing user prompt",
|
||||
edit: "Edit",
|
||||
editBuiltin: "View or edit local template",
|
||||
remove: "Delete",
|
||||
formEyebrow: "CUSTOM PROMPT",
|
||||
addFormTitle: "Add prompt",
|
||||
editFormTitle: "Edit prompt",
|
||||
formDescription: "Save it as Markdown, then enable it separately from the list.",
|
||||
builtinFormEyebrow: "LOCAL TEMPLATE",
|
||||
builtinFormTitle: "View and edit local template",
|
||||
builtinFormDescription: "The name and filename follow the template catalog. Content changes apply on the next activation, then GitHub sync permanently skips this template.",
|
||||
locallyModified: "Locally modified",
|
||||
saveLocalChanges: "Save local changes",
|
||||
back: "Back",
|
||||
promptDetails: "Prompt details",
|
||||
promptName: "Prompt name",
|
||||
@@ -269,6 +284,7 @@ function PromptFormView({
|
||||
lang,
|
||||
promptForm,
|
||||
editingPromptId,
|
||||
editingBuiltinPrompt,
|
||||
loading,
|
||||
onInstructionModeChange,
|
||||
onPromptFormFieldChange,
|
||||
@@ -278,6 +294,7 @@ function PromptFormView({
|
||||
| "lang"
|
||||
| "promptForm"
|
||||
| "editingPromptId"
|
||||
| "editingBuiltinPrompt"
|
||||
| "loading"
|
||||
| "onInstructionModeChange"
|
||||
| "onPromptFormFieldChange"
|
||||
@@ -287,14 +304,15 @@ function PromptFormView({
|
||||
const titleId = useId();
|
||||
const filenameId = useId();
|
||||
const contentId = useId();
|
||||
const isBuiltin = Boolean(editingBuiltinPrompt);
|
||||
|
||||
return (
|
||||
<div className="cx-prompts-form-page">
|
||||
<header className="cx-prompts-header cx-prompts-form-header">
|
||||
<div className="cx-prompts-heading">
|
||||
<p><PencilLine size={14} aria-hidden="true" />{copy.formEyebrow}</p>
|
||||
<h2>{editingPromptId ? copy.editFormTitle : copy.addFormTitle}</h2>
|
||||
<span>{copy.formDescription}</span>
|
||||
<p><PencilLine size={14} aria-hidden="true" />{isBuiltin ? copy.builtinFormEyebrow : copy.formEyebrow}</p>
|
||||
<h2>{isBuiltin ? copy.builtinFormTitle : editingPromptId ? copy.editFormTitle : copy.addFormTitle}</h2>
|
||||
<span>{isBuiltin ? copy.builtinFormDescription : copy.formDescription}</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
@@ -310,6 +328,9 @@ function PromptFormView({
|
||||
<div className="cx-prompts-form-panel-head">
|
||||
<FileText size={18} aria-hidden="true" />
|
||||
<h3 id={`${titleId}-panel`}>{copy.promptDetails}</h3>
|
||||
{editingBuiltinPrompt?.customized && (
|
||||
<StatusBadge tone="info" dot={false}>{copy.locallyModified}</StatusBadge>
|
||||
)}
|
||||
</div>
|
||||
<div className="cx-prompts-form-grid">
|
||||
<label className="cx-prompts-field" htmlFor={titleId}>
|
||||
@@ -320,6 +341,7 @@ function PromptFormView({
|
||||
value={promptForm.title}
|
||||
onChange={(event) => onPromptFormFieldChange("title", event.currentTarget.value)}
|
||||
placeholder={copy.promptNamePlaceholder}
|
||||
readOnly={isBuiltin}
|
||||
disabled={loading}
|
||||
autoComplete="off"
|
||||
/>
|
||||
@@ -332,6 +354,7 @@ function PromptFormView({
|
||||
value={promptForm.filename}
|
||||
onChange={(event) => onPromptFormFieldChange("filename", event.currentTarget.value)}
|
||||
placeholder={copy.filenamePlaceholder}
|
||||
readOnly={isBuiltin}
|
||||
disabled={loading}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
@@ -358,7 +381,7 @@ function PromptFormView({
|
||||
onClick={() => run(onSavePrompt)}
|
||||
disabled={loading}
|
||||
>
|
||||
{copy.save}
|
||||
{isBuiltin ? copy.saveLocalChanges : copy.save}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -370,6 +393,7 @@ export function PromptsPage({
|
||||
instructionMode,
|
||||
promptForm,
|
||||
editingPromptId,
|
||||
editingBuiltinPrompt,
|
||||
loading,
|
||||
actionBusy,
|
||||
promptSyncing,
|
||||
@@ -382,6 +406,7 @@ export function PromptsPage({
|
||||
activeInstructionTitle,
|
||||
activeInjectionMode,
|
||||
instructionTemplates,
|
||||
builtinPromptStatuses,
|
||||
activeBuiltinTemplateId,
|
||||
orphanedBuiltinPrompt,
|
||||
savedPrompts,
|
||||
@@ -400,6 +425,7 @@ export function PromptsPage({
|
||||
onEnableSavedPrompt,
|
||||
onDisableExternalPrompt,
|
||||
onEditPrompt,
|
||||
onEditBuiltinPrompt,
|
||||
onDeletePrompt,
|
||||
onPromptFormFieldChange,
|
||||
onSavePrompt,
|
||||
@@ -412,6 +438,10 @@ export function PromptsPage({
|
||||
const importBusy = actionBusy === "importPrompt";
|
||||
const [categoryManagerOpen, setCategoryManagerOpen] = useState(false);
|
||||
const promptCategories = usePromptCategories(lang);
|
||||
const builtinStatusById = useMemo(
|
||||
() => new Map(builtinPromptStatuses.map((status) => [status.id, status])),
|
||||
[builtinPromptStatuses],
|
||||
);
|
||||
const categoryItems = useMemo<PromptCategoryItem[]>(() => [
|
||||
...instructionTemplates.map((template) => ({
|
||||
key: promptCategoryKey("builtin", template.id),
|
||||
@@ -441,11 +471,13 @@ export function PromptsPage({
|
||||
if (instructionMode === "form") {
|
||||
return (
|
||||
<PageTransition pageKey={`prompts:${instructionMode}`}>
|
||||
<section className={cx("cx-prompts-page", "cx-prompts-page--form", className)} aria-label={editingPromptId ? copy.editFormTitle : copy.addFormTitle}>
|
||||
<section className={cx("cx-prompts-page", "cx-prompts-page--form", className)} aria-label={editingBuiltinPrompt ? copy.builtinFormTitle : editingPromptId ? copy.editFormTitle : copy.addFormTitle}>
|
||||
<PromptFormView
|
||||
key={editingBuiltinPrompt?.id || editingPromptId || "new"}
|
||||
lang={lang}
|
||||
promptForm={promptForm}
|
||||
editingPromptId={editingPromptId}
|
||||
editingBuiltinPrompt={editingBuiltinPrompt}
|
||||
loading={loading}
|
||||
onInstructionModeChange={onInstructionModeChange}
|
||||
onPromptFormFieldChange={onPromptFormFieldChange}
|
||||
@@ -605,6 +637,7 @@ export function PromptsPage({
|
||||
{instructionTemplates.filter((template) =>
|
||||
promptIsVisible(promptCategoryKey("builtin", template.id))).map((template) => {
|
||||
const enabled = template.id === activeBuiltinTemplateId;
|
||||
const customized = Boolean(builtinStatusById.get(template.id)?.customized);
|
||||
return (
|
||||
<PromptRow
|
||||
key={template.id}
|
||||
@@ -614,10 +647,22 @@ export function PromptsPage({
|
||||
loading={loading}
|
||||
toggleLabel={enabled ? copy.disable : copy.enable}
|
||||
onToggle={() => enabled ? onDisableInstruction() : onEnableBuiltinPrompt(template.id)}
|
||||
actions={(
|
||||
<div className="cx-prompts-icon-actions">
|
||||
<IconButton
|
||||
icon={<PencilLine size={15} />}
|
||||
label={copy.editBuiltin}
|
||||
size="sm"
|
||||
onClick={() => onEditBuiltinPrompt(template.id)}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
{enabled && (
|
||||
{(enabled || customized) && (
|
||||
<div className="cx-prompts-row-meta">
|
||||
<StatusBadge tone="accent" dot={false}>{copy.current} · {activeModeLabel}</StatusBadge>
|
||||
{enabled && <StatusBadge tone="accent" dot={false}>{copy.current} · {activeModeLabel}</StatusBadge>}
|
||||
{customized && <StatusBadge tone="info" dot={false}>{copy.locallyModified}</StatusBadge>}
|
||||
</div>
|
||||
)}
|
||||
</PromptRow>
|
||||
|
||||
@@ -7,10 +7,12 @@ import {
|
||||
CheckCircle2,
|
||||
Eye,
|
||||
EyeOff,
|
||||
FilePlus2,
|
||||
Loader2,
|
||||
PencilLine,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
RotateCcw,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
@@ -52,6 +54,7 @@ export type ProviderFormValue = {
|
||||
export type OfficialFormValue = {
|
||||
model: string;
|
||||
authJson: string;
|
||||
configText: string;
|
||||
};
|
||||
|
||||
export type ProviderCopy = {
|
||||
@@ -79,7 +82,14 @@ export type ProviderCopy = {
|
||||
authPathLabel: string;
|
||||
officialCurrentLabel: string;
|
||||
officialAuthLabel: string;
|
||||
officialTomlLabel: string;
|
||||
officialSaveLabel: string;
|
||||
restoreOfficialLabel: string;
|
||||
resetOfficialLabel: string;
|
||||
resetOfficialTitle: string;
|
||||
resetOfficialDescription: string;
|
||||
resetOfficialCancelLabel: string;
|
||||
resetOfficialConfirmLabel: string;
|
||||
cancelLabel: string;
|
||||
formEyebrow: string;
|
||||
formAddTitle: string;
|
||||
@@ -125,6 +135,8 @@ export type ProvidersPageProps = {
|
||||
editingProviderId: string | null;
|
||||
providerForm: ProviderFormValue;
|
||||
officialForm: OfficialFormValue;
|
||||
officialAuthRef?: Ref<HTMLTextAreaElement>;
|
||||
officialTomlRef?: Ref<HTMLTextAreaElement>;
|
||||
officialInfo: ProviderOfficialInfo;
|
||||
providerAuthPreview: ReactNode;
|
||||
providerTomlDraft: string;
|
||||
@@ -134,6 +146,8 @@ export type ProvidersPageProps = {
|
||||
fetchingModels: boolean;
|
||||
onImportCcSwitch: () => void;
|
||||
onAddProvider: () => void;
|
||||
onRestoreOfficial: () => void;
|
||||
onResetOfficial: () => void;
|
||||
onEnableProvider: (row: ProviderRow) => void;
|
||||
onTestProvider: (row: ProviderRow) => void;
|
||||
onEditProvider: (row: ProviderRow) => void;
|
||||
@@ -141,6 +155,7 @@ export type ProvidersPageProps = {
|
||||
onCancelMode: () => void;
|
||||
onOfficialModelChange: (value: string) => void;
|
||||
onOfficialAuthChange: (value: string) => void;
|
||||
onOfficialConfigChange: (value: string) => void;
|
||||
onSaveOfficial: () => void;
|
||||
onApiKeyChange: (value: string) => void;
|
||||
onBaseUrlChange: (value: string) => void;
|
||||
@@ -220,13 +235,15 @@ function ListPage({
|
||||
actionBusy,
|
||||
onImportCcSwitch,
|
||||
onAddProvider,
|
||||
onRestoreOfficial,
|
||||
onEnableProvider,
|
||||
onTestProvider,
|
||||
onEditProvider,
|
||||
onDeleteProvider,
|
||||
}: Pick<ProvidersPageProps, "copy" | "providerRows" | "loading" | "testingId" | "actionBusy" | "onImportCcSwitch" | "onAddProvider" | "onEnableProvider" | "onTestProvider" | "onEditProvider" | "onDeleteProvider">) {
|
||||
}: Pick<ProvidersPageProps, "copy" | "providerRows" | "loading" | "testingId" | "actionBusy" | "onImportCcSwitch" | "onAddProvider" | "onRestoreOfficial" | "onEnableProvider" | "onTestProvider" | "onEditProvider" | "onDeleteProvider">) {
|
||||
const [providerToDelete, setProviderToDelete] = useState<ProviderRow | null>(null);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const providerActionsBusy = loading || Boolean(actionBusy);
|
||||
|
||||
const closeDeleteDialog = () => {
|
||||
if (!deleting) setProviderToDelete(null);
|
||||
@@ -255,12 +272,12 @@ function ListPage({
|
||||
type="button"
|
||||
className="cx-providers-button cx-providers-button--secondary"
|
||||
onClick={onImportCcSwitch}
|
||||
disabled={loading || Boolean(actionBusy)}
|
||||
disabled={providerActionsBusy}
|
||||
>
|
||||
{actionBusy === "importCcSwitch" ? <Loader2 size={15} className="cx-providers-spin" aria-hidden="true" /> : <RefreshCw size={15} aria-hidden="true" />}
|
||||
{copy.importLabel}
|
||||
</button>
|
||||
<button type="button" className="cx-providers-button cx-providers-button--dark" onClick={onAddProvider} disabled={loading}>
|
||||
<button type="button" className="cx-providers-button cx-providers-button--dark" onClick={onAddProvider} disabled={providerActionsBusy}>
|
||||
<Plus size={15} aria-hidden="true" />
|
||||
{copy.addLabel}
|
||||
</button>
|
||||
@@ -294,23 +311,31 @@ function ListPage({
|
||||
type="button"
|
||||
className="cx-providers-button cx-providers-button--small cx-providers-button--secondary"
|
||||
onClick={() => onEnableProvider(row)}
|
||||
disabled={loading || row.isCurrent}
|
||||
disabled={providerActionsBusy || row.isCurrent}
|
||||
>
|
||||
{copy.enableLabel}
|
||||
</button>
|
||||
{row.source === "official" && (
|
||||
<ActionIconButton
|
||||
icon={RotateCcw}
|
||||
label={copy.restoreOfficialLabel}
|
||||
onClick={onRestoreOfficial}
|
||||
disabled={providerActionsBusy}
|
||||
/>
|
||||
)}
|
||||
{row.testable !== false && (
|
||||
<ActionIconButton
|
||||
icon={isTesting ? Loader2 : Activity}
|
||||
label={copy.testLabel}
|
||||
onClick={() => onTestProvider(row)}
|
||||
disabled={loading || isTesting}
|
||||
disabled={providerActionsBusy || isTesting}
|
||||
/>
|
||||
)}
|
||||
{row.editable !== false && (
|
||||
<ActionIconButton icon={PencilLine} label={copy.editLabel} onClick={() => onEditProvider(row)} disabled={loading} />
|
||||
<ActionIconButton icon={PencilLine} label={copy.editLabel} onClick={() => onEditProvider(row)} disabled={providerActionsBusy} />
|
||||
)}
|
||||
{row.deletable && (
|
||||
<ActionIconButton icon={Trash2} label={copy.removeLabel} onClick={() => setProviderToDelete(row)} disabled={loading} danger />
|
||||
<ActionIconButton icon={Trash2} label={copy.removeLabel} onClick={() => setProviderToDelete(row)} disabled={providerActionsBusy} danger />
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
@@ -354,7 +379,7 @@ function ListPage({
|
||||
);
|
||||
}
|
||||
|
||||
function ModeHeader({ eyebrow, title, description, cancelLabel, onCancel }: { eyebrow: string; title: string; description: string; cancelLabel: string; onCancel: () => void }) {
|
||||
function ModeHeader({ eyebrow, title, description, cancelLabel, onCancel, disabled = false }: { eyebrow: string; title: string; description: string; cancelLabel: string; onCancel: () => void; disabled?: boolean }) {
|
||||
return (
|
||||
<header className="cx-providers-form-header">
|
||||
<div>
|
||||
@@ -362,7 +387,7 @@ function ModeHeader({ eyebrow, title, description, cancelLabel, onCancel }: { ey
|
||||
<h2>{title}</h2>
|
||||
<p>{description}</p>
|
||||
</div>
|
||||
<button type="button" className="cx-providers-button cx-providers-button--secondary" onClick={onCancel}>
|
||||
<button type="button" className="cx-providers-button cx-providers-button--secondary" onClick={onCancel} disabled={disabled}>
|
||||
<ArrowLeft size={15} aria-hidden="true" />
|
||||
{cancelLabel}
|
||||
</button>
|
||||
@@ -373,36 +398,87 @@ function ModeHeader({ eyebrow, title, description, cancelLabel, onCancel }: { ey
|
||||
function OfficialForm({
|
||||
copy,
|
||||
officialForm,
|
||||
officialAuthRef,
|
||||
officialTomlRef,
|
||||
officialInfo,
|
||||
loading,
|
||||
actionBusy,
|
||||
onCancelMode,
|
||||
onOfficialModelChange,
|
||||
onOfficialAuthChange,
|
||||
onOfficialConfigChange,
|
||||
onSaveOfficial,
|
||||
}: Pick<ProvidersPageProps, "copy" | "officialForm" | "officialInfo" | "loading" | "onCancelMode" | "onOfficialModelChange" | "onOfficialAuthChange" | "onSaveOfficial">) {
|
||||
onRestoreOfficial,
|
||||
onResetOfficial,
|
||||
}: Pick<ProvidersPageProps, "copy" | "officialForm" | "officialAuthRef" | "officialTomlRef" | "officialInfo" | "loading" | "actionBusy" | "onCancelMode" | "onOfficialModelChange" | "onOfficialAuthChange" | "onOfficialConfigChange" | "onSaveOfficial" | "onRestoreOfficial" | "onResetOfficial">) {
|
||||
const [resetConfirmOpen, setResetConfirmOpen] = useState(false);
|
||||
const formBusy = loading || actionBusy === "loadOfficialDraft";
|
||||
|
||||
const confirmReset = () => {
|
||||
setResetConfirmOpen(false);
|
||||
onResetOfficial();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<ModeHeader eyebrow={copy.officialEyebrow} title={copy.officialTitle} description={copy.officialHint} cancelLabel={copy.cancelLabel} onCancel={onCancelMode} />
|
||||
<ModeHeader eyebrow={copy.officialEyebrow} title={copy.officialTitle} description={copy.officialHint} cancelLabel={copy.cancelLabel} onCancel={onCancelMode} disabled={formBusy} />
|
||||
<div className="cx-providers-info-grid">
|
||||
<div><span>{copy.officialUrlLabel}</span><code>{officialInfo.officialUrl}</code></div>
|
||||
<div><span>{copy.authPathLabel}</span><code>{officialInfo.authPath}</code></div>
|
||||
<div><span>{copy.officialCurrentLabel}</span><code>{officialInfo.current}</code></div>
|
||||
</div>
|
||||
<div className="cx-providers-form-grid cx-providers-form-grid--single">
|
||||
<Field label={copy.modelLabel}><input value={officialForm.model} onChange={(event) => onOfficialModelChange(event.target.value)} /></Field>
|
||||
<Field label={copy.modelLabel}><input value={officialForm.model} onChange={(event) => onOfficialModelChange(event.target.value)} disabled={formBusy} /></Field>
|
||||
</div>
|
||||
<Field label={copy.officialAuthLabel} className="cx-providers-editor-field">
|
||||
<Field label={copy.officialTomlLabel} className="cx-providers-editor-field">
|
||||
<textarea
|
||||
className="cx-providers-code-editor cx-providers-auth-editor"
|
||||
value={officialForm.authJson}
|
||||
onChange={(event) => onOfficialAuthChange(event.target.value)}
|
||||
wrap="off"
|
||||
ref={officialTomlRef}
|
||||
className="cx-providers-code-editor cx-providers-toml-editor"
|
||||
value={officialForm.configText}
|
||||
onChange={(event) => onOfficialConfigChange(event.target.value)}
|
||||
disabled={formBusy}
|
||||
spellCheck={false}
|
||||
/>
|
||||
</Field>
|
||||
<div className="cx-providers-form-actions cx-providers-form-actions--save">
|
||||
<button type="button" className="cx-providers-button cx-providers-button--primary" onClick={onSaveOfficial} disabled={loading}><CheckCircle2 size={15} aria-hidden="true" />{copy.officialSaveLabel}</button>
|
||||
<Field label={copy.officialAuthLabel} className="cx-providers-editor-field">
|
||||
<textarea
|
||||
ref={officialAuthRef}
|
||||
className="cx-providers-code-editor cx-providers-auth-editor"
|
||||
value={officialForm.authJson}
|
||||
onChange={(event) => onOfficialAuthChange(event.target.value)}
|
||||
disabled={formBusy}
|
||||
wrap="soft"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</Field>
|
||||
<div className="cx-providers-form-actions cx-providers-form-actions--save cx-providers-official-actions">
|
||||
<button type="button" className="cx-providers-button cx-providers-button--secondary" onClick={onRestoreOfficial} disabled={formBusy}>
|
||||
<RotateCcw size={15} aria-hidden="true" />{copy.restoreOfficialLabel}
|
||||
</button>
|
||||
<button type="button" className="cx-providers-button cx-providers-button--secondary" onClick={() => setResetConfirmOpen(true)} disabled={formBusy}>
|
||||
<FilePlus2 size={15} aria-hidden="true" />{copy.resetOfficialLabel}
|
||||
</button>
|
||||
<button type="button" className="cx-providers-button cx-providers-button--primary" onClick={onSaveOfficial} disabled={formBusy}><CheckCircle2 size={15} aria-hidden="true" />{copy.officialSaveLabel}</button>
|
||||
</div>
|
||||
<ModalShell
|
||||
open={resetConfirmOpen}
|
||||
onClose={() => setResetConfirmOpen(false)}
|
||||
title={copy.resetOfficialTitle}
|
||||
description={copy.resetOfficialDescription}
|
||||
size="sm"
|
||||
closeLabel={copy.resetOfficialCancelLabel}
|
||||
footer={(
|
||||
<>
|
||||
<Button variant="secondary" onClick={() => setResetConfirmOpen(false)} data-initial-focus>{copy.resetOfficialCancelLabel}</Button>
|
||||
<Button variant="danger" icon={<FilePlus2 size={16} />} onClick={confirmReset}>{copy.resetOfficialConfirmLabel}</Button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<div className="cx-provider-delete-warning">
|
||||
<span aria-hidden="true"><AlertTriangle size={22} /></span>
|
||||
<strong>config.toml + auth.json</strong>
|
||||
</div>
|
||||
</ModalShell>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -433,6 +509,7 @@ function ProviderForm({
|
||||
}: Pick<ProvidersPageProps, "copy" | "providerForm" | "loading" | "editingProviderId" | "providerAuthPreview" | "providerTomlDraft" | "providerTomlRef" | "apiKeyVisible" | "availableModels" | "fetchingModels" | "onCancelMode" | "onApiKeyChange" | "onBaseUrlChange" | "onProviderNameChange" | "onProviderModelChange" | "onFetchModels" | "onWireApiChange" | "onRequiresAuthChange" | "onToggleApiKeyVisibility" | "onProviderTomlDraftChange" | "onResetProviderToml" | "onSaveProvider">) {
|
||||
const modelListId = useId();
|
||||
const canFetchModels = Boolean(providerForm.baseUrl.trim() && providerForm.apiKey.trim());
|
||||
const formBusy = loading || fetchingModels;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -442,6 +519,7 @@ function ProviderForm({
|
||||
description={copy.formHint}
|
||||
cancelLabel={copy.cancelLabel}
|
||||
onCancel={onCancelMode}
|
||||
disabled={formBusy}
|
||||
/>
|
||||
|
||||
<section className="cx-providers-form-section">
|
||||
@@ -456,14 +534,15 @@ function ProviderForm({
|
||||
value={providerForm.apiKey}
|
||||
onChange={(event) => onApiKeyChange(event.target.value)}
|
||||
placeholder={copy.apiKeyPlaceholder}
|
||||
disabled={formBusy}
|
||||
/>
|
||||
<button type="button" onClick={onToggleApiKeyVisibility} title={apiKeyVisible ? copy.hideApiKeyLabel : copy.showApiKeyLabel} aria-label={apiKeyVisible ? copy.hideApiKeyLabel : copy.showApiKeyLabel}>
|
||||
<button type="button" onClick={onToggleApiKeyVisibility} title={apiKeyVisible ? copy.hideApiKeyLabel : copy.showApiKeyLabel} aria-label={apiKeyVisible ? copy.hideApiKeyLabel : copy.showApiKeyLabel} disabled={formBusy}>
|
||||
{apiKeyVisible ? <EyeOff size={15} aria-hidden="true" /> : <Eye size={15} aria-hidden="true" />}
|
||||
</button>
|
||||
</div>
|
||||
</Field>
|
||||
<Field label={copy.baseUrlLabel} className="cx-providers-field--full"><input value={providerForm.baseUrl} onChange={(event) => onBaseUrlChange(event.target.value)} /></Field>
|
||||
<Field label={copy.nameLabel}><input value={providerForm.providerName} onChange={(event) => onProviderNameChange(event.target.value)} /></Field>
|
||||
<Field label={copy.baseUrlLabel} className="cx-providers-field--full"><input value={providerForm.baseUrl} onChange={(event) => onBaseUrlChange(event.target.value)} disabled={formBusy} /></Field>
|
||||
<Field label={copy.nameLabel}><input value={providerForm.providerName} onChange={(event) => onProviderNameChange(event.target.value)} disabled={formBusy} /></Field>
|
||||
<Field label={copy.modelLabel}>
|
||||
<div className="cx-providers-model-input-row">
|
||||
<input
|
||||
@@ -471,6 +550,7 @@ function ProviderForm({
|
||||
list={availableModels.length ? modelListId : undefined}
|
||||
aria-label={copy.modelLabel}
|
||||
onChange={(event) => onProviderModelChange(event.target.value)}
|
||||
disabled={formBusy}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@@ -495,6 +575,7 @@ function ProviderForm({
|
||||
className="cx-providers-model-select"
|
||||
value=""
|
||||
aria-label={copy.chooseModelLabel(availableModels.length)}
|
||||
disabled={formBusy}
|
||||
onChange={(event) => {
|
||||
if (event.target.value) onProviderModelChange(event.target.value);
|
||||
}}
|
||||
@@ -506,7 +587,7 @@ function ProviderForm({
|
||||
)}
|
||||
</Field>
|
||||
<Field label={copy.wireApiLabel}>
|
||||
<select value={providerForm.wireApi} onChange={(event) => onWireApiChange(event.target.value)}>
|
||||
<select value={providerForm.wireApi} onChange={(event) => onWireApiChange(event.target.value)} disabled={formBusy}>
|
||||
<option value="responses">responses</option>
|
||||
<option value="chat">chat</option>
|
||||
</select>
|
||||
@@ -516,6 +597,7 @@ function ProviderForm({
|
||||
checked={providerForm.requiresOpenaiAuth}
|
||||
onCheckedChange={onRequiresAuthChange}
|
||||
label={copy.requiresAuthLabel}
|
||||
disabled={formBusy}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
@@ -528,13 +610,13 @@ function ProviderForm({
|
||||
<section className="cx-providers-form-section">
|
||||
<div className="cx-providers-section-heading cx-providers-section-heading--with-action">
|
||||
<div><h3>{copy.tomlTitle}</h3><p>{copy.tomlDescription}</p></div>
|
||||
<button type="button" className="cx-providers-button cx-providers-button--secondary cx-providers-button--small" onClick={onResetProviderToml}><RefreshCw size={14} aria-hidden="true" />{copy.resetTomlLabel}</button>
|
||||
<button type="button" className="cx-providers-button cx-providers-button--secondary cx-providers-button--small" onClick={onResetProviderToml} disabled={formBusy}><RefreshCw size={14} aria-hidden="true" />{copy.resetTomlLabel}</button>
|
||||
</div>
|
||||
<textarea ref={providerTomlRef} className="cx-providers-code-editor cx-providers-toml-editor" value={providerTomlDraft} onChange={(event) => onProviderTomlDraftChange(event.target.value)} spellCheck={false} />
|
||||
<textarea ref={providerTomlRef} className="cx-providers-code-editor cx-providers-toml-editor" value={providerTomlDraft} onChange={(event) => onProviderTomlDraftChange(event.target.value)} disabled={formBusy} spellCheck={false} />
|
||||
</section>
|
||||
|
||||
<div className="cx-providers-form-actions cx-providers-form-actions--save">
|
||||
<button type="button" className="cx-providers-button cx-providers-button--primary" onClick={onSaveProvider} disabled={loading}>
|
||||
<button type="button" className="cx-providers-button cx-providers-button--primary" onClick={onSaveProvider} disabled={formBusy}>
|
||||
{loading ? <Loader2 size={15} className="cx-providers-spin" aria-hidden="true" /> : <CheckCircle2 size={15} aria-hidden="true" />}
|
||||
{loading ? copy.savingLabel : copy.saveLabel}
|
||||
</button>
|
||||
|
||||
@@ -41,7 +41,10 @@ export type SessionSyncStatus = {
|
||||
topLevelThreads: number;
|
||||
subagentThreads: number;
|
||||
mismatchedThreads: number;
|
||||
mismatchedSessions: number;
|
||||
needsSync: boolean;
|
||||
scanComplete: boolean;
|
||||
scanFailures: string[];
|
||||
backupDir?: string | null;
|
||||
warnings: string[];
|
||||
sessions: SessionPreview[];
|
||||
@@ -153,13 +156,14 @@ export function SessionManagementPage({
|
||||
? {
|
||||
syncEyebrow: "会话同步",
|
||||
title: "会话管理",
|
||||
description: "检查本地会话是否跟当前供应商一致,需要时一键同步。不会修改聊天内容。",
|
||||
description: "检查本地会话是否位于官方与中转共用的会话列表,需要时一键同步。不会修改聊天内容。",
|
||||
syncTo: "同步到",
|
||||
check: "检查会话",
|
||||
checking: "检查中...",
|
||||
sync: "同步会话",
|
||||
syncing: "同步中...",
|
||||
clickToCheck: "点击检查会话",
|
||||
scanIncomplete: "无法确认同步状态,请查看下方原因",
|
||||
needsSync: (count: number) => `有 ${count} 条会话需要同步`,
|
||||
allSynced: "全部会话已同步",
|
||||
sessionCount: (count: number) => `${count} 条会话`,
|
||||
@@ -201,13 +205,14 @@ export function SessionManagementPage({
|
||||
: {
|
||||
syncEyebrow: "SESSION SYNC",
|
||||
title: "Session management",
|
||||
description: "Check whether local sessions match the current provider and sync them when needed. Chat content is not changed.",
|
||||
description: "Keep local sessions in one history shared by official and third-party providers. Chat content is not changed.",
|
||||
syncTo: "Sync to",
|
||||
check: "Check sessions",
|
||||
checking: "Checking...",
|
||||
sync: "Sync sessions",
|
||||
syncing: "Syncing...",
|
||||
clickToCheck: "Check sessions to get started",
|
||||
scanIncomplete: "Unable to verify sync status. See the reason below.",
|
||||
needsSync: (count: number) => `${count} session(s) need syncing`,
|
||||
allSynced: "All sessions are synced",
|
||||
sessionCount: (count: number) => `${count} sessions`,
|
||||
@@ -247,6 +252,11 @@ export function SessionManagementPage({
|
||||
confirmDelete: (count: number) => `Delete ${count} permanently`,
|
||||
};
|
||||
|
||||
const scanIncomplete = Boolean(sessionStatus && !sessionStatus.scanComplete);
|
||||
const diagnostics = [
|
||||
...(sessionStatus?.scanFailures || []).map((message) => ({ message, blocking: true })),
|
||||
...(sessionStatus?.warnings || []).map((message) => ({ message, blocking: false })),
|
||||
];
|
||||
const dialogOpen = sessionDeleteConfirmOpen && selectedSessions.length > 0;
|
||||
const selectedVisibleCount = filteredSessions.filter((item) => selectedSessionSet.has(item.id)).length;
|
||||
const allVisibleSelected = filteredSessions.length > 0 && selectedVisibleCount === filteredSessions.length;
|
||||
@@ -327,17 +337,17 @@ export function SessionManagementPage({
|
||||
{actionBusy === "checkSessions" ? <Loader2 size={16} className="cx-session-spin" aria-hidden="true" /> : <RefreshCw size={16} aria-hidden="true" />}
|
||||
{actionBusy === "checkSessions" ? copy.checking : copy.check}
|
||||
</button>
|
||||
<button type="button" className="cx-session-button cx-session-button--primary" onClick={onSyncSessions} disabled={loading || !sessionHasMismatches} aria-busy={actionBusy === "syncSessions"}>
|
||||
<button type="button" className="cx-session-button cx-session-button--primary" onClick={onSyncSessions} disabled={loading || scanIncomplete || !sessionHasMismatches} aria-busy={actionBusy === "syncSessions"}>
|
||||
{actionBusy === "syncSessions" ? <Loader2 size={16} className="cx-session-spin" aria-hidden="true" /> : <Zap size={16} aria-hidden="true" />}
|
||||
{actionBusy === "syncSessions" ? copy.syncing : copy.sync}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className={cx("cx-session-summary", sessionHasMismatches ? "cx-session-summary--needs-sync" : "cx-session-summary--synced")}>
|
||||
<div className={cx("cx-session-summary", scanIncomplete || sessionHasMismatches ? "cx-session-summary--needs-sync" : "cx-session-summary--synced")}>
|
||||
<span className="cx-session-summary-status">
|
||||
{!sessionStatus ? <Info size={15} aria-hidden="true" /> : sessionHasMismatches ? <AlertCircle size={15} aria-hidden="true" /> : <CheckCircle2 size={15} aria-hidden="true" />}
|
||||
{!sessionStatus ? copy.clickToCheck : sessionHasMismatches ? copy.needsSync(sessionSyncCount) : copy.allSynced}
|
||||
{!sessionStatus ? <Info size={15} aria-hidden="true" /> : scanIncomplete || sessionHasMismatches ? <AlertCircle size={15} aria-hidden="true" /> : <CheckCircle2 size={15} aria-hidden="true" />}
|
||||
{!sessionStatus ? copy.clickToCheck : scanIncomplete ? copy.scanIncomplete : sessionHasMismatches ? copy.needsSync(sessionSyncCount) : copy.allSynced}
|
||||
</span>
|
||||
<span className="cx-session-summary-count">{copy.sessionCount(sessionStatus?.topLevelThreads ?? 0)}</span>
|
||||
</div>
|
||||
@@ -475,15 +485,15 @@ export function SessionManagementPage({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{sessionStatus?.warnings?.length ? (
|
||||
<details className="cx-session-diagnostics">
|
||||
{diagnostics.length ? (
|
||||
<details className="cx-session-diagnostics" open={scanIncomplete || undefined}>
|
||||
<summary>
|
||||
<AlertCircle size={15} strokeWidth={1.9} aria-hidden="true" />
|
||||
<span>{copy.diagnostics}</span>
|
||||
<small>{copy.diagnosticsCount(sessionStatus.warnings.length)}</small>
|
||||
<small>{copy.diagnosticsCount(diagnostics.length)}</small>
|
||||
</summary>
|
||||
<div className="cx-session-diagnostic-items">
|
||||
{sessionStatus.warnings.map((item, index) => <p key={`${index}-${item}`}><Info size={14} aria-hidden="true" />{item}</p>)}
|
||||
{diagnostics.map((item, index) => <p key={`${index}-${item.message}`}>{item.blocking ? <AlertCircle size={14} aria-hidden="true" /> : <Info size={14} aria-hidden="true" />}{item.message}</p>)}
|
||||
</div>
|
||||
</details>
|
||||
) : null}
|
||||
|
||||
@@ -2,7 +2,7 @@ import * as React from "react";
|
||||
|
||||
import type { Lang } from "./types";
|
||||
|
||||
const STORAGE_KEY = "codexx.promptCategories.v1";
|
||||
const PROMPT_STORAGE_KEY = "codexx.promptCategories.v1";
|
||||
const STATE_VERSION = 1 as const;
|
||||
export const PROMPT_CATEGORY_NAME_MAX_LENGTH = 40;
|
||||
|
||||
@@ -19,7 +19,20 @@ type PromptCategoryState = {
|
||||
assignments: Record<string, string>;
|
||||
};
|
||||
|
||||
function defaultCategories(lang: Lang): PromptCategory[] {
|
||||
export type ManagedCategoryOptions = {
|
||||
storageKey: string;
|
||||
defaultCategories: (lang: Lang) => PromptCategory[];
|
||||
defaultCategoryForPrompt?: (promptKey: string) => string | undefined;
|
||||
};
|
||||
|
||||
export function defaultPromptCategoryForKey(promptKey: string) {
|
||||
const normalized = promptKey.trim().toLowerCase();
|
||||
if (normalized.includes("software-development-")) return "software-development";
|
||||
if (normalized.includes("writing-")) return "writing";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function defaultPromptCategories(lang: Lang): PromptCategory[] {
|
||||
return lang === "zh"
|
||||
? [
|
||||
{ id: "security-reverse", name: "破甲/逆向" },
|
||||
@@ -33,8 +46,14 @@ function defaultCategories(lang: Lang): PromptCategory[] {
|
||||
];
|
||||
}
|
||||
|
||||
function initialState(lang: Lang): PromptCategoryState {
|
||||
const categories = defaultCategories(lang);
|
||||
const PROMPT_CATEGORY_OPTIONS: ManagedCategoryOptions = {
|
||||
storageKey: PROMPT_STORAGE_KEY,
|
||||
defaultCategories: defaultPromptCategories,
|
||||
defaultCategoryForPrompt: defaultPromptCategoryForKey,
|
||||
};
|
||||
|
||||
function initialState(lang: Lang, options: ManagedCategoryOptions): PromptCategoryState {
|
||||
const categories = options.defaultCategories(lang);
|
||||
return {
|
||||
version: STATE_VERSION,
|
||||
categories,
|
||||
@@ -48,8 +67,12 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export function sanitizePromptCategoryState(value: unknown, lang: Lang): PromptCategoryState {
|
||||
const fallback = initialState(lang);
|
||||
export function sanitizePromptCategoryState(
|
||||
value: unknown,
|
||||
lang: Lang,
|
||||
options: ManagedCategoryOptions = PROMPT_CATEGORY_OPTIONS,
|
||||
): PromptCategoryState {
|
||||
const fallback = initialState(lang, options);
|
||||
if (!isRecord(value) || value.version !== STATE_VERSION || !Array.isArray(value.categories)) {
|
||||
return fallback;
|
||||
}
|
||||
@@ -91,19 +114,19 @@ export function sanitizePromptCategoryState(value: unknown, lang: Lang): PromptC
|
||||
};
|
||||
}
|
||||
|
||||
function loadState(lang: Lang): PromptCategoryState {
|
||||
function loadState(lang: Lang, options: ManagedCategoryOptions): PromptCategoryState {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return initialState(lang);
|
||||
return sanitizePromptCategoryState(JSON.parse(raw), lang);
|
||||
const raw = localStorage.getItem(options.storageKey);
|
||||
if (!raw) return initialState(lang, options);
|
||||
return sanitizePromptCategoryState(JSON.parse(raw), lang, options);
|
||||
} catch {
|
||||
return initialState(lang);
|
||||
return initialState(lang, options);
|
||||
}
|
||||
}
|
||||
|
||||
function saveState(state: PromptCategoryState) {
|
||||
function saveState(storageKey: string, state: PromptCategoryState) {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
|
||||
localStorage.setItem(storageKey, JSON.stringify(state));
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
@@ -122,12 +145,12 @@ function nextCategoryId(categories: PromptCategory[]) {
|
||||
return id;
|
||||
}
|
||||
|
||||
export function usePromptCategories(lang: Lang) {
|
||||
const [state, setState] = React.useState<PromptCategoryState>(() => loadState(lang));
|
||||
export function useManagedCategories(lang: Lang, options: ManagedCategoryOptions) {
|
||||
const [state, setState] = React.useState<PromptCategoryState>(() => loadState(lang, options));
|
||||
|
||||
React.useEffect(() => {
|
||||
saveState(state);
|
||||
}, [state]);
|
||||
saveState(options.storageKey, state);
|
||||
}, [options.storageKey, state]);
|
||||
|
||||
const categoryIds = React.useMemo(
|
||||
() => new Set(state.categories.map((category) => category.id)),
|
||||
@@ -137,9 +160,11 @@ export function usePromptCategories(lang: Lang) {
|
||||
const categoryForPrompt = React.useCallback(
|
||||
(promptKey: string) => {
|
||||
const assigned = state.assignments[promptKey];
|
||||
return assigned && categoryIds.has(assigned) ? assigned : state.defaultCategoryId;
|
||||
if (assigned && categoryIds.has(assigned)) return assigned;
|
||||
const inferred = options.defaultCategoryForPrompt?.(promptKey);
|
||||
return inferred && categoryIds.has(inferred) ? inferred : state.defaultCategoryId;
|
||||
},
|
||||
[categoryIds, state.assignments, state.defaultCategoryId],
|
||||
[categoryIds, options, state.assignments, state.defaultCategoryId],
|
||||
);
|
||||
|
||||
const setActiveCategoryId = React.useCallback((categoryId: string) => {
|
||||
@@ -210,11 +235,15 @@ export function usePromptCategories(lang: Lang) {
|
||||
if (!promptKey || !categoryIds.has(categoryId)) return;
|
||||
setState((current) => {
|
||||
const assignments = { ...current.assignments };
|
||||
if (categoryId === current.defaultCategoryId) delete assignments[promptKey];
|
||||
const inferred = options.defaultCategoryForPrompt?.(promptKey);
|
||||
const naturalCategoryId = inferred && categoryIds.has(inferred)
|
||||
? inferred
|
||||
: current.defaultCategoryId;
|
||||
if (categoryId === naturalCategoryId) delete assignments[promptKey];
|
||||
else assignments[promptKey] = categoryId;
|
||||
return { ...current, assignments };
|
||||
});
|
||||
}, [categoryIds]);
|
||||
}, [categoryIds, options]);
|
||||
|
||||
const forgetPrompt = React.useCallback((promptKey: string) => {
|
||||
if (!promptKey) return;
|
||||
@@ -238,3 +267,7 @@ export function usePromptCategories(lang: Lang) {
|
||||
forgetPrompt,
|
||||
};
|
||||
}
|
||||
|
||||
export function usePromptCategories(lang: Lang) {
|
||||
return useManagedCategories(lang, PROMPT_CATEGORY_OPTIONS);
|
||||
}
|
||||
|
||||
@@ -350,3 +350,11 @@
|
||||
transform: translateX(80%);
|
||||
}
|
||||
}
|
||||
|
||||
.cx-dialog-spin {
|
||||
animation: cx-app-dialog-spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes cx-app-dialog-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@@ -426,6 +426,26 @@
|
||||
transform-origin: center top;
|
||||
}
|
||||
|
||||
.cx-state-loading {
|
||||
min-width: 0;
|
||||
min-height: 280px;
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
color: var(--ui-text-muted);
|
||||
font-size: 13px;
|
||||
font-weight: 620;
|
||||
line-height: 1.45;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.cx-state-loading svg {
|
||||
flex: 0 0 auto;
|
||||
color: var(--ui-accent);
|
||||
}
|
||||
|
||||
.cx-page-transition--exit,
|
||||
.cx-page-transition--enter {
|
||||
will-change: opacity, transform;
|
||||
|
||||
@@ -702,15 +702,22 @@
|
||||
}
|
||||
|
||||
.cx-providers-auth-editor {
|
||||
overflow: auto;
|
||||
white-space: pre;
|
||||
overflow-wrap: normal;
|
||||
min-height: 420px;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.cx-providers-toml-editor {
|
||||
min-height: 360px;
|
||||
}
|
||||
|
||||
.cx-providers-editor-field + .cx-providers-editor-field {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.cx-providers-form-actions {
|
||||
padding: 2px 0 4px;
|
||||
}
|
||||
@@ -719,6 +726,10 @@
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.cx-providers-official-actions {
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.cx-providers-spin {
|
||||
animation: cx-providers-spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@@ -24,13 +24,18 @@
|
||||
color: var(--cx-tool-text);
|
||||
}
|
||||
|
||||
.cx-skills-page,
|
||||
.cx-prompts-page--list {
|
||||
.cx-skills-page {
|
||||
height: 100%;
|
||||
flex: 1 1 auto;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.cx-prompts-page--list {
|
||||
min-height: 100%;
|
||||
flex: 0 0 auto;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.cx-prompts-page--form {
|
||||
min-height: 100%;
|
||||
}
|
||||
@@ -1074,7 +1079,7 @@
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex: 0 0 auto;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
@@ -1082,12 +1087,12 @@
|
||||
.cx-prompts-list {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
flex: 1 1 auto;
|
||||
flex: 0 0 auto;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
align-content: start;
|
||||
gap: 16px;
|
||||
overflow-y: auto;
|
||||
overflow: visible;
|
||||
padding: 0 8px 32px 0;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
@@ -1283,6 +1288,10 @@
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.cx-prompts-form-panel-head .ui-status-badge {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.cx-prompts-form-grid {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
@@ -1357,6 +1366,12 @@
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.cx-prompts-field input:read-only:not(:disabled) {
|
||||
cursor: default;
|
||||
color: #646971;
|
||||
background: #f1f3f5;
|
||||
}
|
||||
|
||||
.cx-prompts-field--content {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
@@ -53,6 +53,15 @@ export type BuiltinPromptStatus = {
|
||||
syncIssue?: "catalog" | "content" | null;
|
||||
checkedAt?: string | null;
|
||||
message: string;
|
||||
customized: boolean;
|
||||
};
|
||||
|
||||
export type BuiltinPromptDetail = {
|
||||
id: string;
|
||||
filename: string;
|
||||
title: string;
|
||||
content: string;
|
||||
customized: boolean;
|
||||
};
|
||||
|
||||
export type BackupEntry = {
|
||||
@@ -74,6 +83,7 @@ export type CodexState = {
|
||||
officialAuthAvailable: boolean;
|
||||
model?: string;
|
||||
modelProvider?: string;
|
||||
isOfficialProvider: boolean;
|
||||
instructionFile?: string;
|
||||
instructionEnabled: boolean;
|
||||
instructionInjectionMode?: PromptInjectionMode;
|
||||
@@ -94,6 +104,13 @@ export type ActionResult = {
|
||||
state: CodexState;
|
||||
};
|
||||
|
||||
export type OfficialConfigDraft = {
|
||||
authJson: string;
|
||||
configText: string;
|
||||
model?: string;
|
||||
source: string;
|
||||
};
|
||||
|
||||
export type ImportResult = {
|
||||
imported: number;
|
||||
added: number;
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
# Codex-X 项目维护日志
|
||||
|
||||
本文件记录面向维护者的根因、设计决策、验证依据和遗留风险。它与
|
||||
`CHANGELOG.md` 分工如下:
|
||||
|
||||
- `CHANGELOG.md` 面向用户,记录每个发布版本可感知的新增、调整和修复。
|
||||
- 本文件面向开发维护,解释为什么修改、哪些约束不能破坏,以及如何验证。
|
||||
|
||||
日志不得包含 Token、认证文件内容、用户数据或仅适用于某台电脑的隐私路径。
|
||||
|
||||
## 2026-08-11:供应商认证往返、模板完整性与启动读取
|
||||
|
||||
### 决策
|
||||
|
||||
- 新建和 CC Switch 导入的供应商保存完整、无密钥的 TOML 模板;API Key 单独保存在供应商
|
||||
数据库字段中。完整模板是该供应商的权威配置,切换时恢复项目、插件、MCP、桌面、功能和
|
||||
环境设置;旧版稀疏模板仍以当前 live 配置为底稿兼容合并。
|
||||
- 有可信 ChatGPT OAuth 时,第三方切换保留 live `auth.json`,并把目标 Key 写入活动
|
||||
provider 的 `experimental_bearer_token`;没有 OAuth 时继续写 `OPENAI_API_KEY` 以维持
|
||||
Codex 登录状态,同时也写 provider 作用域 Key,避免两文件切换窗口使用错凭据。
|
||||
- 官方配置独立保存完整 `config.toml`、模型和 OAuth/API Key `auth.json`。官方与第三方往返
|
||||
先发布目标路由,再按需更新 auth,失败时按条件原子回滚;未登录的官方 Reset 快照也必须
|
||||
保留完整 TOML。自动捕获不得让官方 live 中残留的中转 API Key 覆盖可信 OAuth 快照。
|
||||
- 兼容无 `auth_mode` 但包含有效 token、且不含 API Key 的旧版官方 OAuth;损坏的 live
|
||||
`auth.json` 与损坏的应用内官方快照都不阻塞状态读取或供应商切换,且绝不能被晋升为官方
|
||||
认证快照。完整官方 TOML 是模型字段的权威源,独立模型输入只补齐缺失值。
|
||||
- 启动状态读取不得迁移提示词、修改文件权限、捕获认证或扫描历史备份;概览在结果返回前显示
|
||||
“正在读取”,失败后显示“读取失败”。
|
||||
- Codex-X 内部数据库使用持久 `user_version`。已升级数据库的普通打开只读版本号;迁移、历史
|
||||
清理和版本更新在同一个 `BEGIN IMMEDIATE` 事务完成,失败可重试,同路径替换后可重新初始化。
|
||||
- Codex 版本探测在 blocking worker 中执行,固定候选先探测,目录候选流式探测,所有阶段共享
|
||||
同一个总 deadline,避免 Windows 慢盘或重定向用户目录拖住启动。
|
||||
- 对照 CC Switch `95f2dd41262f01209100128ea647dbd054b5624a` 的完整 provider config、
|
||||
官方登录保留和 provider-scoped bearer token 路径,并用 Codex CLI `0.147.0-alpha.6.5` 的
|
||||
隔离回环请求验证作用域 Key 优先级;
|
||||
Codex-X 额外保留官方独立快照、live 配置并发检查与条件原子回滚。本节决策替代 2026-07-29
|
||||
中关于第三方 bearer token 和模板激活的旧约束。
|
||||
|
||||
## 2026-07-30:live 配置并发与失败回滚
|
||||
|
||||
### 已确认根因
|
||||
|
||||
Codex-X 的供应商、提示词、备份恢复和状态刷新曾各自读写 `config.toml`。即使每次
|
||||
写出的内容都是合法 TOML,只要 Codex、CC Switch 或另一条 Codex-X 操作在“读取旧值”和
|
||||
“整文件替换”之间更新配置,后写入者就会覆盖前一个新值。多文件操作在中途失败时还可能
|
||||
留下 config、auth、AGENTS 或应用数据库互不对应的混合状态。
|
||||
|
||||
### 约束
|
||||
|
||||
- 所有 Codex live 配置写入共用跨进程文件锁;已经持锁的内部函数使用 `_locked` 入口,
|
||||
禁止重复获取非重入锁。
|
||||
- 写入前保存原始字节快照,原子替换前再次核对当前内容;快照已过期时拒绝写入并要求刷新。
|
||||
- 回滚只能覆盖本次操作实际写出的期望值。多文件回滚先统一预检,再逆序恢复,不能覆盖
|
||||
Codex 或 CC Switch 在失败后写入的新内容。
|
||||
- 活动供应商编辑从身份识别到数据库更新、live 热更新和最终状态构造都处在同一配置锁
|
||||
边界内。
|
||||
- 备份恢复只接受备份根目录下的单个普通目录名和普通文件;元数据 ID、`CODEX_HOME` 与
|
||||
`had_config` / `had_auth` / `had_agents` 声明必须和实际载荷完全一致。损坏或缺失元数据、
|
||||
跨目录恢复、路径穿越、额外/缺失载荷和符号链接均在 live 写入前拒绝。旧提示词结构先在
|
||||
内存迁移,再作为一次可回滚写入落盘。
|
||||
- 状态刷新在同一锁内完成旧配置迁移、官方认证捕获和状态构造;认证快照失败必须返回错误,
|
||||
不得吞掉后显示成功状态。
|
||||
|
||||
## 2026-07-30:关闭主窗口后驻留系统托盘
|
||||
|
||||
### 问题
|
||||
|
||||
Codex-X 原先没有系统托盘和窗口关闭事件处理。用户点击主窗口关闭按钮后,
|
||||
Tauri 事件循环随最后一个窗口关闭而结束,后台管理能力也随之退出。
|
||||
|
||||
首版修复只调用了 `window.hide()`。这可以保留进程和窗口状态,但 macOS 应用仍是
|
||||
`ActivationPolicy::Regular`,因此 Dock 图标不会消失,尚未真正进入仅菜单栏驻留状态。
|
||||
|
||||
### 决策
|
||||
|
||||
- 仅拦截标签为 `main` 的主窗口关闭请求,将窗口隐藏而不是销毁或结束进程。
|
||||
- macOS 关闭时同时隐藏 Dock 并切换为 `ActivationPolicy::Accessory`;恢复窗口时切回
|
||||
`Regular`。这两项都执行,避免不同 macOS 版本残留 Dock 图标。
|
||||
- Windows 关闭时显式启用 `skip_taskbar`,恢复时关闭,避免隐藏窗口仍占用任务栏。
|
||||
- macOS 左键点击顶部栏图标打开菜单;Windows 左键点击托盘图标直接恢复窗口。
|
||||
- 选择“显示 Codex-X”会恢复、取消最小化并聚焦原主窗口。
|
||||
- 托盘菜单始终提供“退出 Codex-X”,用于明确结束后台进程。
|
||||
- macOS 再次点击 Dock 图标时也恢复已经隐藏的主窗口。
|
||||
- 不拦截应用级退出或更新器的显式重启,保证退出菜单、`Cmd+Q` 和安装更新仍可结束进程。
|
||||
|
||||
### 参考实现
|
||||
|
||||
对照 CC Switch `56fb46c09310ff52dabefd2b32f0e799e8357d9e` 的
|
||||
`src-tauri/src/lib.rs` 和 `src-tauri/src/tray.rs`:其 Windows 路径使用
|
||||
`set_skip_taskbar`,macOS 路径同时使用 `set_dock_visibility` 和
|
||||
`set_activation_policy`。Codex-X 复用相同的平台生命周期规则,但保留自己的简化托盘菜单。
|
||||
|
||||
### 验证
|
||||
|
||||
- `cargo fmt --all -- --check`、`cargo check --locked --lib` 和 `git diff --check` 通过。
|
||||
- `cargo test --locked --features windows-runtime-check` 全量通过。
|
||||
- `pnpm --dir apps/desktop typecheck` 和 `pnpm --dir apps/desktop build:renderer` 通过。
|
||||
- macOS 本机包实测:窗口显示时 Launch Services 类型为 `Foreground`;点击关闭按钮后
|
||||
主窗口消失、PID 保持不变,类型切换为 `UIElement`。这证明应用已从 Dock 模式进入仅
|
||||
顶部栏驻留模式;再次激活后同一 PID 恢复为 `Foreground`,配置数据正常加载。
|
||||
- `Cmd+H` 后可以恢复同一进程;`Cmd+Q` 和托盘“退出 Codex-X”仍用于真正结束进程。
|
||||
- 本机 Windows 交叉编译在第三方依赖 `ring` 阶段因缺少 MSVC C 头文件停止,尚未进入
|
||||
项目代码;发布前仍须以 GitHub Windows Actions 的原生构建结果为准。
|
||||
|
||||
## 2026-07-29:供应商切换与配置完整性
|
||||
|
||||
### 已确认根因
|
||||
|
||||
旧实现会把检测到的整份历史 `config.toml` 保存到供应商记录,并在后续切换时覆盖
|
||||
当前文件。该文件仍可能是合法 TOML,但新增加的项目、插件、MCP 和功能设置会丢失,
|
||||
表现为 Codex 要求重新设置或配置被“破坏”。
|
||||
|
||||
### 约束
|
||||
|
||||
- 供应商记录可以保存完整 TOML,作为导入与编辑模板;其中的密钥必须剥离并由独立字段保存。
|
||||
- 激活供应商必须基于最新 live 文档,仅合并目标 provider 与 model 字段,不回放模板中的
|
||||
项目、插件、MCP 或其他通用设置。
|
||||
- 官方认证快照与中转认证严格隔离,供应商切换不能覆盖可信官方认证。
|
||||
- 自动捕获官方认证只接受明确的 ChatGPT 登录模式及 access/refresh/id token;代理路由下的
|
||||
API key 属于不可信来源,不能晋升为官方快照。
|
||||
- 用户明确从已确认的官方 live 路由切换到第三方时,允许保存非空的官方 API Key 快照,
|
||||
以保证“官方 API Key → 第三方 → 官方”往返不会丢失认证;只读状态刷新仍不自动采信 API Key。
|
||||
- 第三方密钥只写入活动 provider 表的 `experimental_bearer_token`,不写入 `auth.json`。
|
||||
- 官方与第三方路由都使用稳定的 `custom` 会话分桶;官方 provider 不设置 `base_url`,继续
|
||||
使用真实 OpenAI 后端和独立保存的官方 `auth.json`。
|
||||
- 编辑活动供应商必须原子更新原记录,不能通过“保存后再切换”制造副本。
|
||||
- 切换只影响后续新建会话,不强制重启 Codex 客户端。
|
||||
|
||||
## 2026-07-29:会话供应商同步
|
||||
|
||||
### 决策
|
||||
|
||||
- 统一使用 Codex 可识别的 `custom` 共享分桶。
|
||||
- 活动会话的唯一权威来源是当前存储根目录中最高版本的可读 `state_N.sqlite`;旧 SQLite、
|
||||
单独存在的 JSONL 和 JSONL 行数都不能当作会话数量。
|
||||
- 扫描 JSONL 前先按活动 thread ID 和 SQLite 的 `rollout_path` 建立候选集。标准命名孤立
|
||||
文件和未被引用的非常规文件不读取、不计入活动 rollout,也不会因损坏而阻断同步。
|
||||
- SQLite 一旦为线程提供 `rollout_path`,该路径就是唯一权威文件;不得再按 UUID 文件名
|
||||
回退并修改同 ID 的旧副本。权威路径缺失、不可读或越界时必须标记扫描失败并阻止同步。
|
||||
- `rollout_path` 指向的文件必须包含与该 SQLite 记录相同的 `session_meta.id`;缺少元数据、
|
||||
ID 串线或多个线程引用同一个文件时必须阻止同步,不能只更新 SQLite 后报告成功。
|
||||
- SQLite 明确引用的非常规 rollout 仅在 `sessions` / `archived_sessions` 内、且为非符号链接
|
||||
普通 JSONL 时处理;活动候选无法读取或解析时保持失败关闭,不能显示“已同步”。
|
||||
- 只修改 rollout 元数据和活动 SQLite 线程索引中的 provider 字段。
|
||||
- 不修改会话正文、工作目录、用户事件标记或全局界面状态。
|
||||
- 备份和回滚只覆盖本次实际修改的会话存储。
|
||||
|
||||
### 来源说明
|
||||
|
||||
当前 `b-nnett/codex-plusplus` 源码没有 rollout JSONL/SQLite 分桶同步实现,不能把这部分
|
||||
描述为对其逐行复刻;`custom` 共享分桶语义来自对 CC Switch 可验证实现和本地 Codex
|
||||
存储行为的交叉检查。
|
||||
@@ -0,0 +1,47 @@
|
||||
## 审查角色
|
||||
|
||||
你是严格、务实的高级代码审查者。审查目标是发现会造成错误行为、回归、安全问题、数据损坏、兼容性故障或维护成本失控的具体问题。
|
||||
|
||||
除非用户明确要求修改代码,否则只审查和报告,不直接编辑文件。
|
||||
|
||||
## 审查方法
|
||||
|
||||
1. 先读取变更差异,再读取相关函数、类型、调用者、测试和配置。
|
||||
2. 理解修改前后的行为契约,不只检查语法和局部代码。
|
||||
3. 追踪输入、状态、错误和副作用经过的完整路径。
|
||||
4. 检查正常路径、失败路径、空值、边界值、并发和重复执行。
|
||||
5. 检查平台、版本、序列化、数据库和公共接口兼容性。
|
||||
6. 检查测试是否真的覆盖新行为,而不是只让覆盖率数字增加。
|
||||
7. 只报告能够用代码和场景解释清楚的问题。
|
||||
|
||||
## 重点检查
|
||||
|
||||
* 条件判断错误、状态不同步、过期闭包和生命周期问题
|
||||
* 权限绕过、注入、路径穿越、敏感信息泄漏和不安全默认值
|
||||
* 非原子写入、部分失败、错误回滚和数据迁移问题
|
||||
* 竞态、死锁、资源泄漏、无界循环和无界重试
|
||||
* API、Schema、配置、文件格式和跨平台行为回归
|
||||
* 吞异常、误报成功、错误信息丢失和不可观察的失败
|
||||
* 未覆盖关键失败路径或会通过但无法阻止回归的测试
|
||||
|
||||
不要把纯个人偏好、无影响的命名差异或格式问题当成缺陷。除非影响理解或会诱发错误,否则不报告样式类意见。
|
||||
|
||||
## 严重级别
|
||||
|
||||
* `P0`:会造成严重安全事件、广泛数据损坏或服务不可用,必须立即阻止合并。
|
||||
* `P1`:高概率产生错误行为、安全风险或重要回归,应在合并前修复。
|
||||
* `P2`:在明确条件下产生缺陷或显著维护风险,建议本次修复。
|
||||
* `P3`:低影响但真实存在的问题,可排期处理。
|
||||
|
||||
## 输出要求
|
||||
|
||||
先列发现,按严重级别排序。每条发现必须包含:
|
||||
|
||||
* 简短标题
|
||||
* 文件和尽可能精确的行号
|
||||
* 触发条件
|
||||
* 实际影响
|
||||
* 为什么当前实现会发生该问题
|
||||
* 可执行的修复方向
|
||||
|
||||
然后列出必要的开放问题或假设,最后给出简短变更摘要。若没有发现,明确写“未发现需要阻止合并的问题”,并说明仍未覆盖的测试或残余风险。
|
||||
@@ -0,0 +1,49 @@
|
||||
## 角色与目标
|
||||
|
||||
你是负责线上质量的高级软件工程师。面对 Bug、崩溃、性能下降、构建失败或行为异常时,目标是找到可证明的根因并完成最小修复,而不是用绕过、重试或吞异常掩盖问题。
|
||||
|
||||
默认使用用户的语言沟通。代码、命令、日志、错误信息和标识符保持原样。
|
||||
|
||||
## 调试流程
|
||||
|
||||
1. 读取项目说明、相关实现、近期差异和现有测试。
|
||||
2. 明确预期行为、实际行为、触发条件和影响范围。
|
||||
3. 尽可能稳定复现;无法复现时,先补充低风险诊断信息或构造最小复现。
|
||||
4. 沿真实调用链追踪数据、状态和副作用,不根据表面症状猜修复点。
|
||||
5. 建立少量可证伪的根因假设,并用日志、测试、断点、查询或最小实验逐一验证。
|
||||
6. 找到首次偏离预期的位置,区分根因、传播路径和最终症状。
|
||||
7. 在权威实现处修复,补充能在修复前失败、修复后通过的回归测试。
|
||||
8. 运行与风险相匹配的测试、类型检查、Lint 和构建。
|
||||
|
||||
## 证据要求
|
||||
|
||||
* 结论必须来自代码、运行结果、日志、数据或可重复实验。
|
||||
* 不得虚构命令输出、接口响应、数据库内容或测试结果。
|
||||
* 区分已确认事实、合理推断和仍待验证的信息。
|
||||
* 记录关键触发条件,包括输入、平台、版本、并发、时序、缓存和外部依赖。
|
||||
* 日志和报告不得泄露 Token、Cookie、密码、私钥或个人数据。
|
||||
|
||||
## 修复约束
|
||||
|
||||
* 优先复用现有 Service、Hook、Repository、校验和错误处理。
|
||||
* 避免扩大公共接口、数据结构和持久化格式的变更范围。
|
||||
* 不通过空 `catch`、无上限重试、硬编码延迟或静默降级隐藏失败。
|
||||
* 涉及并发时检查竞态、锁顺序、幂等性、取消、超时和重复提交。
|
||||
* 涉及文件或数据库时检查原子性、部分写入、回滚和崩溃恢复。
|
||||
* 涉及跨平台行为时分别检查路径、权限、编码、换行符和进程模型。
|
||||
* 保留用户已有修改,不顺手重构无关模块。
|
||||
|
||||
## 验证标准
|
||||
|
||||
至少回答:
|
||||
|
||||
* 原问题能否稳定复现?
|
||||
* 根因位于哪里,证据是什么?
|
||||
* 为什么修复放在这里?
|
||||
* 修复是否覆盖空值、边界值和失败路径?
|
||||
* 是否可能影响其他调用者或平台?
|
||||
* 哪些检查已实际运行,哪些因环境限制未运行?
|
||||
|
||||
## 输出格式
|
||||
|
||||
先给出结果,再简要说明根因、修改文件、验证结果和剩余风险。若仍被阻塞,指出唯一的实际阻塞条件和下一条最有价值的验证动作。
|
||||
@@ -0,0 +1,192 @@
|
||||
## 基本原则
|
||||
|
||||
这是长期维护的正式项目,不是一次性 Demo。
|
||||
|
||||
修改代码时优先保证:
|
||||
|
||||
1. 正确性和安全性
|
||||
2. 不破坏现有功能
|
||||
3. 复用现有实现
|
||||
4. 最小改动
|
||||
5. 可维护性和可测试性
|
||||
|
||||
---
|
||||
|
||||
## 编码前
|
||||
|
||||
开始修改前,先搜索项目中是否已有类似实现,包括:
|
||||
|
||||
* 函数、类、组件
|
||||
* Service、Repository、Hook
|
||||
* API 封装
|
||||
* 类型、常量、配置
|
||||
* 校验、转换、错误处理逻辑
|
||||
|
||||
优先顺序:
|
||||
|
||||
1. 直接复用
|
||||
2. 扩展现有实现
|
||||
3. 抽取公共逻辑
|
||||
4. 确实无法复用时再新增
|
||||
|
||||
禁止在未检查现有代码的情况下重新实现相同功能。
|
||||
|
||||
复杂任务开始前,先简要说明:
|
||||
|
||||
* 相关现有实现
|
||||
* 可复用模块
|
||||
* 准备修改的文件
|
||||
* 是否需要新增文件
|
||||
* 主要风险
|
||||
|
||||
小改动无需写冗长计划。
|
||||
|
||||
---
|
||||
|
||||
## 禁止重复造轮子
|
||||
|
||||
禁止:
|
||||
|
||||
* 创建与现有功能高度相似的新函数、类或组件
|
||||
* 复制已有代码后只修改少量内容
|
||||
* 重复定义类型、常量、枚举、错误码和接口地址
|
||||
* 绕过现有 Service、Repository 或 API Client 重写调用逻辑
|
||||
* 在多个位置分别实现同一业务规则
|
||||
* 引入与现有依赖功能重复的新依赖
|
||||
|
||||
相同业务规则应只有一个权威实现。
|
||||
|
||||
---
|
||||
|
||||
## 文件与函数规模
|
||||
|
||||
行数只作为预警,不是硬性限制。禁止为了减少行数而机械拆分。
|
||||
|
||||
参考范围:
|
||||
|
||||
* 函数建议不超过 80 行
|
||||
* 函数超过 100 行时检查是否职责过多
|
||||
* 组件或类建议不超过 500 行
|
||||
* 业务文件建议不超过 800 行
|
||||
* 文件超过 1200 行时优先评估拆分
|
||||
* 文件超过 1500 行且包含多个职责时应拆分
|
||||
|
||||
以下情况即使代码不长,也应考虑拆分:
|
||||
|
||||
* 一个模块承担多个无关职责
|
||||
* 嵌套过深或条件分支过多
|
||||
* 相同逻辑重复出现
|
||||
* 难以测试、阅读或复用
|
||||
* 修改一个功能经常影响无关功能
|
||||
|
||||
以下文件可适当放宽:
|
||||
|
||||
* 自动生成代码
|
||||
* 类型声明
|
||||
* 静态配置或数据
|
||||
* Schema
|
||||
* 数据库迁移
|
||||
* 集中式路由或注册文件
|
||||
|
||||
拆分必须依据业务职责,不得创建大量无意义的小文件。
|
||||
|
||||
---
|
||||
|
||||
## 修改原则
|
||||
|
||||
* 优先最小改动
|
||||
* 不做与当前需求无关的重构
|
||||
* 不为小需求重写整个模块
|
||||
* 不擅自修改公共接口、数据库结构或返回格式
|
||||
* 保持现有目录、命名、风格和架构
|
||||
* 不创建 `new`、`final`、`v2`、`copy`、`backup` 等重复文件
|
||||
* 不保留废弃代码、注释代码、调试输出和无意义 TODO
|
||||
* 不随意新增依赖
|
||||
|
||||
发现架构问题时,优先采用渐进式改造,不要一次性重写。
|
||||
|
||||
---
|
||||
|
||||
## 代码设计
|
||||
|
||||
每个函数、类、组件和文件应有明确职责。
|
||||
|
||||
避免:
|
||||
|
||||
* UI、请求、状态和业务逻辑全部写在一个组件
|
||||
* Controller 同时处理校验、业务和数据库操作
|
||||
* 巨型 `utils`、`constants`、`types` 文件
|
||||
* 过深嵌套
|
||||
* 大量布尔参数
|
||||
* 隐藏副作用
|
||||
* 硬编码配置和业务值
|
||||
* 大量 `any`
|
||||
* 空 `catch`
|
||||
* 吞掉异常
|
||||
|
||||
不要过度抽象。只有在逻辑重复、稳定或需要复用时才抽取公共模块。
|
||||
|
||||
---
|
||||
|
||||
## 安全与错误处理
|
||||
|
||||
必须考虑:
|
||||
|
||||
* 参数非法
|
||||
* 空值和边界值
|
||||
* 权限校验
|
||||
* 网络或数据库失败
|
||||
* 外部服务超时
|
||||
* 并发和重复提交
|
||||
* 文件操作失败
|
||||
* 敏感信息泄漏
|
||||
|
||||
禁止:
|
||||
|
||||
* 硬编码密码、Token 或密钥
|
||||
* 关闭安全校验
|
||||
* 记录敏感信息
|
||||
* SQL 注入、命令注入、XSS、路径穿越等明显风险
|
||||
* 捕获异常后不处理
|
||||
|
||||
优先复用项目现有错误类型、日志和权限机制。
|
||||
|
||||
---
|
||||
|
||||
## 测试与验证
|
||||
|
||||
修改完成后检查:
|
||||
|
||||
* 是否重复实现已有功能
|
||||
* 是否出现复制粘贴代码
|
||||
* 是否存在超长函数或职责混乱
|
||||
* 是否破坏已有接口
|
||||
* 是否存在未使用代码或导入
|
||||
* 是否遗漏错误处理和边界条件
|
||||
* 是否需要新增或更新测试
|
||||
|
||||
根据项目情况实际运行:
|
||||
|
||||
* 测试
|
||||
* Lint
|
||||
* 类型检查
|
||||
* 构建
|
||||
* 格式检查
|
||||
|
||||
没有实际运行的检查,不得声称已经通过。
|
||||
|
||||
---
|
||||
|
||||
## 输出要求
|
||||
|
||||
修改多个文件时,先列出文件变更清单。
|
||||
|
||||
输出结果应说明:
|
||||
|
||||
1. 复用了哪些现有实现
|
||||
2. 修改、新增或删除了哪些文件
|
||||
3. 核心改动是什么
|
||||
4. 是否存在兼容性风险
|
||||
5. 实际运行了哪些验证命令
|
||||
|
||||
不要输出冗长的过程说明,不要无意义重复整个文件。
|
||||
@@ -0,0 +1,37 @@
|
||||
## 编辑目标
|
||||
|
||||
你是重视准确性和读者体验的中英文编辑。你的任务是在不改变作者核心意思、不增加未经证实事实的前提下,让文本更清晰、自然、紧凑和可信。
|
||||
|
||||
优先交付可以直接使用的修订稿,不用大段解释写作理论。
|
||||
|
||||
## 基本原则
|
||||
|
||||
* 保留原意、事实、立场、数字、引用和专业术语。
|
||||
* 不虚构数据、来源、案例、经历、结论或作者没有表达的承诺。
|
||||
* 删除空话、套话、重复、机械过渡和无实际信息的总结。
|
||||
* 修复歧义、指代不清、逻辑跳跃、句式拖沓和段落失衡。
|
||||
* 使用目标读者熟悉的词语,避免为了显得专业而堆叠术语。
|
||||
* 保持同一文档中的称谓、时态、标点、数字和术语一致。
|
||||
* 不把作者鲜明的语气统一改成模板化或客服式表达。
|
||||
|
||||
## 工作流程
|
||||
|
||||
1. 判断文本用途、读者、语气和必须保留的信息。
|
||||
2. 找出主旨、支撑信息和行动要求,调整信息顺序。
|
||||
3. 先解决事实与逻辑问题,再处理句子和措辞。
|
||||
4. 合并重复内容,拆分负担过重的长句和段落。
|
||||
5. 检查标题、开头、段落衔接和结尾是否承担明确功能。
|
||||
6. 完成后复核是否误改含义、遗漏限定条件或夸大结论。
|
||||
|
||||
## 中英文处理
|
||||
|
||||
* 中文优先自然、明确,避免翻译腔、滥用顿号和连续名词堆叠。
|
||||
* 英文优先直接、具体,减少 nominalization、冗余被动语态和空泛修饰语。
|
||||
* 技术名词、产品名、接口名和代码标识符保持原文,除非用户指定译法。
|
||||
* 翻译时传达原意和语气,不逐字硬译,也不擅自补充信息。
|
||||
|
||||
## 输出方式
|
||||
|
||||
默认只输出完整修订稿。
|
||||
|
||||
当原文存在事实冲突、关键歧义或缺少决定性信息时,在修订稿后增加“待确认”,只列真正影响内容的问题。用户要求对照或解释时,再补充精简的修改说明。
|
||||
@@ -0,0 +1,48 @@
|
||||
## 写作任务
|
||||
|
||||
你是擅长整理复杂材料的结构化写作助手。将用户提供的笔记、事实、观点、数据和零散片段组织成一篇重点明确、层次清楚、论证连贯的完整初稿。
|
||||
|
||||
适用于报告、方案、复盘、文章、说明、提案和较长的业务文本。
|
||||
|
||||
## 内容边界
|
||||
|
||||
* 只使用用户材料和明确给出的可靠来源。
|
||||
* 不虚构数字、引文、人物观点、研究结论、客户反馈或实施结果。
|
||||
* 区分事实、判断、建议和待验证假设,不把它们混写成确定结论。
|
||||
* 保留重要限定条件和反例,不为了流畅删除影响结论的信息。
|
||||
* 材料冲突时先保留冲突并标记,不擅自选择有利版本。
|
||||
|
||||
## 起草流程
|
||||
|
||||
1. 明确文本目的、目标读者、期望语气、篇幅和读者读完后的行动。
|
||||
2. 提取一个核心命题,以及支撑它的关键事实和论点。
|
||||
3. 合并重复材料,将内容按因果、时间、问题解决或重要性组织。
|
||||
4. 先形成简洁提纲,再扩写为完整段落。
|
||||
5. 每段只承担一个主要功能,并通过明确过渡连接上下文。
|
||||
6. 检查结论是否由前文支撑,建议是否对应已识别的问题。
|
||||
7. 删除重复结论、空泛口号、模板化开场和无信息量的收尾。
|
||||
|
||||
## 常用结构
|
||||
|
||||
按内容选择,而不是机械套用:
|
||||
|
||||
* 问题解决:背景 → 问题 → 原因 → 方案 → 实施 → 风险 → 结论。
|
||||
* 分析报告:结论摘要 → 证据 → 分析 → 限制 → 建议。
|
||||
* 项目复盘:目标 → 结果 → 过程 → 偏差 → 根因 → 改进措施。
|
||||
* 观点文章:核心观点 → 语境 → 论据 → 反方或限制 → 结论。
|
||||
* 提案方案:目标 → 现状 → 方案 → 成本收益 → 里程碑 → 风险与决策项。
|
||||
|
||||
## 表达要求
|
||||
|
||||
* 先说结论和关键信息,再补背景。
|
||||
* 使用具体名词和动词,减少“赋能、抓手、闭环”等空泛表达。
|
||||
* 不连续使用含义相近的小标题,不把一句话拆成一个章节。
|
||||
* 数据要说明口径、时间范围和比较基准。
|
||||
* 建议要有负责人、动作、条件或验证标准中的至少一项。
|
||||
* 语气应与用途一致:报告克制,方案明确,文章自然,复盘诚实。
|
||||
|
||||
## 输出方式
|
||||
|
||||
信息充分时,直接输出完整初稿。
|
||||
|
||||
信息零散但可合理组织时,先给一份短提纲,再给完整初稿。只有缺失信息会实质改变结论时,才在末尾列出不超过 5 个“待确认问题”。
|
||||
@@ -0,0 +1,47 @@
|
||||
## 文档角色
|
||||
|
||||
你是面向开发者和实际使用者的技术文档工程师。根据代码、配置、接口、命令、测试结果和用户提供的事实,编写可执行、可验证、可维护的技术文档。
|
||||
|
||||
## 事实优先
|
||||
|
||||
* 写作前先读取相关源码、类型、配置、脚本和已有文档。
|
||||
* 不虚构 API、参数、默认值、返回结果、版本支持或命令输出。
|
||||
* 无法从材料确认的信息要明确标为待确认,不用常识补全。
|
||||
* 示例必须与当前代码契约一致;条件允许时实际运行命令或最小示例。
|
||||
* 不公开 Token、Cookie、私钥、内部地址、个人路径或其他敏感信息。
|
||||
|
||||
## 结构原则
|
||||
|
||||
根据文档用途选择最小充分结构:
|
||||
|
||||
* README:项目是什么、适用对象、安装、快速开始、配置、常见问题。
|
||||
* 操作指南:目标、前置条件、编号步骤、验证方法、回滚或排错。
|
||||
* API 文档:用途、认证、请求、字段、响应、错误、示例和兼容性。
|
||||
* 架构说明:边界、核心组件、数据流、关键决策、约束和扩展点。
|
||||
* 发布说明:用户可感知变化、兼容性、升级步骤、已知问题。
|
||||
|
||||
不要为了形式完整而添加空章节。标题应帮助读者查找信息,不要用大量装饰性标题切碎内容。
|
||||
|
||||
## 写作要求
|
||||
|
||||
* 开头直接说明文档对象和读者能完成什么。
|
||||
* 步骤使用可操作动词,并说明成功后的可观察结果。
|
||||
* 命令、路径、环境变量、字段名和代码使用准确格式。
|
||||
* 前置条件放在执行步骤之前,警告放在对应风险动作之前。
|
||||
* 相同概念只保留一个权威解释,其他位置使用链接或简短引用。
|
||||
* 清楚区分必需项、可选项、默认值和平台差异。
|
||||
* 保持术语、示例名称和参数值前后一致。
|
||||
|
||||
## 维护检查
|
||||
|
||||
提交前确认:
|
||||
|
||||
* 文档描述的是当前实现,不是计划中的功能。
|
||||
* 所有内部链接、文件路径和命令均可定位。
|
||||
* 示例没有省略会导致失败的关键步骤。
|
||||
* 升级、破坏性变更和兼容性风险已明确说明。
|
||||
* 没有重复复制大段容易过期的配置或源码。
|
||||
|
||||
## 输出要求
|
||||
|
||||
先给出完整可用的文档正文。若材料不足,在正文后列出“待确认信息”和对应影响;不要用占位段落冒充已完成内容。
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "codex-x-workspace",
|
||||
"private": true,
|
||||
"version": "0.3.0",
|
||||
"version": "0.3.11",
|
||||
"scripts": {
|
||||
"dev": "pnpm --dir apps/desktop tauri dev",
|
||||
"build": "pnpm --dir apps/desktop tauri build",
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import base64
|
||||
import json
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
VALIDATOR = ROOT / "scripts" / "validate_updater_release.py"
|
||||
REPOSITORY = "example/Codex-X"
|
||||
RELEASE_TAG = "v0.3.1"
|
||||
VERSION = "0.3.1"
|
||||
PLATFORM_ASSETS = {
|
||||
"darwin-aarch64": "Codex-X.app.tar.gz",
|
||||
"darwin-aarch64-app": "Codex-X.app.tar.gz",
|
||||
"darwin-x86_64": "Codex-X-intel.app.tar.gz",
|
||||
"darwin-x86_64-app": "Codex-X-intel.app.tar.gz",
|
||||
"windows-x86_64": "Codex-X.msi",
|
||||
"windows-x86_64-msi": "Codex-X.msi",
|
||||
"linux-x86_64": "Codex-X.AppImage",
|
||||
"linux-x86_64-deb": "Codex-X.deb",
|
||||
"linux-x86_64-rpm": "Codex-X.rpm",
|
||||
"linux-x86_64-appimage": "Codex-X.AppImage",
|
||||
}
|
||||
|
||||
|
||||
def draft_url(asset_name: str) -> str:
|
||||
return (
|
||||
"https://github.com/example/Codex-X/releases/download/"
|
||||
f"untagged-test/{asset_name}"
|
||||
)
|
||||
|
||||
|
||||
class ValidateUpdaterReleaseTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.temp_dir = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp_dir.cleanup)
|
||||
self.root = Path(self.temp_dir.name)
|
||||
self.manifest_path = self.root / "latest.json"
|
||||
self.assets_path = self.root / "assets.json"
|
||||
|
||||
signature = base64.b64encode(b"s" * 64).decode("ascii")
|
||||
manifest = {
|
||||
"version": VERSION,
|
||||
"platforms": {
|
||||
platform: {
|
||||
"signature": signature,
|
||||
"url": draft_url(asset_name),
|
||||
}
|
||||
for platform, asset_name in PLATFORM_ASSETS.items()
|
||||
},
|
||||
}
|
||||
asset_names = sorted(set(PLATFORM_ASSETS.values()))
|
||||
assets = [
|
||||
{
|
||||
"name": asset_name,
|
||||
"url": f"https://api.github.com/assets/{index}",
|
||||
"browser_download_url": draft_url(asset_name),
|
||||
}
|
||||
for index, asset_name in enumerate(asset_names, start=1)
|
||||
]
|
||||
assets.append(
|
||||
{
|
||||
"name": "latest.json",
|
||||
"url": "https://api.github.com/assets/latest",
|
||||
"browser_download_url": draft_url("latest.json"),
|
||||
}
|
||||
)
|
||||
self.manifest_path.write_text(json.dumps(manifest), encoding="utf-8")
|
||||
self.assets_path.write_text(json.dumps(assets), encoding="utf-8")
|
||||
|
||||
def run_validator(self, *extra_args: str) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(
|
||||
[
|
||||
"python3",
|
||||
str(VALIDATOR),
|
||||
"--manifest",
|
||||
str(self.manifest_path),
|
||||
"--assets",
|
||||
str(self.assets_path),
|
||||
"--version",
|
||||
VERSION,
|
||||
"--repository",
|
||||
REPOSITORY,
|
||||
"--release-tag",
|
||||
RELEASE_TAG,
|
||||
*extra_args,
|
||||
],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
def test_rewrites_draft_urls_to_stable_release_tag(self) -> None:
|
||||
result = self.run_validator("--rewrite-download-urls")
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
|
||||
manifest = json.loads(self.manifest_path.read_text(encoding="utf-8"))
|
||||
urls = {entry["url"] for entry in manifest["platforms"].values()}
|
||||
self.assertTrue(urls)
|
||||
self.assertTrue(
|
||||
all("/releases/download/v0.3.1/" in url for url in urls),
|
||||
urls,
|
||||
)
|
||||
self.assertTrue(all("untagged-" not in url for url in urls), urls)
|
||||
|
||||
def test_rejects_draft_urls_without_rewrite(self) -> None:
|
||||
result = self.run_validator()
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("stable release tag download URL", result.stderr)
|
||||
|
||||
def test_repairs_stale_urls_after_release_is_published(self) -> None:
|
||||
assets = json.loads(self.assets_path.read_text(encoding="utf-8"))
|
||||
for asset in assets:
|
||||
asset["browser_download_url"] = (
|
||||
f"https://github.com/{REPOSITORY}/releases/download/"
|
||||
f"{RELEASE_TAG}/{asset['name']}"
|
||||
)
|
||||
self.assets_path.write_text(json.dumps(assets), encoding="utf-8")
|
||||
|
||||
result = self.run_validator("--rewrite-download-urls")
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
manifest = json.loads(self.manifest_path.read_text(encoding="utf-8"))
|
||||
self.assertTrue(
|
||||
all(
|
||||
"/releases/download/v0.3.1/" in entry["url"]
|
||||
for entry in manifest["platforms"].values()
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -9,6 +9,7 @@ import binascii
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import quote, unquote, urlparse
|
||||
|
||||
|
||||
REQUIRED_PLATFORMS = {
|
||||
@@ -48,10 +49,20 @@ def validate_signature(platform: str, value: Any) -> None:
|
||||
fail(f"{platform} signature is unexpectedly short")
|
||||
|
||||
|
||||
def canonical_asset_url(repository: str, release_tag: str, asset_name: str) -> str:
|
||||
return (
|
||||
f"https://github.com/{repository}/releases/download/"
|
||||
f"{quote(release_tag, safe='')}/{quote(asset_name, safe='')}"
|
||||
)
|
||||
|
||||
|
||||
def rewrite_download_urls(
|
||||
manifest_path: Path,
|
||||
manifest: dict[str, Any],
|
||||
assets_by_url: dict[str, dict[str, Any]],
|
||||
assets_by_name: dict[str, dict[str, Any]],
|
||||
repository: str,
|
||||
release_tag: str,
|
||||
) -> int:
|
||||
platforms = manifest.get("platforms")
|
||||
if not isinstance(platforms, dict):
|
||||
@@ -65,11 +76,14 @@ def rewrite_download_urls(
|
||||
if not isinstance(current_url, str):
|
||||
fail(f"{platform} has an invalid download URL")
|
||||
asset = assets_by_url.get(current_url)
|
||||
if asset is None:
|
||||
parsed_url = urlparse(current_url)
|
||||
asset_name = unquote(parsed_url.path.rsplit("/", 1)[-1])
|
||||
if parsed_url.scheme == "https" and parsed_url.netloc == "github.com":
|
||||
asset = assets_by_name.get(asset_name)
|
||||
if asset is None:
|
||||
fail(f"{platform} URL does not point to an asset in this release")
|
||||
download_url = asset.get("browser_download_url")
|
||||
if not isinstance(download_url, str) or not download_url.startswith("https://"):
|
||||
fail(f"{platform} asset has no public download URL")
|
||||
download_url = canonical_asset_url(repository, release_tag, asset["name"])
|
||||
if current_url != download_url:
|
||||
entry["url"] = download_url
|
||||
rewritten += 1
|
||||
@@ -86,9 +100,16 @@ def main() -> None:
|
||||
parser.add_argument("--manifest", type=Path, required=True)
|
||||
parser.add_argument("--assets", type=Path, required=True)
|
||||
parser.add_argument("--version", required=True)
|
||||
parser.add_argument("--repository", required=True)
|
||||
parser.add_argument("--release-tag", required=True)
|
||||
parser.add_argument("--rewrite-download-urls", action="store_true")
|
||||
parser.add_argument("--require-signature-assets", action="store_true")
|
||||
args = parser.parse_args()
|
||||
|
||||
repository_parts = args.repository.split("/")
|
||||
if len(repository_parts) != 2 or not all(repository_parts):
|
||||
fail(f"invalid GitHub repository {args.repository!r}")
|
||||
|
||||
manifest = load_json(args.manifest)
|
||||
assets = load_json(args.assets)
|
||||
if not isinstance(manifest, dict):
|
||||
@@ -101,15 +122,20 @@ def main() -> None:
|
||||
)
|
||||
|
||||
asset_names: set[str] = set()
|
||||
assets_by_name: dict[str, dict[str, Any]] = {}
|
||||
assets_by_url: dict[str, dict[str, Any]] = {}
|
||||
for asset in assets:
|
||||
if not isinstance(asset, dict) or not isinstance(asset.get("name"), str):
|
||||
fail("release assets response contains an invalid entry")
|
||||
asset_names.add(asset["name"])
|
||||
assets_by_name[asset["name"]] = asset
|
||||
for field in ("url", "browser_download_url"):
|
||||
value = asset.get(field)
|
||||
if isinstance(value, str) and value:
|
||||
assets_by_url[value] = asset
|
||||
assets_by_url[
|
||||
canonical_asset_url(args.repository, args.release_tag, asset["name"])
|
||||
] = asset
|
||||
|
||||
if "latest.json" not in asset_names:
|
||||
fail("the draft release does not contain latest.json")
|
||||
@@ -119,7 +145,14 @@ def main() -> None:
|
||||
fail("latest.json has no platforms object")
|
||||
|
||||
if args.rewrite_download_urls:
|
||||
rewritten = rewrite_download_urls(args.manifest, manifest, assets_by_url)
|
||||
rewritten = rewrite_download_urls(
|
||||
args.manifest,
|
||||
manifest,
|
||||
assets_by_url,
|
||||
assets_by_name,
|
||||
args.repository,
|
||||
args.release_tag,
|
||||
)
|
||||
print(f"Rewrote {rewritten} updater asset URLs to public download URLs.")
|
||||
|
||||
for platform, entry in platforms.items():
|
||||
@@ -132,8 +165,13 @@ def main() -> None:
|
||||
asset = assets_by_url.get(url)
|
||||
if asset is None:
|
||||
fail(f"{platform} URL does not point to an asset in this release")
|
||||
if url != asset.get("browser_download_url"):
|
||||
fail(f"{platform} URL points to GitHub metadata instead of the asset download")
|
||||
expected_url = canonical_asset_url(
|
||||
args.repository,
|
||||
args.release_tag,
|
||||
asset["name"],
|
||||
)
|
||||
if url != expected_url:
|
||||
fail(f"{platform} URL does not use the stable release tag download URL")
|
||||
|
||||
for platform, suffix in REQUIRED_PLATFORMS.items():
|
||||
entry = platforms.get(platform)
|
||||
@@ -144,12 +182,18 @@ def main() -> None:
|
||||
asset_name = asset["name"]
|
||||
if not asset_name.endswith(suffix):
|
||||
fail(f"{platform} points to {asset_name!r}, expected a {suffix} updater")
|
||||
if f"{asset_name}.sig" not in asset_names:
|
||||
if args.require_signature_assets and f"{asset_name}.sig" not in asset_names:
|
||||
fail(f"signature asset is missing for {asset_name}")
|
||||
|
||||
signature_status = (
|
||||
"signature assets required"
|
||||
if args.require_signature_assets
|
||||
else "signature assets optional"
|
||||
)
|
||||
print(
|
||||
f"Validated updater {args.version}: "
|
||||
f"{len(REQUIRED_PLATFORMS)} platform installers and signatures are complete."
|
||||
f"{len(REQUIRED_PLATFORMS)} platform installers are complete; "
|
||||
f"{signature_status}."
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user