diff --git a/.github/workflows/pr-build.yml b/.github/workflows/pr-build.yml index d3819453ef..01b1c1adda 100644 --- a/.github/workflows/pr-build.yml +++ b/.github/workflows/pr-build.yml @@ -198,3 +198,82 @@ jobs: name: codex-plus-plus-macos-universal-dmg path: dist/macos/*.dmg if-no-files-found: error + + linux-deb: + name: Linux DEB (Ubuntu 24.04, amd64) + runs-on: ubuntu-24.04 + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: "22" + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + - name: Install Linux build dependencies + run: | + sudo apt-get update -qq + sudo apt-get install -y --no-install-recommends \ + build-essential curl git pkg-config libssl-dev libsqlite3-dev \ + libdbus-1-dev libglib2.0-dev libgtk-3-dev \ + libayatana-appindicator3-dev libjavascriptcoregtk-4.1-dev \ + libwebkit2gtk-4.1-dev libsoup-3.0-dev libz3-dev + - name: Install frontend dependencies + working-directory: apps/codex-plus-manager + run: npm ci + + - name: Frontend tests + working-directory: apps/codex-plus-manager + run: npm test + + - name: Check native browser inspector bundle + run: node scripts/assemble-native-browser-inspector.mjs --check + + - name: TypeScript check + working-directory: apps/codex-plus-manager + run: npm run check + + - name: Build frontend + working-directory: apps/codex-plus-manager + run: npm run vite:build + + - name: Rust tests + run: cargo test --workspace + + - name: Build Linux application + run: cargo build --release -p codex-plus-manager + - name: Check default version outside repository + shell: bash + run: | + set -euo pipefail + cd /tmp + "$GITHUB_WORKSPACE/scripts/installer/linux/build-deb.sh" --help + test -f "$GITHUB_WORKSPACE/apps/codex-plus-manager/package.json" + - name: Build DEB from outside repository + shell: bash + run: | + set -euo pipefail + cd /tmp + bash "$GITHUB_WORKSPACE/scripts/installer/linux/build-deb.sh" --package-only + - name: Verify package and install + shell: bash + run: | + set -euo pipefail + deb_file="$(find dist/linux -maxdepth 1 -name "*.deb" -print -quit)" + test -n "$deb_file" + test "$(dpkg-deb -f "$deb_file" Package)" = "codex-plus-plus" + test "$(dpkg-deb -f "$deb_file" Version)" = "$(node -p 'require("./apps/codex-plus-manager/package.json").version')" + dpkg-deb -c "$deb_file" | grep -F "./usr/bin/codex-plus-plus" + if dpkg-deb -c "$deb_file" | grep -Fq "./usr/bin/codex-plus-plus-manager"; then exit 1; fi + sudo apt-get install -y "./$deb_file" + test -x /usr/bin/codex-plus-plus + dpkg -V codex-plus-plus + ldd /usr/bin/codex-plus-plus | tee /tmp/codex-plus-plus-ldd.log + ! grep -q "not found" /tmp/codex-plus-plus-ldd.log + - name: Upload DEB + uses: actions/upload-artifact@v4 + with: + name: codex-plus-plus-linux-amd64-deb + path: dist/linux/*.deb + if-no-files-found: error diff --git a/.gitignore b/.gitignore index cf04148658..565a73b1c8 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ venv/ *.egg-info/ build/ dist/ +.linux-build/ target/ node_modules/ apps/codex-plus-manager/src-tauri/gen/ diff --git a/CHANGELOG.md b/CHANGELOG.md index b96b20105a..9ccc8effd6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,72 @@ # 更新日志 +## 2.1.4 - 2026-10-30 + +本次为问题修复版:适配 Codex 26.1027,修复原生 Fast 按钮消失;修复网关在单供应商时因熔断 / 429 冷却比直连更容易失败;修复 Codex++ 以管理员身份运行时 Windows 商店版 Codex 启动后立即退出、主 Codex 用官方登录时 API 供应商多开仍走官方账号、插件市场搜不到插件等问题;供应商池可配置 new-api 访问令牌显示余额,DeepSeek 等厂商余额也显示在供应商卡片上。 + +### 新增 + +- **new-api 中转站显示余额**:识别为 new-api 的供应商,详情里多一行「访问令牌」,填上 new-api「个人设置 → 安全设置」里生成的访问令牌(新版分权限令牌需勾选「个人资料 · 查看」)后,卡片显示账户余额;不填时照旧(#2793)。 +- **DeepSeek 等厂商的余额显示在供应商池卡片上**,与「用量」页同一来源(#2793)。 +- **Agent 协作同一 Agent 可并发执行任务**:每个 Agent 可设「同时执行任务数」(1–4,默认 1,与之前一致);未达上限时新任务直接开始,达到上限再排队,多个会话共用同一上限。并发任务请使用不同的工作目录,同一目录时会提示(#2800)。 +- **多开页可直接编辑、删除供应商**:卡片悬停出现编辑和删除,删除前检查是否在用并二次确认;接入网关后「从第三方导入」(含 cc-switch)也不再被隐藏(#2801)。 + +### 修复 + +- Codex 26.1027 上原生 Fast 按钮消失:新版 Codex 改了入口模块的加载链路,Codex++ 找不到要改写的模块(#2788、#2792)。 +- Windows:Codex++ 以管理员身份运行时,商店版 Codex 启动后会降权重启、脱离商店包环境而立即退出;现在以管理员运行时会让 Codex 跳过降权。Windows 激活 Codex 的调用加了超时,不再一直卡在「启动 Codex」(#2789、#2795、#2764)。 +- 网关比直连更容易因限流失败:上游偶发几次 5xx 后,网关的熔断会把唯一的供应商整体挡 60 秒、全部本地回 429(`gateway_keys_cooling`),Codex 重试几次就报 exceeded retry limit。现在只有在有别家可换时才熔断;没给恢复时间的 429 首次只冷却 5 秒、并按上游给的恢复时间;Key 很快能恢复时网关稍等再发;「请求避震」不再对 429 反复重发同一个 Key(#2802、#2761)。 +- 主 Codex 用官方登录时,用 API 供应商启动的多开实例仍以官方账号登录、消耗官方额度(#2794)。 +- 经网关或本地代理转发到 Chat Completions / Anthropic 协议上游的流式请求,用量统计和请求日志里的 token 一直是 0(#2803)。 +- 插件市场开启后,在 Codex 插件页搜索搜不到插件市场里的插件(新版 Codex 的插件搜索改走服务端)(#2790)。 +- 侧栏折叠再展开时,已删除的会话会先闪出再消失(#2791)。 +- 会话页的项目路径在 Windows 上显示 `//?/` 前缀,同一目录被分成两组(#2791)。 +- 「修复与维护 → 同步目标」里出现点不了的内部别名 `codex_plus_managed`(#2791)。 +- 旧版 Codex(例如 Intel Mac 上的 Codex)上用量状态栏不显示(#2753)。 +- 从旧版本自动更新到新版本后,macOS「应用程序」文件夹里的 `.codex-plus-update-…` 残留目录没有被清理(#2774)。 +- 供应商池卡片不再显示中转站类型(sub2api / new-api),只显示倍率和余额,类型放在悬停提示里。 + +## 2.1.3 - 2026-10-29 + +本次更新让 Codex 在 WSL 中运行时也能接入网关,Agent 协作同一 Agent 的任务可以排队,供应商池能测试请求、单独管理每个 API Key、显示 sub2api / new-api 中转站的倍率和余额、导入链接可直接加进供应商池;修复 Windows 商店版 Codex 已在运行时启动 / 重启失败、接入网关后自定义根级设置被冲掉、重启 Codex 后皮肤不生效、从中转切回官方账号时同一对话报「encrypted content could not be verified」等问题,网关转发改为复用连接。 + +### 注意 + +- **WSL 中运行的 Codex 接入网关**:Codex 设置为「在 WSL 中运行」时,网关会额外监听 WSL 虚拟网卡(只开放网关的模型请求、每个请求都要网关 token),第一次可能弹出 Windows 防火墙提示,请允许 Codex++ 访问网络。这种模式下接入网关不使用 ChatGPT 登录身份。已把 WSL 网络设为 mirrored 的不受影响(#2773)。 +- **商店版「重启 Codex」**:Codex 已经开着但没开调试端口(例如从开始菜单打开、缩在托盘里)时,「重启 Codex」会先请它退出、必要时结束主进程再重新启动;「启动 Codex」遇到这种情况只提示,不会结束正在使用的 Codex(#2764、#2700、#2747)。 + +### 新增 + +- **Codex 在 WSL 中运行时也能接入网关**(#2773)。 +- **Agent 协作同一 Agent 的任务排队**:Agent 正忙时新任务不再被拒绝,排队后按提交顺序自动开始,显示前面还有几个;每个 Agent 最多排 8 个,排队中的任务可以取消(#2772)。 +- **sub2api / new-api 中转站倍率和余额**:自动识别这两类中转站,在供应商卡片上显示分组倍率和余额 / 额度。 +- **导入链接可直接加进供应商池**:`codexplusplus://` 导入链接带 `target=pool` 时,确认后直接加进网关供应商池,同地址同 Key 不会重复添加;macOS 上 Codex++ 已在运行时也能接收导入链接。 +- **供应商池请求测试**:供应商详情里新增「测试」,选一个模型按网关真实的方式(这家的协议、代理、Key,账号类供应商先换好 token)发一条很短的请求,显示是否成功、状态码、首字与总耗时和回复片段;失败时给出上游报错和排查提示(Key 无效、模型不存在、被限速、连接或代理问题)。Key 列表里每个 Key 也能单独测。测试请求不计入冷却、限速和测速,不改会话固定,也不进请求日志。 +- **供应商池管理 API Key**:供应商详情里列出已保存的每个 Key(只显示前几位和后几位),可以单独替换、删除或再加一个;删改后该 Key 的冷却、限速状态一并清掉,正在用它的会话自动换用别的 Key。各处「编辑」改为铅笔图标,供应商的编辑按钮移到标题栏(#2782)。 +- **脚本市场显示更新时间**:显示每个脚本的最近更新时间,可按「最近更新」排序(需要市场清单提供每个脚本的更新时间);本地脚本列表的开关和加载状态合并为一列,开着但加载失败等情况会直接标出(#2781)。 + +### 改进 + +- **网关转发复用连接**:网关和本地代理转发上游时复用连接,不再每个请求都重新握手和加载系统证书,每次请求能省几十到几百毫秒(#2761)。 +- **账号类供应商走供应商自己的代理**:官方登录等账号的 token 刷新、额度 / 账号查询、模型列表也按该供应商的代理设置(含直连)发出,不开系统代理时不再直连失败(#2775)。 +- **供应商测试失败时说明用了哪个 Key**:被上游以 401 / 403 拒绝时显示本次 Key 的名称和末 4 位;存了多个 Key 时提醒去勾选当前 Key(#2754)。 +- **商店版 Codex 启动诊断更完整**:包上下文启动会记录执行轨迹和期间出现过的 Codex 进程,方便区分「没起来」和「起来又退了」(#2764)。 +- **供应商模型「从上游同步」**:原来的「从上游获取」改为先预览差异再确认:上游已经没有的模型默认移除(可取消勾选保留,移除时连同它的价格、窗口等资料),上游新增的模型可搜索、多选后再加入,默认不加;默认模型被移除时提示换成哪个;上游返回空列表时不改动。编辑供应商弹窗里的「从上游获取」也改为勾选列表,并新增「上下文窗口」入口,加完模型就能顺手设置(#2785)。 +- 侧栏分组标题更醒目;快速跳转的快捷键按平台显示(Windows / Linux 显示 Ctrl K)(#2778)。 + +### 修复 + +- 从中转切回官方账号(或反过来)后在同一对话里继续,报「The encrypted content for item rs_… could not be verified」:经网关或本地代理的请求会去掉别家签发的加密推理 / 压缩内容后自动重发,并记住被拒内容提前去掉;也能识别 Grok / xAI 的同类报错和流内的拒收。直接用「旧版供应商配置」切回纯官方登录时 Codex 不经过 Codex++,遇到请新开对话(#2777)。 +- 开了「仅允许 Codex 官方客户端」的 ChatGPT 中转站,经网关接入后报 403「This account only allows Codex official clients」:网关现在会把 Codex 的客户端身份请求头原样转给上游(#2783)。 +- Codex 接入网关后,config.toml 里 `model_instructions_file` 等与路由无关的根级设置被冲掉;断开网关切回直连时也会补回(#2771)。 +- 重启 Codex 后皮肤不生效(开了原生 Fast 时每次必现)(#2680)。 +- Windows 上切到 Claude Code 等 Agent 页时闪出黑色终端窗口(#2778)。 +- 在 Codex 里直接退出后,Codex++ 侧栏仍显示 Codex 运行中(#2778)。 +- macOS 自动更新后「应用程序」文件夹里残留 `.codex-plus-update-…` 目录;以前留下的也会自动清理(#2774)。 +- 显式设置的 CODEX_HOME 目录还没创建时会回退到默认目录(#2729)。 +- 设置了状态目录时,多开实例的 Electron 数据没有隔离在该目录下(#2768)。 +- 退出清理完成后仍有进程残留时,增加 watchdog 兜底(#2693)。 + ## 2.1.2 - 2026-10-29 本次更新新增网关按 Key 主动限速、供应商「不使用代理(直连)」;修复网关「依次」组换到后面的供应商后整个会话回不去、MCP 条目残缺导致 Codex 起不来、图片覆盖层下弹窗被遮挡等问题。 diff --git a/Cargo.lock b/Cargo.lock index 4d01c98048..4c4980b9d9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -210,6 +210,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + [[package]] name = "block2" version = "0.6.2" @@ -447,7 +456,7 @@ dependencies = [ [[package]] name = "codex-plus-core" -version = "2.1.2" +version = "2.1.4" dependencies = [ "aes-gcm", "anyhow", @@ -455,10 +464,13 @@ dependencies = [ "base64 0.22.1", "block2", "directories", + "ecb", "fs2", "futures-util", "http", "httpdate", + "libc", + "md-5", "objc2", "objc2-app-kit", "objc2-core-graphics", @@ -487,7 +499,7 @@ dependencies = [ [[package]] name = "codex-plus-data" -version = "2.1.2" +version = "2.1.4" dependencies = [ "anyhow", "base64 0.22.1", @@ -506,7 +518,7 @@ dependencies = [ [[package]] name = "codex-plus-launcher" -version = "2.1.2" +version = "2.1.4" dependencies = [ "anyhow", "async-trait", @@ -520,7 +532,7 @@ dependencies = [ [[package]] name = "codex-plus-manager" -version = "2.1.2" +version = "2.1.4" dependencies = [ "anyhow", "async-trait", @@ -992,6 +1004,15 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "ecb" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a8bfa975b1aec2145850fcaa1c6fe269a16578c44705a532ae3edc92b8881c7" +dependencies = [ + "cipher", +] + [[package]] name = "embed-resource" version = "3.0.9" @@ -1964,6 +1985,7 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" dependencies = [ + "block-padding", "generic-array", ] @@ -2247,6 +2269,16 @@ dependencies = [ "web_atoms", ] +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + [[package]] name = "memchr" version = "2.8.0" diff --git a/Cargo.toml b/Cargo.toml index 52ff919296..4c0b2aa9ce 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,7 +8,7 @@ members = [ ] [workspace.package] -version = "2.1.2" +version = "2.1.4" edition = "2024" license = "AGPL-3.0-only" repository = "https://github.com/BigPizzaV3/CodexPlusPlus" diff --git a/apps/codex-plus-launcher/src/main.rs b/apps/codex-plus-launcher/src/main.rs index 54c5cf42d8..29c7923f91 100644 --- a/apps/codex-plus-launcher/src/main.rs +++ b/apps/codex-plus-launcher/src/main.rs @@ -749,6 +749,9 @@ async fn launcher_session( state.options.app_dir = Some(handle.app_dir.clone()); state.options.debug_port = handle.debug_port; state.options.helper_port = handle.helper_port; + // 「重启」许可只对这一次启动有效:之后再点「启动」复用本会话激活时, + // 不能沿用它去结束用户另开的 Codex。 + state.options.replace_blocking_main_instance = false; state.status_started_at_ms = handle .status_store .load_latest() @@ -986,6 +989,15 @@ async fn activate_existing_codex_app(options: &LaunchOptions) -> anyhow::Result< // 于是「切换登录方式后 model_provider 没跟着切换」「历史会话没被自动修复」, // 只有管理工具的「重启」才生效(issue #2080)。失败不阻断激活,只记日志。 run_activation_provider_sync(&hooks, &settings).await; + if let Err(error) = codex_plus_core::relay_config::ensure_managed_session_provider_in_home( + &codex_plus_core::relay_config::default_codex_home_dir(), + &settings, + ) { + let _ = codex_plus_core::diagnostic_log::append_diagnostic_log( + "launcher.managed_session_provider_repair_failed", + json!({ "message": error.to_string(), "phase": "activate_existing" }), + ); + } let launch_result = hooks .launch_codex( &app_dir, @@ -1114,8 +1126,11 @@ impl LaunchHooks for LauncherHooks { } } - fn cleanup_unsupported_config(&self) -> anyhow::Result<()> { - self.core.cleanup_unsupported_config() + fn cleanup_unsupported_config( + &self, + settings: &codex_plus_core::settings::BackendSettings, + ) -> anyhow::Result<()> { + self.core.cleanup_unsupported_config(settings) } async fn run_provider_sync(&self) -> anyhow::Result<()> { diff --git a/apps/codex-plus-manager/package-lock.json b/apps/codex-plus-manager/package-lock.json index 4fd9a3a590..d517993573 100644 --- a/apps/codex-plus-manager/package-lock.json +++ b/apps/codex-plus-manager/package-lock.json @@ -1,12 +1,12 @@ { "name": "codex-plus-manager", - "version": "2.1.2", + "version": "2.1.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "codex-plus-manager", - "version": "2.1.2", + "version": "2.1.4", "license": "AGPL-3.0-only", "dependencies": { "@dnd-kit/core": "^6.3.1", diff --git a/apps/codex-plus-manager/package.json b/apps/codex-plus-manager/package.json index 7a41aba6b0..8a36a7d9ab 100644 --- a/apps/codex-plus-manager/package.json +++ b/apps/codex-plus-manager/package.json @@ -1,6 +1,6 @@ { "name": "codex-plus-manager", - "version": "2.1.2", + "version": "2.1.4", "private": true, "license": "AGPL-3.0-only", "type": "module", diff --git a/apps/codex-plus-manager/src-tauri/src/auto_takeover.rs b/apps/codex-plus-manager/src-tauri/src/auto_takeover.rs index 3353e24fe8..02a8831fb8 100644 --- a/apps/codex-plus-manager/src-tauri/src/auto_takeover.rs +++ b/apps/codex-plus-manager/src-tauri/src/auto_takeover.rs @@ -112,7 +112,7 @@ fn inner() -> &'static Mutex { INNER.get_or_init(|| { Mutex::new(Inner { state: "disabled", - platform_supported: cfg!(target_os = "macos"), + platform_supported: cfg!(any(target_os = "macos", target_os = "linux")), ..Inner::default() }) }) @@ -422,7 +422,7 @@ fn collect_inputs() -> Result { // 此前这里单独用 `any(macos, windows)`,Windows 上守护线程确实会运行, // 而 UI 读到的 platform_supported 却是 false,用户勾选开关后表现为 // 「反复拉起白色窗口、界面却显示不支持」(#2573)。 - let platform_supported = cfg!(target_os = "macos"); + let platform_supported = cfg!(any(target_os = "macos", target_os = "linux")); let enabled = is_enabled(); let session_snapshot = codex_plus_launcher::session_snapshot(); let latest = StatusStore::default().load_latest().ok().flatten(); @@ -794,7 +794,10 @@ mod tests { #[test] fn platform_support_matches_the_build_target() { let payload = payload(); - assert_eq!(payload.platform_supported, cfg!(target_os = "macos")); + assert_eq!( + payload.platform_supported, + cfg!(any(target_os = "macos", target_os = "linux")) + ); } #[test] diff --git a/apps/codex-plus-manager/src-tauri/src/commands.rs b/apps/codex-plus-manager/src-tauri/src/commands.rs index 1eca3b6829..7de91430ee 100644 --- a/apps/codex-plus-manager/src-tauri/src/commands.rs +++ b/apps/codex-plus-manager/src-tauri/src/commands.rs @@ -395,6 +395,27 @@ pub struct CcsProvidersPayload { #[serde(rename_all = "camelCase")] pub struct PendingProviderImportPayload { pub pending: Option, + /// pending 文件内容的指纹:确认 / 取消时带回,后端比对一致才动手。 + pub fingerprint: Option, +} + +impl PendingProviderImportPayload { + fn empty() -> Self { + Self { + pending: None, + fingerprint: None, + } + } + + fn from_pending(pending: Option) -> Self { + match pending { + Some(pending) => Self { + pending: Some(pending.request), + fingerprint: Some(pending.fingerprint), + }, + None => Self::empty(), + } + } } #[derive(Debug, Clone, Serialize)] @@ -545,20 +566,6 @@ pub struct RelayProfileModelsPayload { pub endpoint: String, } -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct Sub2ApiBillingPayload { - pub endpoint: String, - pub group_rate_multiplier: f64, - pub user_rate_multiplier: Option, - pub resolved_rate_multiplier: f64, - pub peak_rate_enabled: bool, - pub peak_rate_multiplier: Option, - pub applied_peak_multiplier: Option, - pub effective_rate_multiplier: f64, - pub observed_at: String, -} - #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct ProviderDoctorCheck { @@ -977,6 +984,18 @@ where args.into_iter().any(|arg| arg.as_ref() == "--show-update") || env_value == Some("1") } +/// 只查 Codex 桌面端进程还在不在,给前端定期轻量轮询用(#2778:在 Codex 自己的窗口里 +/// 退出后,侧栏和顶栏仍显示「运行中 / 停止 Codex」)。不读设置、不探测端口,状态变了 +/// 前端再走完整的 `load_overview`。后台任务失败返回 None,前端忽略这一轮。 +#[tauri::command] +pub async fn codex_app_running() -> Option { + tauri::async_runtime::spawn_blocking(|| { + !codex_plus_core::watcher::find_codex_processes().is_empty() + }) + .await + .ok() +} + #[tauri::command] pub async fn load_overview() -> CommandResult { let payload = tauri::async_runtime::spawn_blocking(load_overview_payload).await; @@ -1200,39 +1219,32 @@ fn launch_codex_multi_instance_blocking(request: CodexMultiLaunchRequest) -> Com ); } - // 官方 profile 通常没有 authContents;复制一次当前默认 CODEX_HOME 的登录态, - // 用户也可以在新窗口中重新登录。纯 API 实例不复制官方 auth,避免两个身份 - // 重新混在一起。永远不覆盖实例已有 auth.json。 let default_home = codex_plus_core::relay_config::default_codex_home_dir(); - let instance_auth = codex_home.join("auth.json"); - if profile.relay_mode == codex_plus_core::settings::RelayMode::Official - && !instance_auth.exists() - { - let default_auth = default_home.join("auth.json"); - if let Ok(contents) = std::fs::read(&default_auth) { - let _ = codex_plus_core::settings::atomic_write(&instance_auth, &contents); - } - } let common_config = if settings.relay_profiles_enabled { settings.relay_common_config_contents.as_str() } else { "" }; - if let Err(error) = codex_plus_core::relay_config::apply_relay_profile_with_preservation( + // 纯 API 实例写入的是去掉 ChatGPT 登录态后的 profile(#2794),worker 设置也用它。 + let profile = match write_multi_instance_codex_home( &codex_home, + &default_home, &profile, common_config, helper_port, settings.relay_preserve_nonprovider_settings, ) { - return failed( - &format!("应用供应商到多开实例失败:{error}"), - json!({ - "profileId": profile_id, - "codexHome": codex_home.to_string_lossy() - }), - ); - } + Ok(profile) => profile, + Err(error) => { + return failed( + &format!("应用供应商到多开实例失败:{error}"), + json!({ + "profileId": profile_id, + "codexHome": codex_home.to_string_lossy() + }), + ); + } + }; if let Err(error) = absolutize_multi_instance_model_catalog(&codex_home) { return failed( &format!("修正多开实例 model_catalog_json 路径失败:{error}"), @@ -1864,16 +1876,32 @@ fn terminate_multi_worker( #[cfg(windows)] { // 创建身份、精确参数与 exe 均验证后,使用同一句柄终止,不再按 PID taskkill。 - let terminated = codex_plus_core::multi_worker::terminate_verified_worker( + let result = codex_plus_core::multi_worker::terminate_verified_worker( pid, options_path, worker_birth_id, - )?; - if !terminated { - let _ = codex_plus_core::diagnostic_log::append_diagnostic_log( - "manager.multi_worker_stop_skipped", - json!({ "pid": pid, "reason": "identity_unverified" }), - ); + ); + match result { + Ok(true) => {} + Ok(false) => { + let _ = codex_plus_core::diagnostic_log::append_diagnostic_log( + "manager.multi_worker_stop_skipped", + json!({ "pid": pid, "reason": "identity_unverified" }), + ); + } + Err(error) => { + // 多开停止调用方会故意忽略清理错误;这里先写诊断,避免身份冲突或 + // 查询失败只表现为“停掉了”,却没有留下跳过依据。 + let _ = codex_plus_core::diagnostic_log::append_diagnostic_log( + "manager.multi_worker_stop_skipped", + json!({ + "pid": pid, + "reason": "verification_error", + "message": format!("{error:#}"), + }), + ); + return Err(error); + } } } #[cfg(not(any(unix, windows)))] @@ -1921,6 +1949,8 @@ pub fn run_codex_multi_worker(options_path: &Path) -> anyhow::Result<()> { state_dir: Some(instance_state_dir.clone()), codex_home: std::env::var_os("CODEX_HOME").map(PathBuf::from), sqlite_home: std::env::var_os("CODEX_SQLITE_HOME").map(PathBuf::from), + // 多开实例自带 --user-data-dir,与主实例的单实例锁无关,绝不结束主 Codex。 + replace_blocking_main_instance: false, }; let receipt = match codex_plus_launcher::start_with_receipt(launch_options) { Ok(receipt) => receipt, @@ -2084,10 +2114,15 @@ fn multi_instance_user_data_dir(slug: &str) -> PathBuf { multi_instance_user_data_dir_with_root( slug, std::env::var_os("CODEX_ELECTRON_USER_DATA_PATH").map(PathBuf::from), + codex_plus_core::paths::state_dir_override(), ) } -fn multi_instance_user_data_dir_with_root(slug: &str, electron_root: Option) -> PathBuf { +fn multi_instance_user_data_dir_with_root( + slug: &str, + electron_root: Option, + state: Option, +) -> PathBuf { // 多开根与主实例 userData 同级,避免主实例清理时连带删除多开数据。 if let Some(root) = electron_root.filter(|root| !root.as_os_str().is_empty()) { let parent = root.parent().unwrap_or_else(|| Path::new(".")); @@ -2097,6 +2132,14 @@ fn multi_instance_user_data_dir_with_root(slug: &str, electron_root: Option PathBuf { directories::BaseDirs::new() .map(|dirs| { dirs.data_local_dir() @@ -2144,6 +2187,43 @@ fn absolutize_multi_instance_model_catalog(home: &Path) -> anyhow::Result<()> { Ok(()) } +/// 把供应商写进多开实例自己的 CODEX_HOME,返回实际写入的 profile。 +/// +/// - 官方登录(含官方 + API 混合):实例还没有 auth.json 时复制一次主实例的登录态, +/// 用户也可以在新窗口里重新登录;永远不覆盖实例已有 auth.json。 +/// - 纯 API(#2794):不复制主实例的 auth,并且先经 +/// `pure_api_profile_for_isolated_instance` 去掉快照里残留的 ChatGPT 登录态、把 +/// `openai` 会话身份改回 `custom`。否则实例会以主进程的官方账号登录、消耗官方额度。 +/// +/// 只读主实例的 `default_home`,不改动它。 +fn write_multi_instance_codex_home( + codex_home: &Path, + default_home: &Path, + profile: &RelayProfile, + common_config: &str, + helper_port: u16, + preserve_nonprovider: bool, +) -> anyhow::Result { + let instance_auth = codex_home.join("auth.json"); + if profile.relay_mode == codex_plus_core::settings::RelayMode::Official + && !instance_auth.exists() + { + let default_auth = default_home.join("auth.json"); + if let Ok(contents) = std::fs::read(&default_auth) { + let _ = codex_plus_core::settings::atomic_write(&instance_auth, &contents); + } + } + let profile = codex_plus_core::relay_config::pure_api_profile_for_isolated_instance(profile)?; + codex_plus_core::relay_config::apply_relay_profile_with_preservation( + codex_home, + &profile, + common_config, + helper_port, + preserve_nonprovider, + )?; + Ok(profile) +} + fn seed_multi_instance_onboarding_state(home: &Path) -> anyhow::Result<()> { let path = home.join(".codex-global-state.json"); let mut state = match std::fs::read(&path) { @@ -2332,7 +2412,13 @@ fn restart_codex_plus_blocking(request: LaunchRequest) -> CommandResult { let app_running = codex_plus_core::cdp::endpoint_available(request.debug_port); if restart_disposition(request.sync_active_relay, app_running) == RestartDisposition::LaunchOnly { - return spawn_codex_plus_launch(request, "Codex 未运行,已按直接启动方式在后台唤起。"); + // 调试端口不可连不代表 Codex 没在跑:Windows 商店版主 Codex 可能开着却没带端口 + // (issue #2764)。走重启专用的启动,由 launcher 识别并结束它后再启动。 + return spawn_codex_plus_launch_with( + request, + "Codex 未运行,已按直接启动方式在后台唤起。", + start_embedded_launcher_for_restart, + ); } let Ok(_guard) = relay_switch_mutex().try_lock() else { return failed( @@ -2429,7 +2515,11 @@ fn restart_codex_plus_blocking(request: LaunchRequest) -> CommandResult { } let mut restart_message = "Codex 已请求重启,启动任务正在后台运行。".to_string(); match restart_codex_plus_after_stop(&request, &home, settings.as_ref(), |request| { - spawn_after_provider_guard_release(provider_guard, request, start_embedded_launcher) + spawn_after_provider_guard_release( + provider_guard, + request, + start_embedded_launcher_for_restart, + ) }) { Ok(warnings) => { for warning in warnings { @@ -2898,6 +2988,14 @@ fn normalize_launch_request(mut request: LaunchRequest) -> LaunchRequest { } fn spawn_codex_plus_launch(request: LaunchRequest, accepted_message: &str) -> CommandResult { + spawn_codex_plus_launch_with(request, accepted_message, start_embedded_launcher) +} + +fn spawn_codex_plus_launch_with( + request: LaunchRequest, + accepted_message: &str, + start: impl FnOnce(&LaunchRequest) -> anyhow::Result<()>, +) -> CommandResult { let request = normalize_launch_request(request); let debug_port = request.debug_port; let helper_port = request.helper_port; @@ -2924,7 +3022,7 @@ fn spawn_codex_plus_launch(request: LaunchRequest, accepted_message: &str) -> Co }), ); } - match start_embedded_launcher(&request) { + match start(&request) { Ok(()) => { let (debug_port, helper_port, started_at_ms) = accepted_launch_identity(&request, launch_started_at_ms); @@ -3037,6 +3135,19 @@ fn current_timestamp_ms() -> u64 { } fn start_embedded_launcher(request: &LaunchRequest) -> anyhow::Result<()> { + start_embedded_launcher_with(request, false) +} + +/// 「重启 Codex」专用:允许 launcher 结束挡住本次启动的 Windows 商店版主 Codex +/// (已在运行却没带调试端口,issue #2764)。普通启动只提示用户自行退出。 +fn start_embedded_launcher_for_restart(request: &LaunchRequest) -> anyhow::Result<()> { + start_embedded_launcher_with(request, true) +} + +fn start_embedded_launcher_with( + request: &LaunchRequest, + replace_blocking_main_instance: bool, +) -> anyhow::Result<()> { anyhow::ensure!(!crate::app_is_exiting(), "Codex++ 正在退出,未启动 Codex"); // 先让常驻 helper 占住协议代理端口:启动链路会识别并复用,Codex 会话退出 // 时就不再关闭它,从而实现「Codex++ 在跑,本地代理就一直可用」。 @@ -3047,6 +3158,7 @@ fn start_embedded_launcher(request: &LaunchRequest) -> anyhow::Result<()> { debug_port: request.debug_port, helper_port: request.helper_port, status_store: StatusStore::default(), + replace_blocking_main_instance, ..Default::default() }) } @@ -4592,30 +4704,47 @@ pub fn import_ccs_providers( #[tauri::command] pub fn load_pending_provider_import() -> CommandResult { - match codex_plus_core::provider_import::load_pending_provider_import() { + match codex_plus_core::provider_import::load_pending_provider_import_with_fingerprint() { Ok(pending) => ok( "待确认供应商导入已读取。", - PendingProviderImportPayload { pending }, + PendingProviderImportPayload::from_pending(pending), ), Err(error) => failed( &format!("读取待确认供应商导入失败:{error}"), - PendingProviderImportPayload { pending: None }, + PendingProviderImportPayload::empty(), ), } } +/// `fingerprint` 是弹窗展示时拿到的指纹;pending 期间被新链接覆盖时拒绝写入,让用户重新确认。 +/// 导入目标与导入内容取自同一次读取。 #[tauri::command] -pub fn confirm_pending_provider_import() -> CommandResult { - match codex_plus_core::provider_import::confirm_pending_provider_import() { - Ok(Some(result)) => { - let message = if result.imported { - format!("已导入供应商配置:{}。", result.profile_name) - } else { - format!("供应商配置已存在:{}。", result.profile_name) +pub fn confirm_pending_provider_import(fingerprint: String) -> CommandResult { + match codex_plus_core::provider_import::confirm_pending_provider_import_matching(&fingerprint) { + Ok(Some(confirmed)) => { + let result = confirmed.result; + let message = match (confirmed.to_pool, result.imported) { + (true, true) => { + crate::gateway_commands::after_url_pool_import(result.profile_id.clone()); + format!("已加入网关供应商池:{},正在从上游获取模型。", result.profile_name) + } + (true, false) => format!("供应商池里已有相同地址和 Key 的供应商:{}。", result.profile_name), + (false, true) => format!("已导入供应商配置:{}。", result.profile_name), + (false, false) => format!("供应商配置已存在:{}。", result.profile_name), }; settings_payload(&message, "供应商导入后重新读取设置失败") } Ok(None) => settings_payload("没有待确认的供应商导入。", "设置读取失败"), + Err(error) + if error + .downcast_ref::() + .is_some() => + { + failed( + &error.to_string(), + settings_payload_value().unwrap_or_else(|(_, payload)| payload), + ) + } Err(error) => failed( &format!("导入供应商配置失败:{error}"), settings_payload_value().unwrap_or_else(|(_, payload)| payload), @@ -4623,16 +4752,21 @@ pub fn confirm_pending_provider_import() -> CommandResult { } } +/// 只取消用户看到的那一条;期间被新链接覆盖时保留新的,并返回给界面重新展示。 #[tauri::command] -pub fn dismiss_pending_provider_import() -> CommandResult { - match codex_plus_core::provider_import::clear_pending_provider_import() { - Ok(()) => ok( +pub fn dismiss_pending_provider_import( + fingerprint: Option, +) -> CommandResult { + match codex_plus_core::provider_import::dismiss_pending_provider_import_matching( + fingerprint.as_deref(), + ) { + Ok(remaining) => ok( "已取消供应商导入。", - PendingProviderImportPayload { pending: None }, + PendingProviderImportPayload::from_pending(remaining), ), Err(error) => failed( &format!("取消供应商导入失败:{error}"), - PendingProviderImportPayload { pending: None }, + PendingProviderImportPayload::empty(), ), } } @@ -4688,8 +4822,31 @@ pub async fn list_project_sessions( } fn normalized_project_path(path: &str) -> String { - // 与前端 sessionGroups 一致:只统一分隔符和末尾斜杠,保留大小写及路径中的空格。 - path.replace('\\', "/").trim_end_matches('/').to_string() + // 与前端 normalizeProjectPath 一致:统一分隔符和末尾斜杠,保留大小写及路径中的空格; + // 去掉 Windows 长路径前缀(\\?\C:\ → C:/,\\?\UNC\srv\share → //srv/share), + // 否则同一项目带不带前缀会被当成两个目录(#2791)。 + let path = path.replace('\\', "/"); + let path = path.trim_end_matches('/'); + for prefix in ["//?/", "//./"] { + let Some(rest) = path.strip_prefix(prefix) else { + continue; + }; + if rest + .get(..4) + .is_some_and(|head| head.eq_ignore_ascii_case("UNC/")) + { + return format!("//{}", &rest[4..]); + } + let bytes = rest.as_bytes(); + if bytes.len() >= 2 + && bytes[0].is_ascii_alphabetic() + && bytes[1] == b':' + && (bytes.len() == 2 || bytes[2] == b'/') + { + return rest.to_string(); + } + } + path.to_string() } fn list_project_sessions_blocking(cwd: String) -> CommandResult { @@ -5323,6 +5480,10 @@ fn merge_manual_provider_sync_targets( codex_home: Option<&Path>, ) { for id in manual { + // 旧版本可能把内部会话别名存进已保存列表;它不是可选目标,不再补回列表(#2791)。 + if codex_plus_data::is_provider_sync_internal_alias(id) { + continue; + } if let Some(existing) = targets.targets.iter_mut().find(|target| target.id == *id) { if !existing .sources @@ -6737,7 +6898,7 @@ pub async fn check_update() -> CommandResult { } } -#[cfg(any(windows, target_os = "macos"))] +#[cfg(any(windows, target_os = "macos", target_os = "linux"))] #[tauri::command] pub async fn perform_update( app: tauri::AppHandle, @@ -6774,7 +6935,7 @@ pub async fn perform_update( result } -#[cfg(not(any(windows, target_os = "macos")))] +#[cfg(not(any(windows, target_os = "macos", target_os = "linux")))] #[tauri::command] pub async fn perform_update( release: Option, @@ -8314,52 +8475,28 @@ pub async fn fetch_relay_profile_models( } } +/// 识别供应商是哪种中转站(sub2api / new-api),并顺带取回倍率和余额。供应商卡片在后台静默调用, +/// 识别不出也返回 ok,前端按 `platform` 决定是否显示。 #[tauri::command] -pub async fn fetch_sub2api_billing(profile: RelayProfile) -> CommandResult { - let profile_name = if profile.name.trim().is_empty() { - "未命名供应商" - } else { - profile.name.trim() - }; - match codex_plus_core::sub2api::fetch_sub2api_billing_info(&profile).await { - Ok(info) => ok( - &format!( - "已从「{profile_name}」获取倍率:{}x。", - format_multiplier(info.effective_rate_multiplier) - ), - Sub2ApiBillingPayload { - endpoint: info.endpoint, - group_rate_multiplier: info.group_rate_multiplier, - user_rate_multiplier: info.user_rate_multiplier, - resolved_rate_multiplier: info.resolved_rate_multiplier, - peak_rate_enabled: info.peak_rate_enabled, - peak_rate_multiplier: info.peak_rate_multiplier, - applied_peak_multiplier: info.applied_peak_multiplier, - effective_rate_multiplier: info.effective_rate_multiplier, - observed_at: info.observed_at, - }, - ), - Err(error) => failed( - &format!("从「{profile_name}」获取 sub2api 倍率失败:{error}"), - Sub2ApiBillingPayload { - endpoint: codex_plus_core::sub2api::sub2api_billing_endpoint( - if profile.upstream_base_url.trim().is_empty() { - profile.base_url.trim() - } else { - profile.upstream_base_url.trim() - }, - ), - group_rate_multiplier: 0.0, - user_rate_multiplier: None, - resolved_rate_multiplier: 0.0, - peak_rate_enabled: false, - peak_rate_multiplier: None, - applied_peak_multiplier: None, - effective_rate_multiplier: 0.0, - observed_at: String::new(), - }, +pub async fn probe_relay_billing( + profile: RelayProfile, +) -> CommandResult { + let probe = codex_plus_core::relay_billing::probe_relay_billing(&profile).await; + let message = match (&probe.platform, &probe.rate) { + (Some(platform), Some(rate)) => format!( + "已识别为 {platform},倍率 {}x。", + format_multiplier(rate.multiplier) ), - } + (Some(platform), None) => format!("已识别为 {platform}。"), + (None, _) + if probe.outcome + == codex_plus_core::relay_billing::RelayBillingOutcome::Unavailable => + { + "暂时获取不到倍率和余额。".to_string() + } + (None, _) => "不是已知的中转站。".to_string(), + }; + ok(&message, probe) } fn format_multiplier(value: f64) -> String { @@ -9183,7 +9320,7 @@ fn open_url(url: &str) -> anyhow::Result<()> { { codex_plus_core::windows_open_url(url) } - #[cfg(not(windows))] + #[cfg(target_os = "macos")] { std::process::Command::new("open") .arg(url) @@ -9191,6 +9328,14 @@ fn open_url(url: &str) -> anyhow::Result<()> { .map(|_| ()) .map_err(|error| anyhow::anyhow!("启动系统浏览器失败:{error}")) } + #[cfg(not(any(windows, target_os = "macos")))] + { + std::process::Command::new("xdg-open") + .arg(url) + .spawn() + .map(|_| ()) + .map_err(|error| anyhow::anyhow!("启动系统浏览器失败:{error}")) + } } fn settings_payload(message: &str, failure_context: &str) -> CommandResult { @@ -9320,6 +9465,7 @@ fn market_script_payload(script: &MarketScript, installed: &BTreeMap>(), + vec!["custom"] + ); + } + #[test] fn provider_sync_synced_is_reported_as_command_success() { let result = provider_sync_command_result(provider_sync_result_for_test( @@ -11168,6 +11370,20 @@ base_url = "https://example.invalid/v1" "C:/Projects/Foo" ); assert_eq!(normalized_project_path("/Projects/Foo///"), "/Projects/Foo"); + // Windows 长路径前缀不进入项目身份(#2791)。 + assert_eq!( + normalized_project_path(r"\\?\C:\Projects\Foo\"), + "C:/Projects/Foo" + ); + assert_eq!( + normalized_project_path(r"\\?\UNC\server\share\Foo"), + normalized_project_path(r"\\server\share\Foo") + ); + assert_eq!(normalized_project_path(r"\\?\C:\"), "C:"); + assert_eq!( + normalized_project_path(r"\\?\Volume{abc}\Foo"), + "//?/Volume{abc}/Foo" + ); assert_ne!( normalized_project_path("/Projects/Foo"), normalized_project_path("/Projects/foo") @@ -12056,6 +12272,119 @@ model_reasoning_effort = "high" assert_ne!(first, second); } + const MAIN_CHATGPT_AUTH: &str = "{\n \"auth_mode\": \"chatgpt\",\n \"tokens\": {\n \"id_token\": \"main-id\",\n \"access_token\": \"main-access\",\n \"refresh_token\": \"main-refresh\",\n \"account_id\": \"acct-main\"\n },\n \"last_refresh\": \"2026-10-01T00:00:00Z\"\n}\n"; + + #[test] + fn multi_instance_pure_api_does_not_inherit_main_chatgpt_login() { + // issue #2794:主实例官方登录,用纯 API 供应商多开,实例只能用供应商 Key。 + let temp = tempfile::tempdir().unwrap(); + let main_home = temp.path().join("main-codex"); + let instance_home = temp.path().join("instance/codex-home"); + std::fs::create_dir_all(&main_home).unwrap(); + std::fs::create_dir_all(&instance_home).unwrap(); + std::fs::write(main_home.join("auth.json"), MAIN_CHATGPT_AUTH).unwrap(); + std::fs::write(main_home.join("config.toml"), "model = \"gpt-5.5\"\n").unwrap(); + // 旧版本留在实例里的官方登录态也要被换掉。 + std::fs::write(instance_home.join("auth.json"), MAIN_CHATGPT_AUTH).unwrap(); + // 从官方 profile 改成纯 API 时,快照会留着 ChatGPT tokens;会话身份选的是 openai。 + let mut snapshot: Value = serde_json::from_str(MAIN_CHATGPT_AUTH).unwrap(); + snapshot["OPENAI_API_KEY"] = json!("sk-relay"); + let profile = RelayProfile { + id: "relay".into(), + name: "中转".into(), + relay_mode: RelayMode::PureApi, + protocol: codex_plus_core::settings::RelayProtocol::Responses, + config_contents: "model_provider = \"openai\"\nmodel = \"gpt-5.5\"\nopenai_base_url = \"http://127.0.0.1:57321/v1\"\n\n[model_providers.custom]\nname = \"中转\"\nwire_api = \"responses\"\nrequires_openai_auth = true\nbase_url = \"https://relay.example.com/v1\"\nexperimental_bearer_token = \"sk-relay\"\n".into(), + auth_contents: serde_json::to_string_pretty(&snapshot).unwrap(), + api_key: "sk-relay".into(), + ..RelayProfile::default() + }; + + let written = + write_multi_instance_codex_home(&instance_home, &main_home, &profile, "", 61234, false) + .unwrap(); + + let auth: Value = + serde_json::from_str(&std::fs::read_to_string(instance_home.join("auth.json")).unwrap()) + .unwrap(); + assert_eq!(auth, json!({ "OPENAI_API_KEY": "sk-relay" })); + let config = std::fs::read_to_string(instance_home.join("config.toml")).unwrap(); + let doc = config.parse::().unwrap(); + assert_eq!(doc["model_provider"].as_str(), Some("custom"), "{config}"); + assert!(doc.get("openai_base_url").is_none(), "{config}"); + let provider = &doc["model_providers"]["custom"]; + assert_eq!(provider["base_url"].as_str(), Some("https://relay.example.com/v1")); + assert_eq!(provider["experimental_bearer_token"].as_str(), Some("sk-relay")); + assert_ne!(provider.get("requires_openai_auth").and_then(|v| v.as_bool()), Some(true)); + assert!(!written.auth_contents.contains("tokens")); + // 主实例的登录态原样保留。 + assert_eq!( + std::fs::read_to_string(main_home.join("auth.json")).unwrap(), + MAIN_CHATGPT_AUTH + ); + } + + #[test] + fn multi_instance_official_profile_still_copies_main_login() { + let temp = tempfile::tempdir().unwrap(); + let main_home = temp.path().join("main-codex"); + let instance_home = temp.path().join("instance/codex-home"); + std::fs::create_dir_all(&main_home).unwrap(); + std::fs::create_dir_all(&instance_home).unwrap(); + std::fs::write(main_home.join("auth.json"), MAIN_CHATGPT_AUTH).unwrap(); + let profile = RelayProfile { + id: "official".into(), + relay_mode: RelayMode::Official, + ..RelayProfile::default() + }; + + write_multi_instance_codex_home(&instance_home, &main_home, &profile, "", 61235, false) + .unwrap(); + + let auth: Value = + serde_json::from_str(&std::fs::read_to_string(instance_home.join("auth.json")).unwrap()) + .unwrap(); + assert_eq!(auth["tokens"]["account_id"], json!("acct-main")); + } + + #[test] + fn multi_instance_user_data_is_scoped_to_explicit_manager_state() { + let temp = tempfile::tempdir().unwrap(); + let first = temp.path().join("manager a"); + let second = temp.path().join("manager b"); + assert_eq!( + multi_instance_user_data_dir_with_root("same-name", None, Some(first.clone())), + first.join("codex-multi/same-name/user-data") + ); + assert_eq!( + multi_instance_user_data_dir_with_root("same-name", None, Some(second.clone())), + second.join("codex-multi/same-name/user-data") + ); + assert_ne!( + multi_instance_user_data_dir_with_root("same-name", None, Some(first.clone())), + multi_instance_user_data_dir_with_root("same-name", None, Some(second.clone())) + ); + assert_ne!( + multi_instance_user_data_dir_with_root("first", None, Some(first.clone())), + multi_instance_user_data_dir_with_root("second", None, Some(first.clone())) + ); + assert!(!first.exists()); + assert!(!second.exists()); + } + + #[test] + fn multi_instance_user_data_without_state_keeps_legacy_layout() { + let expected = legacy_multi_instance_user_data_dir("fixture"); + assert_eq!( + multi_instance_user_data_dir_with_root("fixture", None, None), + expected + ); + assert_eq!( + multi_instance_user_data_dir_with_root("fixture", None, Some(PathBuf::new())), + expected + ); + } + #[test] fn multi_instance_manifest_roundtrips_runtime_metadata() { let temp = tempfile::tempdir().unwrap(); diff --git a/apps/codex-plus-manager/src-tauri/src/gateway_commands.rs b/apps/codex-plus-manager/src-tauri/src/gateway_commands.rs index 71a90fa7bf..a87cd372ed 100644 --- a/apps/codex-plus-manager/src-tauri/src/gateway_commands.rs +++ b/apps/codex-plus-manager/src-tauri/src/gateway_commands.rs @@ -29,6 +29,8 @@ pub struct GatewayPoolEntry { pub official: bool, pub models: Vec, pub key_count: usize, + /// 已保存的 Key(打码,#2782):只有标识和头尾几位,Key 原文不传给界面。订阅账号等不用 Key 的为空。 + pub keys: Vec, pub enabled: bool, /// 预设 key 或导入来源,界面判断「已添加」用。 pub source: String, @@ -64,6 +66,10 @@ pub struct GatewayPoolEntry { pub upstream_gone: Vec, /// 订阅账号的额度(ChatGPT 5 小时 / 每周、Copilot 本月高级请求);查不到或普通供应商为空。 pub quota: Option, + /// 有余额接口的厂商(DeepSeek、Kimi 等,#2793):卡片用「用量」页同一份余额,不当中转站识别。 + pub balance_api: bool, + /// new-api 访问令牌(打码,#2793);没配置为空。令牌原文不传给界面。 + pub newapi_access: Option, } #[derive(Debug, Clone, Serialize)] @@ -171,6 +177,7 @@ fn pool_entry( official: codex_plus_core::official_login::member_uses_official_login_transport(relay), models: codex_plus_core::gateway::route::relay_models(relay), key_count: key_pool::expand_keys(relay).len(), + keys: Vec::new(), enabled, source: source.to_string(), price, @@ -189,6 +196,8 @@ fn pool_entry( upstream_new: upstream.0, upstream_gone: upstream.1, quota: codex_plus_core::gateway::quota::get(&relay.id), + balance_api: codex_plus_core::gateway::balance::has_balance_api(relay), + newapi_access: None, } } @@ -213,6 +222,8 @@ fn build_payload(settings: &BackendSettings, gateway: GatewaySettings) -> Gatewa .providers .iter() .map(|provider| GatewayPoolEntry { + keys: apply::provider_keys(&provider.relay), + newapi_access: apply::provider_newapi_access(provider), model_windows: provider.model_windows.clone(), context_window: provider.context_window, key_rpm: provider.key_rpm, @@ -676,6 +687,131 @@ pub fn update_gateway_provider( respond("供应商已保存。", "保存供应商失败", result) } +/// 给网关供应商再加一个 Key(#2782,在供应商池的详情里操作)。 +#[tauri::command] +pub fn add_gateway_provider_key(id: String, key: String) -> CommandResult> { + respond( + "Key 已添加。", + "添加 Key 失败", + refresh_agents(apply::add_provider_key(&GatewayStore::default(), &id, &key)), + ) +} + +/// 把供应商的某个 Key 换成新值(按 Key 的标识找,#2782)。 +#[tauri::command] +pub fn replace_gateway_provider_key( + id: String, + key_id: String, + key: String, +) -> CommandResult> { + respond( + "Key 已替换。", + "替换 Key 失败", + refresh_agents(apply::replace_provider_key( + &GatewayStore::default(), + &id, + &key_id, + &key, + )), + ) +} + +/// 删掉供应商的某个 Key(按 Key 的标识找,至少保留一个,#2782)。 +#[tauri::command] +pub fn delete_gateway_provider_key( + id: String, + key_id: String, +) -> CommandResult> { + respond( + "Key 已删除。", + "删除 Key 失败", + refresh_agents(apply::delete_provider_key( + &GatewayStore::default(), + &id, + &key_id, + )), + ) +} + +/// 进行中的供应商测试:(序号, 中止函数)。同一时间只留一个,开始新的测试时中止上一个。 +static PROBE_TASK: std::sync::Mutex)>> = + std::sync::Mutex::new(None); +static PROBE_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); +/// 测试被用户取消时的提示;界面按 status `cancelled` 识别。 +const PROBE_CANCELLED: &str = "测试已取消。"; + +fn probe_slot() -> std::sync::MutexGuard<'static, Option<(u64, Box)>> { + PROBE_TASK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) +} + +/// 测试供应商池里的一家供应商:发一条很短的请求(见 `gateway::probe`),不计入路由与统计。 +/// `key_id` 给了就只用这个 Key(按标识选,完整 Key 不经过界面);`model` 为空时用默认模型。 +/// 在后台任务里跑、最多 60 秒,可用 [`cancel_gateway_provider_test`] 取消。 +#[tauri::command] +pub async fn test_gateway_provider( + id: String, + model: Option, + key_id: Option, +) -> CommandResult> { + let handle = tauri::async_runtime::spawn(async move { + let settings = SettingsStore::default().load().unwrap_or_default(); + let gateway = GatewayStore::default().load()?; + codex_plus_core::gateway::probe::probe_provider( + &settings, + &gateway, + &id, + model.as_deref().unwrap_or_default(), + key_id.as_deref(), + ) + .await + }); + let seq = PROBE_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1; + let abort = handle.inner().abort_handle(); + if let Some((_, previous)) = probe_slot().replace((seq, Box::new(move || abort.abort()))) { + previous(); + } + let joined = handle.await; + { + let mut slot = probe_slot(); + if slot.as_ref().is_some_and(|(current, _)| *current == seq) { + *slot = None; + } + } + match joined { + Ok(Ok(result)) => ok( + if result.ok { + "测试成功。" + } else { + "测试失败。" + }, + Some(result), + ), + Ok(Err(error)) => failed(&format!("测试失败:{error:#}"), None), + Err(tauri::Error::JoinError(error)) if error.is_cancelled() => CommandResult { + status: "cancelled".to_string(), + message: PROBE_CANCELLED.to_string(), + payload: None, + }, + Err(error) => failed(&format!("测试失败:测试任务异常退出:{error}"), None), + } +} + +/// 取消进行中的供应商测试(关掉测试弹窗、点「取消」时调用)。没有进行中的测试时什么也不做。 +#[tauri::command] +pub fn cancel_gateway_provider_test() -> CommandResult<()> { + let cancelled = probe_slot().take().map(|(_, abort)| abort()).is_some(); + ok( + if cancelled { + PROBE_CANCELLED + } else { + "没有进行中的测试。" + }, + (), + ) +} + /// 只改供应商的价格与独立代理(在供应商池的详情里编辑)。 #[tauri::command] pub fn save_gateway_provider_extras( @@ -707,6 +843,32 @@ pub fn save_gateway_provider_limits( ) } +/// 改 new-api 供应商的访问令牌与用户 ID(#2793,在供应商池的详情里编辑)。 +/// `token` 留空沿用已保存的;`clear` 删掉令牌,之后不再查账户余额。令牌不写进返回的消息。 +#[tauri::command] +pub fn save_gateway_provider_newapi_access( + id: String, + token: Option, + user_id: String, + clear: bool, +) -> CommandResult> { + respond( + if clear { + "访问令牌已清除。" + } else { + "访问令牌已保存。" + }, + "保存访问令牌失败", + apply::set_provider_newapi_access( + &GatewayStore::default(), + &id, + token.as_deref(), + &user_id, + clear, + ), + ) +} + /// 改供应商的上下文窗口(按模型的和其余模型用的默认值);窗口写在 Agent 的模型资料里,存完重写 Agent 配置。 #[tauri::command] pub fn save_gateway_provider_windows( @@ -745,35 +907,70 @@ pub async fn check_gateway_provider_models( } } -/// 供应商详情里的「从上游获取」:上游新增的模型直接加进列表,已有的不删。 +/// 供应商详情里「从上游同步」的第一步(#2785):拉上游模型,返回和现有列表的差异供用户勾选, +/// 不改任何数据。拉取失败或上游返回空列表时报错。 #[tauri::command] -pub async fn fetch_gateway_provider_models(id: String) -> CommandResult> { - let result = model_sync::fetch_provider_models(&GatewayStore::default(), &id).await; - let message = match &result { - Ok((_, diff)) if diff.added.is_empty() => format!( - "上游有 {} 个模型,列表已是最新。", - diff.upstream.len() - ), - Ok((_, diff)) => format!( - "上游有 {} 个模型,已加入 {} 个:{}。", - diff.upstream.len(), - diff.added.len(), - diff.added.join("、") +pub async fn preview_gateway_provider_models_sync( + id: String, +) -> CommandResult> { + match model_sync::preview_provider_models(&GatewayStore::default(), &id).await { + Ok(preview) => ok( + &format!( + "上游有 {} 个模型:新增 {} 个,已下架 {} 个。", + preview.upstream.len(), + preview.added.len(), + preview.removed.len() + ), + Some(preview), ), + Err(error) => failed(&format!("获取模型列表失败:{error:#}"), None), + } +} + +/// 「从上游同步」的第二步:只删用户勾选移除的、只加勾选新增的;被删模型的价格、窗口、思考等级一并清掉。 +#[tauri::command] +pub fn apply_gateway_provider_models_sync( + id: String, + remove: Vec, + add: Vec, +) -> CommandResult> { + let result = model_sync::apply_upstream_sync(&GatewayStore::default(), &id, &remove, &add); + let message = match &result { + Ok((_, changes)) => sync_models_message(changes), Err(_) => String::new(), }; - let gone = result.as_ref().map_or(0, |(_, diff)| diff.removed.len()); - let message = if gone > 0 { - format!("{message}另有 {gone} 个上游已没有,可在「编辑」里移除。") - } else { - message - }; - let added = result.as_ref().is_ok_and(|(_, diff)| !diff.added.is_empty()); + let added = result + .as_ref() + .is_ok_and(|(_, changes)| !changes.added.is_empty()); let result = refresh_agents(result); if added { autofill_prices(Some(id)); } - respond(&message, "获取模型列表失败", result) + respond(&message, "从上游同步模型失败", result) +} + +/// 「从上游同步」的结果提示:新增、移除各几个(列出模型名),都没有就说列表没变。 +fn sync_models_message(changes: &model_sync::ModelChanges) -> String { + let mut parts = Vec::new(); + if !changes.added.is_empty() { + parts.push(format!( + "新增 {} 个:{}", + changes.added.len(), + changes.added.join("、") + )); + } + if !changes.removed.is_empty() { + parts.push(format!( + "移除 {} 个:{}", + changes.removed.len(), + changes.removed.join("、") + )); + } + if parts.is_empty() { + "模型列表没有变化。".to_string() + } else { + format!("已同步模型:{}。", parts.join(";")) + } } /// 保存手动编辑或刷新后的模型列表;新加的模型按公开价格表补价格。 @@ -804,6 +1001,18 @@ fn refresh_added(result: anyhow::Result) -> anyhow::Result { refresh_agents(result) } +/// 网页导入链接(`target=pool`)加进供应商池之后:重写开着的 Agent 配置, +/// 并在后台从上游获取模型列表(导入链接不带模型),拿到后再补价格。 +pub(crate) fn after_url_pool_import(id: String) { + let _ = refresh_added(Ok(())); + tauri::async_runtime::spawn(async move { + let result = model_sync::fetch_provider_models(&GatewayStore::default(), &id).await; + if refresh_agents(result).is_ok() { + autofill_prices(Some(id)); + } + }); +} + /// 一键同步价格:按公开价格表更新这个供应商所有模型的价格并保存。 #[tauri::command] pub async fn sync_gateway_provider_prices(id: String) -> CommandResult> { @@ -1191,13 +1400,19 @@ fn probe_cli_version(binary: &str) -> Option { .into_iter() .map(|dir| dir.join(binary)) .find(|candidate| candidate.is_file())?; - let mut child = std::process::Command::new(path) + let mut command = std::process::Command::new(path); + command .arg("--version") .stdin(std::process::Stdio::null()) .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::null()) - .spawn() - .ok()?; + .stderr(std::process::Stdio::null()); + // CLI 是控制台程序:Windows 上不加 CREATE_NO_WINDOW,打开 Agent 页时会闪黑色终端(#2778)。 + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + command.creation_flags(codex_plus_core::windows_create_no_window()); + } + let mut child = command.spawn().ok()?; let deadline = std::time::Instant::now() + std::time::Duration::from_secs(4); loop { match child.try_wait() { @@ -1288,6 +1503,43 @@ pub struct GatewayBalances { pub balances: Vec, } +/// 识别网关供应商是哪种中转站(sub2api / new-api),并取回倍率和余额。Key 只在后端,前端按 id 调。 +#[tauri::command] +pub async fn probe_gateway_provider_billing( + id: String, +) -> CommandResult { + let provider = GatewayStore::default().load().ok().and_then(|gateway| { + gateway + .provider(&id) + .map(|provider| (provider.relay.clone(), provider.newapi_access.clone())) + }); + let Some((relay, newapi_access)) = provider else { + return failed( + &format!("供应商不存在:{id}"), + codex_plus_core::relay_billing::RelayBillingProbe::unrecognized( + codex_plus_core::relay_billing::RelayBillingOutcome::NotRelay, + String::new(), + ), + ); + }; + // 识别成 new-api 且配置了访问令牌时,顺带查账户余额(#2793)。 + let probe = codex_plus_core::relay_billing::probe_relay_billing_with_access( + &relay, + newapi_access.as_ref(), + ) + .await; + let message = match &probe.platform { + Some(platform) => format!("已识别为 {platform}。"), + None if probe.outcome + == codex_plus_core::relay_billing::RelayBillingOutcome::Unavailable => + { + "暂时获取不到倍率和余额。".to_string() + } + None => "不是已知的中转站。".to_string(), + }; + ok(&message, probe) +} + /// 查池里有余额接口的供应商的余额(联网,每家最多 10 秒)。 #[tauri::command] pub async fn load_gateway_balances() -> CommandResult { @@ -1660,7 +1912,8 @@ pub async fn login_gateway_grok( use codex_plus_core::gateway::accounts; let outcome = cancellable_login(async move { let tokens = accounts::grok_login(open_in_browser).await?; - let models = accounts::grok_models(&tokens.access_token).await; + // 登录时还没有供应商(也就没有它的代理设置),走系统代理。 + let models = accounts::grok_models(&tokens.access_token, None).await; add_account_provider( accounts::grok_relay(&tokens, &models), "grok", @@ -1685,6 +1938,7 @@ pub async fn login_gateway_chatgpt_api( let models = accounts::chatgpt_api_models( &account.access_token, &codex_plus_core::codex_home::default_codex_home_dir(), + None, ) .await; add_account_provider( @@ -1705,8 +1959,28 @@ pub fn reset_gateway_key_health(relay_id: String) -> CommandResult bool { APP_EXITING.load(Ordering::SeqCst) } +fn shutdown_entry_snapshot(code: Option) -> serde_json::Value { + serde_json::json!({ + "code": code, + "appExiting": APP_EXITING.load(Ordering::SeqCst), + "shutdownStarted": SHUTDOWN_STARTED.load(Ordering::SeqCst), + "cleanupComplete": SHUTDOWN_COMPLETE.load(Ordering::SeqCst), + "observedAtMs": std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64, + }) +} + +fn record_shutdown_entry(event: &'static str, detail: serde_json::Value) { + shutdown_watchdog::record_best_effort(move || { + let _ = codex_plus_core::diagnostic_log::append_diagnostic_log(event, detail); + }); +} + pub fn run_agent_collaboration_mcp(args: &[std::ffi::OsString]) -> anyhow::Result<()> { agent_collaboration_commands::run_mcp(args) } @@ -187,6 +207,11 @@ pub fn run() { } } } + // 清理以前自动更新留在 app 同级目录里的 .codex-plus-update- 残留(#2774)。 + // 删旧 app 备份要些时间,放后台线程;刚由旧版 helper 完成的更新会在 helper 退出后补跑清掉。 + // 失败只记诊断日志,不影响启动。 + #[cfg(target_os = "macos")] + std::thread::spawn(codex_plus_core::update::macos::cleanup_stale_update_transactions); if let Ok(settings) = codex_plus_core::settings::SettingsStore::default().load() { // 请求体上限是运行期策略:管理器启动即按设置应用,之后启动的 helper 直接继承, // 不必等完整启动流程。环境变量仍由 launcher 侧统一优先。 @@ -245,8 +270,9 @@ pub fn run() { app.handle().clone(), start_codex_at_login || start_codex_on_open, ); - // 网关模式:有 Agent 走网关时常驻 helper 必须在线。 - gateway_commands::start_from_saved_state(); + // 网关模式:有 Agent 走网关时常驻 helper 必须在线。后台线程里跑, + // WSL 网卡查询与监听绑定不拖慢窗口出现。 + gateway_commands::start_from_saved_state_in_background(); gateway_commands::start_model_watch(); // 自动接管官方 App(默认关闭):设置里开着才起守护线程。 auto_takeover::start_supervisor(); @@ -264,6 +290,7 @@ pub fn run() { commands::startup_options, commands::consume_pending_manager_navigation, commands::load_overview, + commands::codex_app_running, commands::launch_codex_plus, commands::launch_codex_multi_instance, commands::list_codex_multi_instances, @@ -335,9 +362,15 @@ pub fn run() { gateway_commands::reset_gateway_key_health, gateway_commands::create_gateway_provider, gateway_commands::update_gateway_provider, + gateway_commands::add_gateway_provider_key, + gateway_commands::replace_gateway_provider_key, + gateway_commands::delete_gateway_provider_key, + gateway_commands::test_gateway_provider, + gateway_commands::cancel_gateway_provider_test, gateway_commands::delete_gateway_provider, gateway_commands::check_gateway_provider_models, - gateway_commands::fetch_gateway_provider_models, + gateway_commands::preview_gateway_provider_models_sync, + gateway_commands::apply_gateway_provider_models_sync, gateway_commands::save_gateway_provider_models, gateway_commands::refresh_gateway_quota, gateway_commands::sync_gateway_provider_prices, @@ -349,6 +382,7 @@ pub fn run() { gateway_commands::load_gateway_usage, gateway_commands::clear_gateway_usage, gateway_commands::load_gateway_balances, + gateway_commands::probe_gateway_provider_billing, gateway_commands::list_agent_sessions, gateway_commands::resume_agent_session, gateway_commands::delete_agent_session, @@ -361,6 +395,7 @@ pub fn run() { gateway_commands::import_gateway_providers, gateway_commands::save_gateway_provider_extras, gateway_commands::save_gateway_provider_limits, + gateway_commands::save_gateway_provider_newapi_access, gateway_commands::save_gateway_provider_windows, gateway_commands::save_gateway_provider_metadata, gateway_commands::gateway_provider_reasoning, @@ -502,7 +537,7 @@ pub fn run() { commands::diagnose_relay_profile, commands::test_stepwise_settings, commands::fetch_relay_profile_models, - commands::fetch_sub2api_billing, + commands::probe_relay_billing, commands::switch_relay_profile, commands::apply_relay_injection, commands::apply_pure_api_injection, @@ -516,58 +551,63 @@ pub fn run() { Ok(app) => app.run(|app_handle, event| { if let tauri::RunEvent::ExitRequested { api, code, .. } = &event { if code.is_none() && !APP_EXITING.load(Ordering::SeqCst) { - // 不带退出码的退出请求来自「最后一个窗口被销毁」:轻量模式销毁主窗口后 - // 后端、网关和托盘要继续运行,这不是用户要退出(#2677)。 - // 真正的退出(托盘退出、app.exit)都会带退出码并先置 APP_EXITING。 + // 轻量模式销毁主窗口时,Tauri 可能发出不带退出码的请求;后端、网关和托盘 + // 必须继续运行。真正退出由托盘、app.exit 或已设置 APP_EXITING 的路径触发。 api.prevent_exit(); - } else if !SHUTDOWN_COMPLETE.load(Ordering::SeqCst) { + return; + } + let detail = shutdown_entry_snapshot(*code); + let start_shutdown = if !SHUTDOWN_COMPLETE.load(Ordering::SeqCst) { api.prevent_exit(); - if !SHUTDOWN_STARTED.swap(true, Ordering::SeqCst) { - APP_EXITING.store(true, Ordering::SeqCst); - auto_takeover::stop_supervisor(); - // 退出清理里任何一步卡住(例如等待启动器或常驻代理), - // 进程就会停在「已阻止退出、尚未完成」的状态:托盘菜单点了没反应, - // 再点也不会重来(SHUTDOWN_STARTED 已经是 true),任务管理器里也 - // 只能强杀(#2611)。这里给清理设一个上限,到点直接退出, - // 先写日志留下是超时退出的证据。 - std::thread::spawn(|| { - std::thread::sleep(std::time::Duration::from_secs(SHUTDOWN_WATCHDOG)); - if SHUTDOWN_COMPLETE.load(Ordering::SeqCst) { - return; - } + !SHUTDOWN_STARTED.swap(true, Ordering::SeqCst) + } else { + false + }; + if start_shutdown { + APP_EXITING.store(true, Ordering::SeqCst); + shutdown_watchdog::arm( + std::time::Duration::from_secs(SHUTDOWN_WATCHDOG), + || { let _ = codex_plus_core::diagnostic_log::append_diagnostic_log( "app.shutdown_watchdog_forced", - serde_json::json!({ "waitedSeconds": SHUTDOWN_WATCHDOG }), + serde_json::json!({ + "waitedSeconds": SHUTDOWN_WATCHDOG, + "cleanupComplete": SHUTDOWN_COMPLETE.load(Ordering::SeqCst), + }), ); - std::process::exit(0); - }); - let app = app_handle.clone(); - tauri::async_runtime::spawn_blocking(move || { - // 退出前先等待自有 CLI 任务清理,避免进程退出后遗留执行器。 - tauri::async_runtime::block_on( - codex_plus_core::agent_collaboration::executor::shutdown_owned_tasks(), + }, + ); + } + // 入口状态已采样;首次退出必须先启用兜底,再交给后台写盘。 + record_shutdown_entry("app.exit_requested", detail); + if start_shutdown { + auto_takeover::stop_supervisor(); + let app = app_handle.clone(); + tauri::async_runtime::spawn_blocking(move || { + // 退出前先等待自有 CLI 任务清理,避免进程退出后遗留执行器。 + tauri::async_runtime::block_on( + codex_plus_core::agent_collaboration::executor::shutdown_owned_tasks(), + ); + if let Err(error) = codex_plus_launcher::shutdown() { + let _ = codex_plus_core::diagnostic_log::append_diagnostic_log( + "app.shutdown_failed", + serde_json::json!({ "message": error.to_string() }), ); - if let Err(error) = codex_plus_launcher::shutdown() { - let _ = codex_plus_core::diagnostic_log::append_diagnostic_log( - "app.shutdown_failed", - serde_json::json!({ "message": error.to_string() }), - ); - } - // 常驻本地代理只在 Codex++ 退出时停止;Codex 会话结束 - // 不会关它(见 core::resident_helper)。 - gateway_commands::restore_for_exit(); - codex_plus_core::resident_helper::stop_resident_helper(); - SHUTDOWN_COMPLETE.store(true, Ordering::SeqCst); - app.exit(0); - }); - } + } + // 常驻本地代理只在 Codex++ 退出时停止;Codex 会话结束 + // 不会关它(见 core::resident_helper)。 + gateway_commands::restore_for_exit(); + codex_plus_core::resident_helper::stop_resident_helper(); + SHUTDOWN_COMPLETE.store(true, Ordering::SeqCst); + app.exit(0); + }); } } #[cfg(target_os = "macos")] match event { tauri::RunEvent::Opened { urls } => { for url in urls { - if handle_dream_skin_url(url.as_str()) { + if handle_dream_skin_url(url.as_str()) || handle_provider_import_url(url.as_str()) { show_main_window(app_handle); } } @@ -592,6 +632,36 @@ pub fn run() { } } +/// 网页导入链接(codexplusplus://v1/import/provider?…):存为待确认导入,界面轮询到后弹出确认框。 +/// Windows 从启动参数传进来,macOS 由系统的「打开 URL」事件送达(应用已在运行时也走这里)。 +pub fn handle_provider_import_url(url: &str) -> bool { + if !url.starts_with("codexplusplus://") { + return false; + } + match codex_plus_core::provider_import::save_pending_provider_import_from_url(url) { + Ok(request) => { + let _ = codex_plus_core::diagnostic_log::append_diagnostic_log( + "manager.provider_import_url.pending", + serde_json::json!({ + "name": request.name, + "baseUrl": request.base_url, + "target": request.target + }), + ); + true + } + Err(error) => { + let _ = codex_plus_core::diagnostic_log::append_diagnostic_log( + "manager.provider_import_url.failed", + serde_json::json!({ + "error": error.to_string() + }), + ); + false + } + } +} + pub fn handle_dream_skin_url(url: &str) -> bool { if !url.starts_with("dreamskin://") { return false; @@ -627,6 +697,7 @@ fn install_tray(app: &tauri::App) -> tauri::Result<()> { show_main_window(app); } TRAY_MENU_QUIT => { + record_shutdown_entry("app.tray_quit", shutdown_entry_snapshot(None)); APP_EXITING.store(true, Ordering::SeqCst); app.exit(0); } @@ -875,6 +946,7 @@ fn register_main_window_events(window: tauri::WebviewWindow(app: tauri::AppHandle) { + record_shutdown_entry("app.manager_exit_app", shutdown_entry_snapshot(None)); APP_EXITING.store(true, Ordering::SeqCst); app.exit(0); } diff --git a/apps/codex-plus-manager/src-tauri/src/main.rs b/apps/codex-plus-manager/src-tauri/src/main.rs index 0614e5426c..2837c0d5ec 100644 --- a/apps/codex-plus-manager/src-tauri/src/main.rs +++ b/apps/codex-plus-manager/src-tauri/src/main.rs @@ -118,27 +118,8 @@ fn main() { if codex_plus_manager_lib::handle_dream_skin_url(&arg) { codex_plus_manager_lib::focus_existing_manager_window(); } - } else if arg.starts_with("codexplusplus://") { - match codex_plus_core::provider_import::save_pending_provider_import_from_url(&arg) { - Ok(request) => { - let _ = codex_plus_core::diagnostic_log::append_diagnostic_log( - "manager.provider_import_url.pending", - serde_json::json!({ - "name": request.name, - "baseUrl": request.base_url - }), - ); - codex_plus_manager_lib::focus_existing_manager_window(); - } - Err(error) => { - let _ = codex_plus_core::diagnostic_log::append_diagnostic_log( - "manager.provider_import_url.failed", - serde_json::json!({ - "error": error.to_string() - }), - ); - } - } + } else if codex_plus_manager_lib::handle_provider_import_url(&arg) { + codex_plus_manager_lib::focus_existing_manager_window(); } } if std::env::args().any(|arg| arg == "--show-update") { diff --git a/apps/codex-plus-manager/src-tauri/src/shutdown_watchdog.rs b/apps/codex-plus-manager/src-tauri/src/shutdown_watchdog.rs new file mode 100644 index 0000000000..2eed22ec03 --- /dev/null +++ b/apps/codex-plus-manager/src-tauri/src/shutdown_watchdog.rs @@ -0,0 +1,158 @@ +use std::time::Duration; + +/// 日志只尽力后台写入;不等待写盘,线程创建失败也不能妨碍退出。 +pub(crate) fn record_best_effort(record: impl FnOnce() + Send + 'static) { + let _ = std::thread::Builder::new() + .name("shutdown-diagnostic".into()) + .spawn(record); +} + +/// 只有进程真正退出才解除兜底,不能用后台清理完成代替退出确认。 +pub(crate) fn arm(timeout: Duration, record_timeout: impl FnOnce() + Send + 'static) { + std::thread::spawn(move || { + std::thread::sleep(timeout); + // 诊断写盘也可能阻塞,不能让日志阻止最终退出。 + let (recorded, completion) = std::sync::mpsc::channel(); + record_best_effort(move || { + record_timeout(); + let _ = recorded.send(()); + }); + let _ = completion.recv_timeout(Duration::from_millis(250)); + std::process::exit(0); + }); +} + +#[cfg(test)] +mod tests { + use super::*; + use std::process::{Child, Command, Stdio}; + use std::sync::atomic::{AtomicBool, Ordering}; + use std::time::Instant; + + struct ChildGuard(Child); + + impl Drop for ChildGuard { + fn drop(&mut self) { + let _ = self.0.kill(); + let _ = self.0.wait(); + } + } + + #[test] + #[ignore = "subprocess fixture; invoked by watchdog_covers_the_entire_exit"] + fn watchdog_child() { + let mode = std::env::var("CODEX_PLUS_SHUTDOWN_WATCHDOG_TEST").unwrap(); + let cleanup_complete = AtomicBool::new(mode == "complete"); + let block_diagnostic = mode == "blocked_diagnostic"; + if mode == "blocked_entry" { + record_best_effort(|| { + loop { + std::thread::park(); + } + }); + } + arm(Duration::from_millis(100), move || { + if block_diagnostic { + loop { + std::thread::park(); + } + } + println!( + "cleanupComplete={}", + cleanup_complete.load(Ordering::SeqCst) + ); + }); + if mode == "normal_exit" { + std::process::exit(23); + } + // 模拟最终退出事件未生效或清理阻塞;不能让测试框架自行退出掩盖问题。 + loop { + std::thread::park(); + } + } + + #[test] + fn watchdog_covers_the_entire_exit() { + for mode in [ + "incomplete", + "complete", + "normal_exit", + "blocked_diagnostic", + "blocked_entry", + ] { + let mut command = Command::new(std::env::current_exe().unwrap()); + command + .args([ + "--exact", + "shutdown_watchdog::tests::watchdog_child", + "--ignored", + "--nocapture", + ]) + .env("CODEX_PLUS_SHUTDOWN_WATCHDOG_TEST", mode) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + command.creation_flags(0x08000000); + } + let mut child = ChildGuard(command.spawn().unwrap()); + let deadline = Instant::now() + Duration::from_secs(5); + let status = loop { + if let Some(status) = child.0.try_wait().unwrap() { + break status; + } + assert!( + Instant::now() < deadline, + "watchdog did not terminate {mode}" + ); + std::thread::sleep(Duration::from_millis(10)); + }; + assert_eq!( + status.code(), + Some(if mode == "normal_exit" { 23 } else { 0 }), + "{mode}" + ); + use std::io::Read; + let mut stdout = String::new(); + child + .0 + .stdout + .take() + .unwrap() + .read_to_string(&mut stdout) + .unwrap(); + if !matches!(mode, "normal_exit" | "blocked_diagnostic") { + assert!( + stdout.contains(&format!("cleanupComplete={}", mode == "complete")), + "{stdout}" + ); + } else { + assert!(!stdout.contains("cleanupComplete="), "{stdout}"); + } + } + } + + #[test] + fn entry_logging_returns_before_a_blocked_writer_finishes() { + let (writer_started, writer_started_rx) = std::sync::mpsc::channel(); + let (release, release_rx) = std::sync::mpsc::channel(); + let (writer_finished, writer_finished_rx) = std::sync::mpsc::channel(); + let (caller_returned, caller_returned_rx) = std::sync::mpsc::channel(); + let caller = std::thread::spawn(move || { + record_best_effort(move || { + writer_started.send(()).unwrap(); + let _ = release_rx.recv_timeout(Duration::from_secs(5)); + writer_finished.send(()).unwrap(); + }); + caller_returned.send(()).unwrap(); + }); + let writer_started = writer_started_rx.recv_timeout(Duration::from_secs(2)); + let caller_returned = caller_returned_rx.recv_timeout(Duration::from_secs(1)); + let _ = release.send(()); + caller.join().unwrap(); + assert!(writer_started.is_ok(), "diagnostic writer never started"); + assert!(caller_returned.is_ok(), "caller waited for diagnostic I/O"); + writer_finished_rx.recv_timeout(Duration::from_secs(2)).unwrap(); + } +} diff --git a/apps/codex-plus-manager/src-tauri/tauri.conf.json b/apps/codex-plus-manager/src-tauri/tauri.conf.json index dae1d9b726..5e58370a79 100644 --- a/apps/codex-plus-manager/src-tauri/tauri.conf.json +++ b/apps/codex-plus-manager/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Codex++", - "version": "2.1.2", + "version": "2.1.4", "identifier": "com.bigpizzav3.codexplusplus", "build": { "beforeDevCommand": "npm run vite:dev", diff --git a/apps/codex-plus-manager/src-tauri/tests/windows_subsystem.rs b/apps/codex-plus-manager/src-tauri/tests/windows_subsystem.rs index 7354f402e9..7fd5e67733 100644 --- a/apps/codex-plus-manager/src-tauri/tests/windows_subsystem.rs +++ b/apps/codex-plus-manager/src-tauri/tests/windows_subsystem.rs @@ -198,8 +198,14 @@ fn manager_second_instance_activates_existing_macos_bundle() { #[test] fn manager_queues_codexplusplus_provider_urls_for_confirmation_on_startup() { - let main_rs = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/src/main.rs")) - .expect("read manager main.rs"); + // 导入链接的处理抽成了 lib.rs 里的 handle_provider_import_url,启动参数与 macOS 的 Opened 事件共用。 + let main_rs = [ + std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/src/main.rs")) + .expect("read manager main.rs"), + std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/src/lib.rs")) + .expect("read manager lib.rs"), + ] + .concat(); assert!(main_rs.contains("codexplusplus://")); assert!(main_rs.contains("provider_import::save_pending_provider_import_from_url")); diff --git a/apps/codex-plus-manager/src/AgentCollaborationPage.tsx b/apps/codex-plus-manager/src/AgentCollaborationPage.tsx index dabebd7b3c..1e02f57fd8 100644 --- a/apps/codex-plus-manager/src/AgentCollaborationPage.tsx +++ b/apps/codex-plus-manager/src/AgentCollaborationPage.tsx @@ -3,9 +3,12 @@ import { invoke } from "@tauri-apps/api/core"; import { ArrowRightLeft, Check, Copy, Loader2, Play, RefreshCw, Save, Square } from "lucide-react"; import { PageHeader } from "./ManagementPages"; import { AppSelect } from "./AppSelect"; +import { agentQueueState, DEFAULT_MAX_CONCURRENT_TASKS_LIMIT, normalizeConcurrency } from "./agent-collaboration-queue"; +import { t, tf } from "./i18n"; import "./AgentCollaborationPage.css"; -type Agent = { id: string; name: string; executable: string; enabled: boolean; model: string; reuseSavedAuth: boolean }; +// maxConcurrentTasks:同一 Agent 最多同时运行的任务数(1–4,默认 1),超出时排队(#2800)。 +type Agent = { id: string; name: string; executable: string; enabled: boolean; model: string; reuseSavedAuth: boolean; maxConcurrentTasks: number }; type CollaborationConfig = { enabled: boolean; defaultAgentId: string; agents: Agent[]; maxRuntimeMinutes: number }; const defaultMaxRuntimeMinutes = 10; const maxRuntimeMinutesLimit = 240; @@ -42,6 +45,8 @@ type Snapshot = { availability?: { id: string; available: boolean; path?: string | null; configuredMissing?: boolean; recovered?: boolean }[]; integration?: Integration; runtime?: CodexRuntime; + /** maxQueuedPerAgent:每个 Agent 最多排队的任务数(#2772);maxConcurrentTasksLimit:并发上限的可选上界(#2800)。 */ + capabilities?: { maxQueuedPerAgent?: number; maxConcurrentTasksLimit?: number }; }; type McpConfig = { codexToml: string; claudeJson: string; command: string; args: string[] }; @@ -49,9 +54,9 @@ const defaultConfig = (): CollaborationConfig => ({ enabled: false, defaultAgentId: "deepseek", agents: [ - { id: "codex", name: "Codex", executable: "", enabled: false, model: "", reuseSavedAuth: false }, - { id: "deepseek", name: "DeepSeek Harness", executable: "", enabled: false, model: "", reuseSavedAuth: false }, - { id: "claude", name: "Claude Code", executable: "", enabled: false, model: "", reuseSavedAuth: false }, + { id: "codex", name: "Codex", executable: "", enabled: false, model: "", reuseSavedAuth: false, maxConcurrentTasks: 1 }, + { id: "deepseek", name: "DeepSeek Harness", executable: "", enabled: false, model: "", reuseSavedAuth: false, maxConcurrentTasks: 1 }, + { id: "claude", name: "Claude Code", executable: "", enabled: false, model: "", reuseSavedAuth: false, maxConcurrentTasks: 1 }, ], maxRuntimeMinutes: defaultMaxRuntimeMinutes, }); @@ -69,7 +74,7 @@ const copyConfig = (config: CollaborationConfig): CollaborationConfig => ({ defaultAgentId: config.defaultAgentId || "deepseek", maxRuntimeMinutes: Number.isInteger(config.maxRuntimeMinutes) && config.maxRuntimeMinutes > 0 ? config.maxRuntimeMinutes : defaultMaxRuntimeMinutes, - agents: config.agents.map((agent) => ({ ...agent, model: agent.model || "", reuseSavedAuth: agent.reuseSavedAuth === true })), + agents: config.agents.map((agent) => ({ ...agent, model: agent.model || "", reuseSavedAuth: agent.reuseSavedAuth === true, maxConcurrentTasks: normalizeConcurrency(agent.maxConcurrentTasks) })), }); const formatTime = (value: number) => Number.isFinite(value) && value > 0 ? new Date(value).toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hour12: false }) @@ -246,16 +251,29 @@ export function AgentCollaborationPage({ defaultCwd = "" }: { defaultCwd?: strin : enabledAgents.find((agent) => agent.id === snapshot?.config.defaultAgentId)?.id ?? enabledAgents[0]?.id ?? ""); const targetAgent = enabledAgents.find((agent) => agent.id === targetId); const targetUnavailable = snapshot?.availability?.find((item) => item.id === targetId)?.available === false; - const targetBusy = tasks.some((task) => task.agentId === targetId && unfinished(task)); + // 同一 Agent 运行中的任务未达并发上限时直接启动,达到上限时排队、任一结束后按提交顺序启动; + // 只有排队已满才禁止提交(#2772、#2800)。 + const reportedConcurrencyMax = snapshot?.capabilities?.maxConcurrentTasksLimit; + const concurrencyMax = Number.isInteger(reportedConcurrencyMax) && (reportedConcurrencyMax as number) > 0 + ? reportedConcurrencyMax as number : DEFAULT_MAX_CONCURRENT_TASKS_LIMIT; + const concurrencyLimit = (id: string) => normalizeConcurrency(snapshot?.config.agents.find((agent) => agent.id === id)?.maxConcurrentTasks); + const targetQueue = agentQueueState(tasks, targetId, snapshot?.capabilities?.maxQueuedPerAgent, concurrencyLimit(targetId)); const targetCwd = continuation?.cwd ?? cwd; + // 并发任务共用工作目录时只提示、不阻止(#2800)。 + const trimmedCwd = (value: string) => value.trim().replace(/[\\/]+$/, ""); + const sameCwdRunning = targetCwd.trim() ? tasks.filter((task) => task.status === "running" && trimmedCwd(task.cwd) === trimmedCwd(targetCwd)).length : 0; const formDisabledReason = !snapshot ? "正在读取协作配置。" : !snapshot.config.enabled ? "请先启用外部子 Agent 并保存设置。" : !targetAgent ? "请先启用至少一个 CLI 并保存配置。" : targetUnavailable ? "目标 CLI 的可执行文件不可用,请检查路径并保存。" - : targetBusy ? "此 Agent 已有排队或执行中的任务,请等待结束后再提交。" + : targetQueue.full ? tf("此 Agent 的排队任务已达上限({0} 个),请等待前面的任务结束或取消部分排队任务。", [targetQueue.limit]) : continuation && (continuation.status !== "completed" || !continuation.sessionId) ? "该任务目前无法续聊,请创建新任务。" : ""; const agentName = (id: string) => snapshot?.config.agents.find((agent) => agent.id === id)?.name ?? id; + // 任务列表顶部按 Agent 汇总并发与排队情况(#2800):只列出有未结束任务的 Agent。 + const agentLoads = (snapshot?.config.agents ?? []) + .map((agent) => ({ agent, state: agentQueueState(tasks, agent.id, snapshot?.capabilities?.maxQueuedPerAgent, concurrencyLimit(agent.id)) })) + .filter(({ state }) => state.active > 0); const runtimeRefreshing = busy === "connect_codex" || busy === "refresh_codex"; const runtimeSucceeded = runtimeReady(runtime); const connectCodex = () => { @@ -295,7 +313,7 @@ export function AgentCollaborationPage({ defaultCwd = "" }: { defaultCwd?: strin setPrompt(""); setContinueTaskId(""); setCwd(targetCwd); - setNotice("任务已提交,可在下方查看执行状态。"); + setNotice(targetQueue.willQueue ? t("任务已提交并进入排队,前面的任务结束后自动启动。") : "任务已提交,可在下方查看执行状态。"); }); }; const continueTask = (task: TaskRecord) => { @@ -381,6 +399,7 @@ export function AgentCollaborationPage({ defaultCwd = "" }: { defaultCwd?: strin +
{t("同时执行任务数")} editAgent(agent.id, { maxConcurrentTasks: normalizeConcurrency(Number(next), concurrencyMax) })} options={Array.from({ length: concurrencyMax }, (_, index) => ({ value: String(index + 1), label: index === 0 ? t("1(逐个执行,其余排队)") : String(index + 1) }))} value={String(agent.maxConcurrentTasks)} />{t("同一 Agent 最多同时运行的任务数。超出时排队,任一任务结束后按提交顺序启动下一个;并发任务请使用不同的工作目录,避免互相修改相同文件。")}
{!savedPath ? "路径尚未保存" : availability?.recovered ? "已保存的路径已失效(桌面版更新后版本目录会变化),本次自动使用当前 CLI;清空路径保存可一直自动查找" : available === true ? "已找到 CLI 文件" : availability?.configuredMissing ? "已保存的路径不存在;清空路径保存可自动查找,或重新填写" : available === false ? "未找到 CLI 文件" : agent.executable.trim() ? "已配置,执行任务时验证" : "留空自动查找已安装的 CLI"}{availability?.path && {availability.path}} {agent.id === "deepseek" && <> @@ -439,12 +458,12 @@ export function AgentCollaborationPage({ defaultCwd = "" }: { defaultCwd?: strin