From edd31d8cdcc385445b6a2e2ed9bc7e72ee4ba5fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B1=86=E5=8C=85?= Date: Thu, 18 Jun 2026 08:56:17 +0800 Subject: [PATCH 01/16] fix: enable native fast service tier for API-key auth --- apps/codex-plus-manager/src/App.tsx | 4 +- assets/inject/renderer-inject.js | 13 +- crates/codex-plus-core/src/launcher.rs | 46 +++- crates/codex-plus-core/src/lib.rs | 1 + .../src/service_tier_preload.rs | 235 ++++++++++++++++++ crates/codex-plus-core/src/settings.rs | 5 +- crates/codex-plus-core/tests/bridge_routes.rs | 2 +- crates/codex-plus-core/tests/cdp_bridge.rs | 6 +- crates/codex-plus-core/tests/launcher.rs | 10 +- 9 files changed, 295 insertions(+), 27 deletions(-) create mode 100644 crates/codex-plus-core/src/service_tier_preload.rs diff --git a/apps/codex-plus-manager/src/App.tsx b/apps/codex-plus-manager/src/App.tsx index 4b250148..6054d703 100644 --- a/apps/codex-plus-manager/src/App.tsx +++ b/apps/codex-plus-manager/src/App.tsx @@ -625,7 +625,7 @@ const defaultSettings: BackendSettings = { zedRemoteSyncToZedSettings: false, codexAppUpstreamWorktreeCreate: true, codexAppNativeMenuPlacement: true, - codexAppServiceTierControls: false, + codexAppServiceTierControls: true, codexAppImageOverlayEnabled: false, codexAppImageOverlayPath: "", codexAppImageOverlayOpacity: 35, @@ -2593,7 +2593,7 @@ function EnhanceScreen({ setEnhanceFlag("codexAppPluginEntryUnlock", value)} /> setEnhanceFlag("codexAppForcePluginInstall", value)} /> setEnhanceFlag("codexAppModelWhitelistUnlock", value)} /> - setEnhanceFlag("codexAppServiceTierControls", value)} /> + {}} /> setEnhanceFlag("codexAppSessionDelete", value)} /> setEnhanceFlag("codexAppMarkdownExport", value)} /> setEnhanceFlag("codexAppPasteFix", value)} /> diff --git a/assets/inject/renderer-inject.js b/assets/inject/renderer-inject.js index 49c5a98c..8a4a86d0 100644 --- a/assets/inject/renderer-inject.js +++ b/assets/inject/renderer-inject.js @@ -956,7 +956,7 @@ } function defaultCodexPlusSettings() { - return { pluginEntryUnlock: true, pluginMarketplaceUnlock: true, forcePluginInstall: true, modelWhitelistUnlock: true, sessionDelete: true, markdownExport: true, pasteFix: false, projectMove: true, conversationTimeline: true, threadIdBadge: false, conversationView: false, conversationViewMaxWidth: conversationViewDefaultWidth, threadScrollRestore: true, zedRemoteOpen: true, upstreamWorktreeCreate: true, nativeMenuPlacement: true, serviceTierControls: false }; + return { pluginEntryUnlock: true, pluginMarketplaceUnlock: true, forcePluginInstall: true, modelWhitelistUnlock: true, sessionDelete: true, markdownExport: true, pasteFix: false, projectMove: true, conversationTimeline: true, threadIdBadge: false, conversationView: false, conversationViewMaxWidth: conversationViewDefaultWidth, threadScrollRestore: true, zedRemoteOpen: true, upstreamWorktreeCreate: true, nativeMenuPlacement: true, serviceTierControls: true }; } const codexPlusBackendSettingMap = { @@ -1018,6 +1018,7 @@ settings.pluginMarketplaceUnlock = false; settings.forcePluginInstall = false; } + settings.serviceTierControls = true; return settings; } catch { const settings = { ...defaultCodexPlusSettings(), ...backendCodexPlusSettings() }; @@ -1026,6 +1027,7 @@ settings.pluginMarketplaceUnlock = false; settings.forcePluginInstall = false; } + settings.serviceTierControls = true; return settings; } } @@ -1775,10 +1777,7 @@ function applyCodexServiceTierRequestOverride(method, params, threadIdHint = "") { const override = codexServiceTierOverrideForRequest(method, params, threadIdHint); if (!override) return params; - const nextParams = { ...(params || {}), serviceTier: override.serviceTier }; - if (Object.prototype.hasOwnProperty.call(nextParams, "service_tier") || override.fastBlocked) { - nextParams.service_tier = override.serviceTier; - } + const nextParams = { ...(params || {}), serviceTier: override.serviceTier, service_tier: override.serviceTier }; sendCodexPlusDiagnostic("service_tier_request_override_applied", { method, threadId: override.threadId || "", @@ -2206,8 +2205,8 @@
-
Fast 按钮
显示服务模式切换按钮;Fast 仅支持 ${codexServiceTierFastModelListLabel()},其他模型按 Standard 发送。
- +
系统 Fast 开关
是否开启系统 Fast 开关:已默认开启,API Key 登录复用 Codex 原生速度选项与标识;具体 Fast / Standard 在 Codex 界面选择,Fast 仅支持 ${codexServiceTierFastModelListLabel()}。
+
服务模式
继承使用 config.toml 的 service tier;全局模式覆盖全部 thread;自定义允许按 thread 覆盖。
diff --git a/crates/codex-plus-core/src/launcher.rs b/crates/codex-plus-core/src/launcher.rs index e4618115..cb5b3367 100644 --- a/crates/codex-plus-core/src/launcher.rs +++ b/crates/codex-plus-core/src/launcher.rs @@ -1,3 +1,4 @@ +use std::env; use std::net::SocketAddr; use std::path::{Path, PathBuf}; use std::process::Stdio; @@ -153,7 +154,7 @@ pub trait LaunchHooks: Send + Sync { &self, app_dir: &Path, debug_port: u16, - extra_args: &[String], + settings: &BackendSettings, ) -> anyhow::Result; async fn bridge_context( &self, @@ -290,9 +291,7 @@ where helper_started = true; } - let launch = hooks - .launch_codex(&app_dir, debug_port, &settings.codex_extra_args) - .await?; + let launch = hooks.launch_codex(&app_dir, debug_port, &settings).await?; launched = Some(launch.clone()); keep_launched_on_error = true; if settings.computer_use_guard_enabled { @@ -582,8 +581,9 @@ impl LaunchHooks for DefaultLaunchHooks { &self, app_dir: &Path, debug_port: u16, - extra_args: &[String], + settings: &BackendSettings, ) -> anyhow::Result { + let extra_args = &settings.codex_extra_args; if cfg!(windows) { if let Some(activation) = build_packaged_activation(app_dir, debug_port, extra_args) { let CodexLaunch::PackagedActivation { @@ -616,20 +616,46 @@ impl LaunchHooks for DefaultLaunchHooks { } else { MacosCleanupPolicy::QuitIfNotPreviouslyRunning }; - let command = build_macos_open_command(app_dir, debug_port, extra_args); + let command = build_codex_command(app_dir, debug_port, extra_args); let executable = command .first() - .ok_or_else(|| anyhow::anyhow!("macOS open command is empty"))?; - let child = Command::new(executable) + .ok_or_else(|| anyhow::anyhow!("macOS Codex command is empty"))?; + let mut child_command = Command::new(executable); + child_command .args(&command[1..]) .stdout(Stdio::null()) - .stderr(Stdio::null()) + .stderr(Stdio::null()); + if settings.enhancements_enabled { + let preload_path = crate::service_tier_preload::ensure_service_tier_preload() + .context("failed to prepare service tier preload")?; + let node_options = + crate::service_tier_preload::node_options_with_service_tier_preload( + env::var("NODE_OPTIONS").ok().as_deref(), + &preload_path.to_string_lossy(), + ); + child_command.env("NODE_OPTIONS", node_options.clone()); + let _ = crate::diagnostic_log::append_diagnostic_log( + "launcher.service_tier_preload_enabled", + serde_json::json!({ + "preload_path": preload_path.to_string_lossy(), + "node_options": node_options, + }), + ); + } else { + let _ = crate::diagnostic_log::append_diagnostic_log( + "launcher.service_tier_preload_disabled", + serde_json::json!({ + "enhancements_enabled": settings.enhancements_enabled, + }), + ); + } + let child = child_command .spawn() .context("failed to launch macOS Codex app")?; *self.child.lock().await = Some(child); return Ok(CodexLaunch::Process { command, - wait_strategy: ProcessWaitStrategy::ExternalWaitCommand, + wait_strategy: ProcessWaitStrategy::TrackedChild, macos_cleanup_policy: Some(cleanup_policy), }); } diff --git a/crates/codex-plus-core/src/lib.rs b/crates/codex-plus-core/src/lib.rs index f0341819..762310e8 100644 --- a/crates/codex-plus-core/src/lib.rs +++ b/crates/codex-plus-core/src/lib.rs @@ -25,6 +25,7 @@ pub mod relay_rotation; pub mod relay_switch; pub mod routes; pub mod script_market; +pub mod service_tier_preload; pub mod settings; pub mod status; pub mod update; diff --git a/crates/codex-plus-core/src/service_tier_preload.rs b/crates/codex-plus-core/src/service_tier_preload.rs new file mode 100644 index 00000000..58f6fea1 --- /dev/null +++ b/crates/codex-plus-core/src/service_tier_preload.rs @@ -0,0 +1,235 @@ +use std::fs; +use std::path::PathBuf; + +use anyhow::Context; + +const PRELOAD_FILE: &str = "service-tier-preload.js"; + +pub fn ensure_service_tier_preload() -> anyhow::Result { + let dir = crate::paths::default_app_state_dir().join("preload"); + fs::create_dir_all(&dir).with_context(|| { + format!( + "failed to create service tier preload directory {}", + dir.display() + ) + })?; + let path = dir.join(PRELOAD_FILE); + fs::write(&path, service_tier_preload_script()).with_context(|| { + format!( + "failed to write service tier preload script {}", + path.display() + ) + })?; + Ok(path) +} + +pub fn node_options_with_service_tier_preload( + existing: Option<&str>, + preload_path: &str, +) -> String { + let require_arg = format!("--require={preload_path}"); + match existing.map(str::trim).filter(|value| !value.is_empty()) { + Some(existing) if existing.contains(&require_arg) => existing.to_string(), + Some(existing) => format!("{require_arg} {existing}"), + None => require_arg, + } +} + +pub fn service_tier_preload_script() -> &'static str { + r#""use strict"; + +const fs = require("fs"); +const path = require("path"); +const Module = require("module"); + +const PATCH_MARK = Symbol.for("codex-plus.service-tier-protocol-handle-patched"); +const PATCH_VERSION = "protocol-handle-2"; +const SERVICE_TIER_SETTINGS_ASSET_RE = /^use-service-tier-settings-.*\.js$/; +const READ_SERVICE_TIER_ASSET_RE = /^read-service-tier-for-request-.*\.js$/; +const LOG_PATH = path.join(process.env.HOME || process.cwd(), ".codex-session-delete", "codex-plus.log"); +const SETTINGS_PATH = path.join(process.env.HOME || process.cwd(), ".codex-session-delete", "settings.json"); + +function log(event, detail) { + try { + fs.mkdirSync(path.dirname(LOG_PATH), { recursive: true }); + fs.appendFileSync(LOG_PATH, JSON.stringify({ + timestamp_ms: Date.now(), + pid: process.pid, + event, + detail: detail || {}, + }) + "\n"); + } catch {} +} + +function patchServiceTierSettingsAsset(source) { + let patched = source; + patched = replaceOnce( + patched, + "s=o?.authMethod===`chatgpt`", + "s=o?.authMethod===`chatgpt`||o?.authMethod===`apikey`", + "service tier settings auth gate" + ); + patched = replaceOnce( + patched, + "s&&!f&&u!=null", + "s&&!f", + "service tier settings API key config requirement" + ); + return patched; +} + +function patchReadServiceTierAsset(source) { + let patched = source; + patched = replaceOnce( + patched, + "return n===`chatgpt`?(await e.query.fetch(c,{authMethod:n,hostId:t})).requirements?.featureRequirements?.fast_mode!==!1:!1", + "return n===`chatgpt`?(await e.query.fetch(c,{authMethod:n,hostId:t})).requirements?.featureRequirements?.fast_mode!==!1:n===`apikey`", + "read service tier auth gate" + ); + patched = replaceOnce( + patched, + "return d.service_tier==null?t(await m(o,c??d.model),d.service_tier,s):t(null,d.service_tier,s)", + "return d.service_tier==null?t(await m(o,c??d.model),d.service_tier,s):t(await m(o,c??d.model),d.service_tier,s)", + "read service tier explicit config model lookup" + ); + return patched; +} + +function replaceOnce(source, from, to, label) { + if (source.includes(to)) return source; + if (!source.includes(from)) throw new Error(`${label} pattern not found`); + return source.replace(from, to); +} + +function appProtocolAssetName(url) { + if (typeof url !== "string") return ""; + try { + const parsed = new URL(url); + if (parsed.protocol !== "app:" || parsed.host !== "-") return ""; + const segments = decodeURIComponent(parsed.pathname).split("/").filter(Boolean); + return segments.length >= 2 && segments[0] === "assets" ? segments[segments.length - 1] : ""; + } catch { + return ""; + } +} + +function serviceTierControlsEnabled() { + try { + const settings = JSON.parse(fs.readFileSync(SETTINGS_PATH, "utf8")); + return settings && settings.enhancementsEnabled !== false; + } catch { + return true; + } +} + +function discoverPatchedAssets() { + if (!serviceTierControlsEnabled()) { + log("service_tier_preload_disabled_by_settings", {}); + return new Map(); + } + const assetsDir = path.join(process.resourcesPath, "app.asar", "webview", "assets"); + const assets = new Map(); + for (const name of fs.readdirSync(assetsDir)) { + const filePath = path.join(assetsDir, name); + if (SERVICE_TIER_SETTINGS_ASSET_RE.test(name)) { + assets.set(name, { + kind: "native-service-tier-settings", + patched: patchServiceTierSettingsAsset(fs.readFileSync(filePath, "utf8")), + }); + } else if (READ_SERVICE_TIER_ASSET_RE.test(name)) { + assets.set(name, { + kind: "native-read-service-tier", + patched: patchReadServiceTierAsset(fs.readFileSync(filePath, "utf8")), + }); + } + } + if (assets.size === 0) throw new Error("target native speed UI assets were not found"); + return assets; +} + +function installProtocolHandlePatch(electron) { + const protocol = electron && electron.protocol; + if (!protocol || typeof protocol.handle !== "function") { + log("service_tier_preload_protocol_unavailable", {}); + return; + } + if (protocol.handle[PATCH_MARK] === PATCH_VERSION) return; + + const patchedAssets = discoverPatchedAssets(); + if (patchedAssets.size === 0) return; + const originalHandle = protocol.handle; + const wrappedHandle = function codexPlusServiceTierProtocolHandle(scheme, handler) { + if (String(scheme) !== "app" || typeof handler !== "function") { + return originalHandle.apply(this, arguments); + } + const wrappedHandler = async function codexPlusServiceTierAppProtocolHandler(request) { + const asset = patchedAssets.get(appProtocolAssetName(request && request.url)); + if (!asset) return handler.call(this, request); + log("service_tier_preload_asset_patched", { kind: asset.kind, url: request && request.url, version: PATCH_VERSION }); + return new Response(Buffer.from(asset.patched, "utf8"), { + headers: { + "Content-Length": String(Buffer.byteLength(asset.patched, "utf8")), + "Content-Type": "text/javascript; charset=utf-8", + "X-Codex-Plus-Patch": PATCH_VERSION, + }, + }); + }; + return originalHandle.call(this, scheme, wrappedHandler); + }; + + Object.defineProperty(wrappedHandle, PATCH_MARK, { + configurable: false, + enumerable: false, + value: PATCH_VERSION, + }); + protocol.handle = wrappedHandle; + log("service_tier_preload_protocol_patch_installed", { + assets: Array.from(patchedAssets.keys()), + version: PATCH_VERSION, + }); +} + +const originalLoad = Module._load; +Module._load = function codexPlusServiceTierModuleLoad(request, parent, isMain) { + const result = originalLoad.apply(this, arguments); + if (request === "electron") { + try { + installProtocolHandlePatch(result); + } catch (error) { + log("service_tier_preload_protocol_patch_failed", { message: String(error) }); + } + } + return result; +}; + +log("service_tier_preload_loaded", { version: PATCH_VERSION }); +"# +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn node_options_prepends_preload() { + assert_eq!( + node_options_with_service_tier_preload(Some("--trace-warnings"), "/tmp/preload.js"), + "--require=/tmp/preload.js --trace-warnings" + ); + } + + #[test] + fn preload_script_wraps_electron_module_load_and_app_protocol() { + let script = service_tier_preload_script(); + + assert!(script.contains("Module._load")); + assert!(script.contains("protocol.handle")); + assert!(script.contains("serviceTierControlsEnabled")); + assert!(script.contains("settings.enhancementsEnabled !== false")); + assert!(script.contains("service_tier_preload_disabled_by_settings")); + assert!(script.contains("use-service-tier-settings-")); + assert!(script.contains("read-service-tier-for-request-")); + assert!(script.contains("s=o?.authMethod===`chatgpt`||o?.authMethod===`apikey`")); + assert!(script.contains("n===`apikey`")); + } +} diff --git a/crates/codex-plus-core/src/settings.rs b/crates/codex-plus-core/src/settings.rs index feddeb9d..607c6096 100644 --- a/crates/codex-plus-core/src/settings.rs +++ b/crates/codex-plus-core/src/settings.rs @@ -228,7 +228,7 @@ pub struct BackendSettings { pub codex_app_upstream_worktree_create: bool, #[serde(rename = "codexAppNativeMenuPlacement", default = "default_true")] pub codex_app_native_menu_placement: bool, - #[serde(rename = "codexAppServiceTierControls", default)] + #[serde(rename = "codexAppServiceTierControls", default = "default_true")] pub codex_app_service_tier_controls: bool, #[serde(rename = "codexAppImageOverlayEnabled", default)] pub codex_app_image_overlay_enabled: bool, @@ -318,7 +318,7 @@ impl Default for BackendSettings { zed_remote_sync_to_zed_settings: false, codex_app_upstream_worktree_create: true, codex_app_native_menu_placement: true, - codex_app_service_tier_controls: false, + codex_app_service_tier_controls: true, codex_app_image_overlay_enabled: false, codex_app_image_overlay_path: String::new(), codex_app_image_overlay_opacity: default_image_overlay_opacity(), @@ -923,6 +923,7 @@ fn normalize_settings_config_sections(mut settings: BackendSettings) -> BackendS } settings.codex_app_image_overlay_opacity = clamp_image_overlay_opacity(settings.codex_app_image_overlay_opacity); + settings.codex_app_service_tier_controls = true; settings } diff --git a/crates/codex-plus-core/tests/bridge_routes.rs b/crates/codex-plus-core/tests/bridge_routes.rs index 7f8c42e7..7d320f25 100644 --- a/crates/codex-plus-core/tests/bridge_routes.rs +++ b/crates/codex-plus-core/tests/bridge_routes.rs @@ -1342,7 +1342,7 @@ impl LaunchHooks for ContextHooks { &self, _app_dir: &std::path::Path, _debug_port: u16, - _extra_args: &[String], + _settings: &BackendSettings, ) -> anyhow::Result { Ok(CodexLaunch::Process { command: vec!["codex".to_string()], diff --git a/crates/codex-plus-core/tests/cdp_bridge.rs b/crates/codex-plus-core/tests/cdp_bridge.rs index cae9f900..1a3cc6e1 100644 --- a/crates/codex-plus-core/tests/cdp_bridge.rs +++ b/crates/codex-plus-core/tests/cdp_bridge.rs @@ -466,8 +466,8 @@ fn injection_script_exposes_fast_service_tier_control() { assert!(script.contains("codexServiceTierMaybeLoadModelCatalog")); assert!(script.contains("fastBlocked")); assert!(script.contains("data-tier=\"unsupported\"")); - assert!(script.contains("nextParams.service_tier = override.serviceTier")); - assert!(script.contains("serviceTierControls: false")); + assert!(script.contains("service_tier: override.serviceTier")); + assert!(script.contains("serviceTierControls: true")); assert!(script.contains("data-codex-plus-setting=\"serviceTierControls\"")); assert!(script.contains("data-codex-service-tier-controls")); assert!(script.contains("removeCodexServiceTierBadges")); @@ -548,6 +548,7 @@ fn injection_script_applies_fast_service_tier_contract() { ); assert_eq!(cases["turnWithoutModel"]["serviceTier"], "priority"); + assert_eq!(cases["turnWithoutModel"]["service_tier"], "priority"); assert_eq!(cases["turnWithoutModelDiagnosticModel"], "gpt-5.4"); assert_eq!( @@ -560,6 +561,7 @@ fn injection_script_applies_fast_service_tier_contract() { ); assert_eq!(cases["startConversation"]["serviceTier"], "priority"); + assert_eq!(cases["startConversation"]["service_tier"], "priority"); } fn run_service_tier_contract_harness() -> serde_json::Value { diff --git a/crates/codex-plus-core/tests/launcher.rs b/crates/codex-plus-core/tests/launcher.rs index ac253966..c9cb636c 100644 --- a/crates/codex-plus-core/tests/launcher.rs +++ b/crates/codex-plus-core/tests/launcher.rs @@ -207,10 +207,13 @@ fn launcher_builds_debug_arguments_and_commands() { } #[test] -fn launcher_does_not_override_codex_app_environment() { +fn launcher_uses_gated_startup_preload_without_proxy_environment_override() { let source = include_str!("../src/launcher.rs"); - assert!(!source.contains(".envs(codex_process_environment())")); + assert!(source.contains("NODE_OPTIONS")); + assert!(source.contains("ensure_service_tier_preload")); + assert!(source.contains("settings.enhancements_enabled")); + assert!(source.contains("launcher.service_tier_preload_disabled")); assert!(!source.contains("activate_packaged_app_with_environment")); assert!(!source.contains("with_temporary_proxy_environment")); } @@ -1248,9 +1251,10 @@ impl LaunchHooks for FakeHooks { &self, app_dir: &Path, debug_port: u16, - extra_args: &[String], + settings: &BackendSettings, ) -> anyhow::Result { assert!(app_dir.ends_with("Codex.app")); + let extra_args = &settings.codex_extra_args; if extra_args.is_empty() { self.event(format!("launch:{debug_port}")); } else { From 37c96a82aced07ea52c8708e5912c475a2df99dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B1=86=E5=8C=85?= Date: Thu, 18 Jun 2026 12:55:11 +0800 Subject: [PATCH 02/16] fix: align launcher launch_codex signature --- apps/codex-plus-launcher/src/main.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/codex-plus-launcher/src/main.rs b/apps/codex-plus-launcher/src/main.rs index 961b2c79..9c6fea83 100644 --- a/apps/codex-plus-launcher/src/main.rs +++ b/apps/codex-plus-launcher/src/main.rs @@ -138,7 +138,7 @@ async fn activate_existing_codex_app(options: &LaunchOptions) -> anyhow::Result< let settings = hooks.load_settings().await?; let app_dir = hooks.resolve_app_dir(options.app_dir.as_deref(), &settings)?; let launch_result = hooks - .launch_codex(&app_dir, options.debug_port, &settings.codex_extra_args) + .launch_codex(&app_dir, options.debug_port, &settings) .await; if settings.enhancements_enabled { hooks.start_helper(options.helper_port).await?; @@ -314,10 +314,10 @@ impl LaunchHooks for LauncherHooks { &self, app_dir: &Path, debug_port: u16, - extra_args: &[String], + settings: &codex_plus_core::settings::BackendSettings, ) -> anyhow::Result { self.core - .launch_codex(app_dir, debug_port, extra_args) + .launch_codex(app_dir, debug_port, settings) .await } From a55c4fcaf8e07e346bd673051985329fc5d67523 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B1=86=E5=8C=85?= Date: Thu, 18 Jun 2026 13:10:15 +0800 Subject: [PATCH 03/16] fix: align CI release build prerequisites --- .github/workflows/pr-build.yml | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pr-build.yml b/.github/workflows/pr-build.yml index ce3818db..ebc0f267 100644 --- a/.github/workflows/pr-build.yml +++ b/.github/workflows/pr-build.yml @@ -38,7 +38,10 @@ jobs: - name: Build frontend working-directory: apps/codex-plus-manager - run: npm run vite:build + shell: bash + run: | + ./node_modules/.bin/vite build + test -d dist - name: Rust tests run: cargo test --workspace @@ -116,7 +119,10 @@ jobs: - name: Build frontend working-directory: apps/codex-plus-manager - run: npm run vite:build + shell: bash + run: | + ./node_modules/.bin/vite build + test -d dist - name: Build release binaries run: cargo build --release --target ${{ matrix.target }} From 1f41409dad575776444baafae16463bf866373b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B1=86=E5=8C=85?= Date: Thu, 18 Jun 2026 13:22:45 +0800 Subject: [PATCH 04/16] fix: restore relay profile snapshot guard --- apps/codex-plus-launcher/src/main.rs | 2 +- apps/codex-plus-manager/src/App.tsx | 56 ++++++++++++++++++++++------ 2 files changed, 45 insertions(+), 13 deletions(-) diff --git a/apps/codex-plus-launcher/src/main.rs b/apps/codex-plus-launcher/src/main.rs index 9c6fea83..5a0ce339 100644 --- a/apps/codex-plus-launcher/src/main.rs +++ b/apps/codex-plus-launcher/src/main.rs @@ -144,7 +144,7 @@ async fn activate_existing_codex_app(options: &LaunchOptions) -> anyhow::Result< hooks.start_helper(options.helper_port).await?; } let process_ids = codex_plus_core::watcher::find_codex_processes(); - let mut activated = false; + let activated = false; #[cfg(windows)] { for process_id in &process_ids { diff --git a/apps/codex-plus-manager/src/App.tsx b/apps/codex-plus-manager/src/App.tsx index 6054d703..cb562d88 100644 --- a/apps/codex-plus-manager/src/App.tsx +++ b/apps/codex-plus-manager/src/App.tsx @@ -1434,6 +1434,16 @@ export function App() { targetRelayName: targetBeforeSnapshot.name, targetRelayMode: targetBeforeSnapshot.relayMode, }); + const switchSettingsWithSnapshot = await snapshotActiveRelayFilesBeforeSwitch(switchSettings, previousActiveRelayId); + if (!switchSettingsWithSnapshot) { + logDiagnostic("switchRelayProfile.snapshot_failed", { + currentRelayId: previousActiveRelayId, + targetRelayId: switchSettings.activeRelayId, + }); + return; + } + switchSettings = switchSettingsWithSnapshot; + const selectedBeforeSave = activeRelayProfile(switchSettings); const validationError = relayProfileSwitchValidation(selectedBeforeSave); if (validationError) { @@ -1445,8 +1455,7 @@ export function App() { showNotice("供应商配置可能不正确", validationError, "failed"); return; } - switchSettings = await snapshotActiveRelayFilesBeforeSwitch(switchSettings, previousActiveRelayId); - const selectedAfterSave = activeRelayProfile(switchSettings); + const selectedAfterSave = selectedBeforeSave; const command = relayProfileSwitchCommand(selectedAfterSave); logDiagnostic("switchRelayProfile.apply_start", { @@ -1508,21 +1517,38 @@ export function App() { const snapshotActiveRelayFilesBeforeSwitch = async ( next: BackendSettings, previousActiveRelayId: string, - ): Promise => { - const profileId = previousActiveRelayId.trim(); - if (!profileId) return next; + ): Promise => { + const current = activeRelayProfile({ ...settingsForm, activeRelayId: previousActiveRelayId }); + const selected = activeRelayProfile(next); + if (current.id === selected.id) return next; + + logDiagnostic("snapshotActiveRelayFilesBeforeSwitch.start", { + currentRelayId: current.id, + currentRelayName: current.name, + selectedRelayId: selected.id, + selectedRelayName: selected.name, + }); const result = await run(() => call("backfill_relay_profile_from_live", { - request: { settings: next, profileId }, + request: { settings: next, profileId: current.id }, }), ); - if (!result) return next; - const normalized = normalizeSettings(result.settings); - if (!isSuccessStatus(result.status)) { - showNotice("供应商切换", result.message, result.status); - return next; + if (!result || !isSuccessStatus(result.status)) { + logDiagnostic("snapshotActiveRelayFilesBeforeSwitch.failed", { + currentRelayId: current.id, + selectedRelayId: selected.id, + status: result?.status, + message: result?.message, + }); + showNotice("供应商切换", result?.message ?? "读取当前配置文件失败,已停止切换以避免覆盖用户改动。", result?.status ?? "failed"); + return null; } - return normalized; + + logDiagnostic("snapshotActiveRelayFilesBeforeSwitch.ok", { + currentRelayId: current.id, + selectedRelayId: selected.id, + }); + return syncLegacyRelayFields(normalizeSettings(result.settings)); }; const copyText = async (text: string, message: string) => { @@ -5682,6 +5708,12 @@ function relayProfileModeSwitchedText(profile: RelayProfile): string { return "已按此供应商切回官方登录;页面增强已设为兼容增强。"; } +function relayProfileSwitchCommand(profile: RelayProfile): "clear_relay_injection" | "apply_relay_injection" | "apply_pure_api_injection" { + if (profile.relayMode === "pureApi") return "apply_pure_api_injection"; + if (profile.relayMode === "official" && !profile.officialMixApiKey) return "clear_relay_injection"; + return "apply_relay_injection"; +} + function withGeneratedRelayFiles(profile: RelayProfile): RelayProfile { if (isAggregateRelayProfile(profile)) { return { ...profile, configContents: "", authContents: "", aggregate: normalizeAggregateConfig(profile.aggregate, []) }; From 4aa7bf877177596c8741a5c97006771d78e98d62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B1=86=E5=8C=85?= Date: Thu, 18 Jun 2026 13:35:22 +0800 Subject: [PATCH 05/16] fix: keep launcher activation flag mutable on Windows --- apps/codex-plus-launcher/src/main.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/codex-plus-launcher/src/main.rs b/apps/codex-plus-launcher/src/main.rs index 5a0ce339..9c6fea83 100644 --- a/apps/codex-plus-launcher/src/main.rs +++ b/apps/codex-plus-launcher/src/main.rs @@ -144,7 +144,7 @@ async fn activate_existing_codex_app(options: &LaunchOptions) -> anyhow::Result< hooks.start_helper(options.helper_port).await?; } let process_ids = codex_plus_core::watcher::find_codex_processes(); - let activated = false; + let mut activated = false; #[cfg(windows)] { for process_id in &process_ids { From e24a57c8f469babf8c8b44384059413245aea581 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B1=86=E5=8C=85?= Date: Mon, 22 Jun 2026 11:00:23 +0800 Subject: [PATCH 06/16] fix: harden native fast service tier patch --- apps/codex-plus-launcher/src/main.rs | 7 +- crates/codex-plus-core/src/launcher.rs | 3 +- .../src/service_tier_preload.rs | 158 +++++++++++++----- crates/codex-plus-core/src/settings.rs | 1 - 4 files changed, 118 insertions(+), 51 deletions(-) diff --git a/apps/codex-plus-launcher/src/main.rs b/apps/codex-plus-launcher/src/main.rs index 9c6fea83..1674b87b 100644 --- a/apps/codex-plus-launcher/src/main.rs +++ b/apps/codex-plus-launcher/src/main.rs @@ -144,6 +144,9 @@ async fn activate_existing_codex_app(options: &LaunchOptions) -> anyhow::Result< hooks.start_helper(options.helper_port).await?; } let process_ids = codex_plus_core::watcher::find_codex_processes(); + #[cfg(not(windows))] + let activated = false; + #[cfg(windows)] let mut activated = false; #[cfg(windows)] { @@ -316,9 +319,7 @@ impl LaunchHooks for LauncherHooks { debug_port: u16, settings: &codex_plus_core::settings::BackendSettings, ) -> anyhow::Result { - self.core - .launch_codex(app_dir, debug_port, settings) - .await + self.core.launch_codex(app_dir, debug_port, settings).await } async fn bridge_context( diff --git a/crates/codex-plus-core/src/launcher.rs b/crates/codex-plus-core/src/launcher.rs index cb5b3367..b241b97b 100644 --- a/crates/codex-plus-core/src/launcher.rs +++ b/crates/codex-plus-core/src/launcher.rs @@ -625,7 +625,7 @@ impl LaunchHooks for DefaultLaunchHooks { .args(&command[1..]) .stdout(Stdio::null()) .stderr(Stdio::null()); - if settings.enhancements_enabled { + if settings.enhancements_enabled && settings.codex_app_service_tier_controls { let preload_path = crate::service_tier_preload::ensure_service_tier_preload() .context("failed to prepare service tier preload")?; let node_options = @@ -646,6 +646,7 @@ impl LaunchHooks for DefaultLaunchHooks { "launcher.service_tier_preload_disabled", serde_json::json!({ "enhancements_enabled": settings.enhancements_enabled, + "service_tier_controls": settings.codex_app_service_tier_controls, }), ); } diff --git a/crates/codex-plus-core/src/service_tier_preload.rs b/crates/codex-plus-core/src/service_tier_preload.rs index 58f6fea1..467a704d 100644 --- a/crates/codex-plus-core/src/service_tier_preload.rs +++ b/crates/codex-plus-core/src/service_tier_preload.rs @@ -43,7 +43,7 @@ const path = require("path"); const Module = require("module"); const PATCH_MARK = Symbol.for("codex-plus.service-tier-protocol-handle-patched"); -const PATCH_VERSION = "protocol-handle-2"; +const PATCH_VERSION = "protocol-handle-3"; const SERVICE_TIER_SETTINGS_ASSET_RE = /^use-service-tier-settings-.*\.js$/; const READ_SERVICE_TIER_ASSET_RE = /^read-service-tier-for-request-.*\.js$/; const LOG_PATH = path.join(process.env.HOME || process.cwd(), ".codex-session-delete", "codex-plus.log"); @@ -63,16 +63,26 @@ function log(event, detail) { function patchServiceTierSettingsAsset(source) { let patched = source; - patched = replaceOnce( + patched = replaceFirstOf( patched, - "s=o?.authMethod===`chatgpt`", - "s=o?.authMethod===`chatgpt`||o?.authMethod===`apikey`", + [ + [ + "s=o?.authMethod===`chatgpt`", + "s=o?.authMethod===`chatgpt`||o?.authMethod===`apikey`", + ], + [ + "c=o?.authMethod===`chatgpt`", + "c=o?.authMethod===`chatgpt`||o?.authMethod===`apikey`", + ], + ], "service tier settings auth gate" ); - patched = replaceOnce( + patched = replaceFirstOf( patched, - "s&&!f&&u!=null", - "s&&!f", + [ + ["s&&!f&&u!=null", "s&&!f"], + ["c&&!p&&d!=null", "c&&!p"], + ], "service tier settings API key config requirement" ); return patched; @@ -80,16 +90,28 @@ function patchServiceTierSettingsAsset(source) { function patchReadServiceTierAsset(source) { let patched = source; - patched = replaceOnce( + patched = replaceFirstOf( patched, - "return n===`chatgpt`?(await e.query.fetch(c,{authMethod:n,hostId:t})).requirements?.featureRequirements?.fast_mode!==!1:!1", - "return n===`chatgpt`?(await e.query.fetch(c,{authMethod:n,hostId:t})).requirements?.featureRequirements?.fast_mode!==!1:n===`apikey`", + [ + [ + "return n===`chatgpt`?(await e.query.fetch(c,{authMethod:n,hostId:t})).requirements?.featureRequirements?.fast_mode!==!1:!1", + "return n===`chatgpt`?(await e.query.fetch(c,{authMethod:n,hostId:t})).requirements?.featureRequirements?.fast_mode!==!1:n===`apikey`", + ], + ], "read service tier auth gate" ); - patched = replaceOnce( + patched = replaceFirstOf( patched, - "return d.service_tier==null?t(await m(o,c??d.model),d.service_tier,s):t(null,d.service_tier,s)", - "return d.service_tier==null?t(await m(o,c??d.model),d.service_tier,s):t(await m(o,c??d.model),d.service_tier,s)", + [ + [ + "return d.service_tier==null?t(await m(o,c??d.model),d.service_tier,s):t(null,d.service_tier,s)", + "return d.service_tier==null?t(await m(o,c??d.model),d.service_tier,s):t(await m(o,c??d.model),d.service_tier,s)", + ], + [ + "return d.service_tier==null?i(await m(o,c??d.model),d.service_tier,s):i(null,d.service_tier,s)", + "return d.service_tier==null?i(await m(o,c??d.model),d.service_tier,s):i(await m(o,c??d.model),d.service_tier,s)", + ], + ], "read service tier explicit config model lookup" ); return patched; @@ -101,6 +123,36 @@ function replaceOnce(source, from, to, label) { return source.replace(from, to); } +function replaceFirstOf(source, replacements, label) { + for (const [from, to] of replacements) { + if (source.includes(from)) return source.replace(from, to); + } + for (const [, to] of replacements) { + if (source.includes(to)) return source; + } + throw new Error(`${label} pattern not found`); +} + +function tryPatchServiceTierAsset(kind, source, url) { + try { + return { + ok: true, + source: patchServiceTierAsset(kind, source), + }; + } catch (error) { + log("service_tier_preload_asset_patch_failed", { + kind, + url, + message: String(error), + version: PATCH_VERSION, + }); + return { + ok: false, + source, + }; + } +} + function appProtocolAssetName(url) { if (typeof url !== "string") return ""; try { @@ -122,29 +174,47 @@ function serviceTierControlsEnabled() { } } -function discoverPatchedAssets() { +function serviceTierAssetKindFromUrl(url) { + const name = appProtocolAssetName(url); + if (SERVICE_TIER_SETTINGS_ASSET_RE.test(name)) return "native-service-tier-settings"; + if (READ_SERVICE_TIER_ASSET_RE.test(name)) return "native-read-service-tier"; + return ""; +} + +function patchServiceTierAsset(kind, source) { + if (kind === "native-service-tier-settings") return patchServiceTierSettingsAsset(source); + if (kind === "native-read-service-tier") return patchReadServiceTierAsset(source); + return source; +} + +function responseHeadersWithPatchMark(response, source) { + const headers = new Headers(response && response.headers ? response.headers : undefined); + headers.set("Content-Length", String(Buffer.byteLength(source, "utf8"))); + headers.set("Content-Type", "text/javascript; charset=utf-8"); + headers.set("X-Codex-Plus-Patch", PATCH_VERSION); + return headers; +} + +async function patchedAssetResponse(kind, request, handler, self) { + const response = await handler.call(self, request); + const source = await response.text(); + const result = tryPatchServiceTierAsset(kind, source, request && request.url); + if (result.ok) { + log("service_tier_preload_asset_patched", { kind, url: request && request.url, version: PATCH_VERSION }); + } + return new Response(Buffer.from(result.source, "utf8"), { + status: response.status, + statusText: response.statusText, + headers: result.ok ? responseHeadersWithPatchMark(response, result.source) : response.headers, + }); +} + +function shouldInstallProtocolPatch() { if (!serviceTierControlsEnabled()) { log("service_tier_preload_disabled_by_settings", {}); - return new Map(); - } - const assetsDir = path.join(process.resourcesPath, "app.asar", "webview", "assets"); - const assets = new Map(); - for (const name of fs.readdirSync(assetsDir)) { - const filePath = path.join(assetsDir, name); - if (SERVICE_TIER_SETTINGS_ASSET_RE.test(name)) { - assets.set(name, { - kind: "native-service-tier-settings", - patched: patchServiceTierSettingsAsset(fs.readFileSync(filePath, "utf8")), - }); - } else if (READ_SERVICE_TIER_ASSET_RE.test(name)) { - assets.set(name, { - kind: "native-read-service-tier", - patched: patchReadServiceTierAsset(fs.readFileSync(filePath, "utf8")), - }); - } + return false; } - if (assets.size === 0) throw new Error("target native speed UI assets were not found"); - return assets; + return true; } function installProtocolHandlePatch(electron) { @@ -154,25 +224,17 @@ function installProtocolHandlePatch(electron) { return; } if (protocol.handle[PATCH_MARK] === PATCH_VERSION) return; + if (!shouldInstallProtocolPatch()) return; - const patchedAssets = discoverPatchedAssets(); - if (patchedAssets.size === 0) return; const originalHandle = protocol.handle; const wrappedHandle = function codexPlusServiceTierProtocolHandle(scheme, handler) { if (String(scheme) !== "app" || typeof handler !== "function") { return originalHandle.apply(this, arguments); } const wrappedHandler = async function codexPlusServiceTierAppProtocolHandler(request) { - const asset = patchedAssets.get(appProtocolAssetName(request && request.url)); - if (!asset) return handler.call(this, request); - log("service_tier_preload_asset_patched", { kind: asset.kind, url: request && request.url, version: PATCH_VERSION }); - return new Response(Buffer.from(asset.patched, "utf8"), { - headers: { - "Content-Length": String(Buffer.byteLength(asset.patched, "utf8")), - "Content-Type": "text/javascript; charset=utf-8", - "X-Codex-Plus-Patch": PATCH_VERSION, - }, - }); + const kind = serviceTierAssetKindFromUrl(request && request.url); + if (!kind) return handler.call(this, request); + return patchedAssetResponse(kind, request, handler, this); }; return originalHandle.call(this, scheme, wrappedHandler); }; @@ -184,7 +246,6 @@ function installProtocolHandlePatch(electron) { }); protocol.handle = wrappedHandle; log("service_tier_preload_protocol_patch_installed", { - assets: Array.from(patchedAssets.keys()), version: PATCH_VERSION, }); } @@ -227,6 +288,11 @@ mod tests { assert!(script.contains("serviceTierControlsEnabled")); assert!(script.contains("settings.enhancementsEnabled !== false")); assert!(script.contains("service_tier_preload_disabled_by_settings")); + assert!(script.contains("patchedAssetResponse")); + assert!(script.contains("tryPatchServiceTierAsset")); + assert!(script.contains("service_tier_preload_asset_patch_failed")); + assert!(script.contains("handler.call(self, request)")); + assert!(!script.contains("app.asar\", \"webview\", \"assets")); assert!(script.contains("use-service-tier-settings-")); assert!(script.contains("read-service-tier-for-request-")); assert!(script.contains("s=o?.authMethod===`chatgpt`||o?.authMethod===`apikey`")); diff --git a/crates/codex-plus-core/src/settings.rs b/crates/codex-plus-core/src/settings.rs index 607c6096..0beee6fd 100644 --- a/crates/codex-plus-core/src/settings.rs +++ b/crates/codex-plus-core/src/settings.rs @@ -923,7 +923,6 @@ fn normalize_settings_config_sections(mut settings: BackendSettings) -> BackendS } settings.codex_app_image_overlay_opacity = clamp_image_overlay_opacity(settings.codex_app_image_overlay_opacity); - settings.codex_app_service_tier_controls = true; settings } From c3fd11759243d0a5d130350667a386844a76e864 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B1=86=E5=8C=85?= Date: Mon, 22 Jun 2026 11:14:44 +0800 Subject: [PATCH 07/16] fix: avoid persisting aggregate relay runtime fields --- crates/codex-plus-core/src/relay_config.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/crates/codex-plus-core/src/relay_config.rs b/crates/codex-plus-core/src/relay_config.rs index 0c0b606a..902b4304 100644 --- a/crates/codex-plus-core/src/relay_config.rs +++ b/crates/codex-plus-core/src/relay_config.rs @@ -1880,6 +1880,15 @@ fn complete_relay_profile_config(profile: &RelayProfile) -> anyhow::Result anyhow::Result<()> { + if profile.relay_mode == crate::settings::RelayMode::Aggregate { + profile.model.clear(); + profile.base_url.clear(); + profile.upstream_base_url.clear(); + profile.api_key.clear(); + profile.config_contents.clear(); + profile.auth_contents.clear(); + return Ok(()); + } if profile.relay_mode == crate::settings::RelayMode::Official && !profile.official_mix_api_key { let has_api_config = !profile.base_url.trim().is_empty() || !profile.api_key.trim().is_empty() From 0bad6748fa998374deb8ddf7e56a748fca48ca8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B1=86=E5=8C=85?= Date: Mon, 22 Jun 2026 15:19:23 +0800 Subject: [PATCH 08/16] style: format updater arch token branch --- crates/codex-plus-core/src/update.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/codex-plus-core/src/update.rs b/crates/codex-plus-core/src/update.rs index e18a74b0..2f9fb00c 100644 --- a/crates/codex-plus-core/src/update.rs +++ b/crates/codex-plus-core/src/update.rs @@ -287,7 +287,11 @@ fn is_macos_native_arch_asset(name: &str) -> bool { return true; } // Newer but alternative shape: `..._x64.dmg` (no `macos-` token) - let other_token = if native_arch_token == "x64" { "arm64" } else { "x64" }; + let other_token = if native_arch_token == "x64" { + "arm64" + } else { + "x64" + }; if lower.contains(&format!("_{other_token}.")) || lower.contains(&format!("-{other_token}.")) { return false; } From b0782b6dacd438f21a5b96e1986bc49bcb4b66fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B1=86=E5=8C=85?= Date: Mon, 22 Jun 2026 15:26:17 +0800 Subject: [PATCH 09/16] fix: remove duplicate relay switch command helper --- apps/codex-plus-manager/src/App.tsx | 6 ------ 1 file changed, 6 deletions(-) diff --git a/apps/codex-plus-manager/src/App.tsx b/apps/codex-plus-manager/src/App.tsx index cb562d88..b1bb6e99 100644 --- a/apps/codex-plus-manager/src/App.tsx +++ b/apps/codex-plus-manager/src/App.tsx @@ -5708,12 +5708,6 @@ function relayProfileModeSwitchedText(profile: RelayProfile): string { return "已按此供应商切回官方登录;页面增强已设为兼容增强。"; } -function relayProfileSwitchCommand(profile: RelayProfile): "clear_relay_injection" | "apply_relay_injection" | "apply_pure_api_injection" { - if (profile.relayMode === "pureApi") return "apply_pure_api_injection"; - if (profile.relayMode === "official" && !profile.officialMixApiKey) return "clear_relay_injection"; - return "apply_relay_injection"; -} - function withGeneratedRelayFiles(profile: RelayProfile): RelayProfile { if (isAggregateRelayProfile(profile)) { return { ...profile, configContents: "", authContents: "", aggregate: normalizeAggregateConfig(profile.aggregate, []) }; From 61c0ac5f0defc8b36a8e6b57ef27be3b7d9a8560 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B1=86=E5=8C=85?= Date: Mon, 29 Jun 2026 16:07:35 +0800 Subject: [PATCH 10/16] fix: restore native fast preload on macOS launch --- crates/codex-plus-core/src/launcher.rs | 31 ++++++++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/crates/codex-plus-core/src/launcher.rs b/crates/codex-plus-core/src/launcher.rs index fcdeb046..466b3e48 100644 --- a/crates/codex-plus-core/src/launcher.rs +++ b/crates/codex-plus-core/src/launcher.rs @@ -3,6 +3,7 @@ use std::path::{Path, PathBuf}; use std::process::Stdio; use std::sync::Arc; use std::time::{SystemTime, UNIX_EPOCH}; +use std::env; use anyhow::Context; use async_trait::async_trait; @@ -693,10 +694,36 @@ impl LaunchHooks for DefaultLaunchHooks { let executable = command .first() .ok_or_else(|| anyhow::anyhow!("macOS open command is empty"))?; - let child = Command::new(executable) + let mut child_command = Command::new(executable); + child_command .args(&command[1..]) .stdout(Stdio::null()) - .stderr(Stdio::null()) + .stderr(Stdio::null()); + if settings.enhancements_enabled { + let preload_path = crate::service_tier_preload::ensure_service_tier_preload() + .context("failed to prepare service tier preload")?; + let node_options = + crate::service_tier_preload::node_options_with_service_tier_preload( + env::var("NODE_OPTIONS").ok().as_deref(), + &preload_path.to_string_lossy(), + ); + child_command.env("NODE_OPTIONS", node_options.clone()); + let _ = crate::diagnostic_log::append_diagnostic_log( + "launcher.service_tier_preload_enabled", + serde_json::json!({ + "preload_path": preload_path.to_string_lossy(), + "node_options": node_options, + }), + ); + } else { + let _ = crate::diagnostic_log::append_diagnostic_log( + "launcher.service_tier_preload_disabled", + serde_json::json!({ + "enhancements_enabled": settings.enhancements_enabled, + }), + ); + } + let child = child_command .spawn() .context("failed to launch macOS Codex app")?; *self.child.lock().await = Some(child); From 642e484f400b367a6561043b850853bd22001b1b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B1=86=E5=8C=85?= Date: Mon, 29 Jun 2026 16:54:51 +0800 Subject: [PATCH 11/16] fix: stabilize fast mode CI after main merge --- .../src/model-windows.test.ts | 26 +++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/apps/codex-plus-manager/src/model-windows.test.ts b/apps/codex-plus-manager/src/model-windows.test.ts index 9f995cea..50e12f3f 100644 --- a/apps/codex-plus-manager/src/model-windows.test.ts +++ b/apps/codex-plus-manager/src/model-windows.test.ts @@ -1,6 +1,5 @@ import assert from "node:assert"; import { describe, it } from "node:test"; -import type { RelayProfile } from "./App.tsx"; import { buildModelWindows, modelWindowRowsFromProfile, @@ -10,8 +9,31 @@ import { mergeModelWindowRows, } from "./model-windows.ts"; +type RelayProfileShape = { + id: string; + name: string; + model: string; + baseUrl: string; + upstreamBaseUrl: string; + apiKey: string; + protocol: "responses" | "chatCompletions"; + relayMode: "official" | "mixedApi" | "pureApi" | "aggregate"; + officialMixApiKey: boolean; + testModel: string; + configContents: string; + authContents: string; + useCommonConfig: boolean; + contextSelection: { mcpServers: string[]; skills: string[]; plugins: string[] }; + contextSelectionInitialized: boolean; + contextWindow: string; + autoCompactLimit: string; + modelList: string; + modelWindows: string; + userAgent: string; +}; + // 类型检查:确保 RelayProfile 包含 modelWindows 字段 -const _profileTypeCheck: RelayProfile = { +const _profileTypeCheck: RelayProfileShape = { id: "test", name: "", model: "", From b7fe7f01845033c03946ffb41ff4c0f238502bff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B1=86=E5=8C=85?= Date: Mon, 29 Jun 2026 18:29:59 +0800 Subject: [PATCH 12/16] fix: align CI checks after main merge --- .../src-tauri/src/commands.rs | 9 + apps/codex-plus-manager/src/App.tsx | 989 ++++++++++-------- assets/inject/renderer-inject.js | 883 +++++++++------- crates/codex-plus-core/src/ports.rs | 10 + crates/codex-plus-core/tests/bridge_routes.rs | 2 +- 5 files changed, 1088 insertions(+), 805 deletions(-) diff --git a/apps/codex-plus-manager/src-tauri/src/commands.rs b/apps/codex-plus-manager/src-tauri/src/commands.rs index d2248f50..59e54550 100644 --- a/apps/codex-plus-manager/src-tauri/src/commands.rs +++ b/apps/codex-plus-manager/src-tauri/src/commands.rs @@ -16,6 +16,11 @@ use serde_json::{Value, json}; use crate::install::{self, InstallActionResult, InstallOptions}; +fn test_env_lock() -> std::sync::MutexGuard<'static, ()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())).lock().expect("test env lock poisoned") +} + #[derive(Debug, Clone, Serialize)] pub struct CommandResult where @@ -3034,6 +3039,7 @@ mod tests { #[test] fn env_conflict_commands_ignore_codex_home_and_remove_openai_vars() { + let _lock = test_env_lock(); let test_openai_name = "OPENAI_CODEX_PLUS_ENV_CONFLICT_TEST"; let previous_openai = std::env::var_os(test_openai_name); let previous_codex_home = std::env::var_os("CODEX_HOME"); @@ -3085,6 +3091,7 @@ mod tests { #[test] fn delete_local_session_falls_back_when_requested_db_no_longer_contains_thread() { + let _lock = test_env_lock(); let temp = tempfile::tempdir().unwrap(); let previous_codex_home = std::env::var_os("CODEX_HOME"); let codex_home = temp.path().join("codex-home"); @@ -3151,6 +3158,7 @@ mod tests { #[test] fn list_local_sessions_deduplicates_threads_across_current_and_legacy_dbs() { + let _lock = test_env_lock(); let temp = tempfile::tempdir().unwrap(); let previous_codex_home = std::env::var_os("CODEX_HOME"); let codex_home = temp.path().join("codex-home"); @@ -3179,6 +3187,7 @@ mod tests { #[test] fn delete_local_session_removes_duplicate_threads_from_all_candidate_dbs() { + let _lock = test_env_lock(); let temp = tempfile::tempdir().unwrap(); let previous_codex_home = std::env::var_os("CODEX_HOME"); let codex_home = temp.path().join("codex-home"); diff --git a/apps/codex-plus-manager/src/App.tsx b/apps/codex-plus-manager/src/App.tsx index b1bb6e99..11a9043e 100644 --- a/apps/codex-plus-manager/src/App.tsx +++ b/apps/codex-plus-manager/src/App.tsx @@ -15,6 +15,7 @@ import { } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import { invoke } from "@tauri-apps/api/core"; +import { listen } from "@tauri-apps/api/event"; import { open } from "@tauri-apps/plugin-dialog"; import { ArrowLeft, @@ -59,6 +60,12 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; +import { + mergeModelWindowRows, + modelWindowRowsFromProfile, + serializeModelWindowRows, + type ModelWindowRow, +} from "./model-windows"; type Status = "ok" | "failed" | "not_implemented" | "not_checked" | string; @@ -118,15 +125,16 @@ type BackendSettings = { relayProfilesEnabled: boolean; enhancementsEnabled: boolean; computerUseGuardEnabled: boolean; - codexAppPluginEntryUnlock: boolean; codexAppPluginMarketplaceUnlock: boolean; codexAppForcePluginInstall: boolean; + codexAppPluginAutoExpand: boolean; codexAppModelWhitelistUnlock: boolean; codexAppSessionDelete: boolean; codexAppMarkdownExport: boolean; codexAppPasteFix: boolean; + codexAppForceChineseLocale: boolean; + codexAppFastStartup: boolean; codexAppProjectMove: boolean; - codexAppConversationTimeline: boolean; codexAppThreadIdBadge: boolean; codexAppConversationView: boolean; codexAppThreadScrollRestore: boolean; @@ -136,15 +144,12 @@ type BackendSettings = { zedRemoteSyncToZedSettings: boolean; codexAppUpstreamWorktreeCreate: boolean; codexAppNativeMenuPlacement: boolean; + codexAppNativeMenuLocalization: boolean; codexAppServiceTierControls: boolean; codexAppImageOverlayEnabled: boolean; codexAppImageOverlayPath: string; codexAppImageOverlayOpacity: number; codexGoalsEnabled: boolean; - mobileControlEnabled: boolean; - mobileControlRelayUrl: string; - mobileControlRoom: string; - mobileControlKey: string; launchMode: LaunchMode; relayBaseUrl: string; relayApiKey: string; @@ -164,7 +169,7 @@ type BackendSettings = { type ZedOpenStrategy = "addToFocusedWorkspace" | "reuseWindow" | "newWindow" | "default"; type LaunchMode = "patch" | "relay"; -type RelayProfile = { +export type RelayProfile = { id: string; name: string; model: string; @@ -183,6 +188,7 @@ type RelayProfile = { contextWindow: string; autoCompactLimit: string; modelList: string; + modelWindows: string; userAgent: string; aggregate?: RelayAggregateConfig | null; }; @@ -235,13 +241,6 @@ type RelayMode = "official" | "mixedApi" | "pureApi" | "aggregate"; const PROTOCOL_PROXY_BASE_URL = "http://127.0.0.1:57321/v1"; const CHAT_UPSTREAM_BASE_URL_KEY = "codex_plus_chat_base_url"; const SCRIPT_MARKET_REPOSITORY_URL = "https://github.com/BigPizzaV3/CodexPlusPlusScriptMarket"; -const LOCAL_MOBILE_RELAY_URL = "ws://127.0.0.1:57323"; -const PUBLIC_MOBILE_RELAY_URL = "ws://154.201.90.76:57323"; - -const mobileRelayServers = [ - { id: "local", label: "本机测试", url: LOCAL_MOBILE_RELAY_URL, capacity: 100 }, - { id: "public-154", label: "公共服务器 1", url: PUBLIC_MOBILE_RELAY_URL, capacity: 100 }, -]; const emptyContextSelection = (): RelayContextSelection => ({ mcpServers: [], @@ -393,6 +392,20 @@ type CcsProvidersResult = CommandResult<{ providers: CcsProviderImport[]; }>; +type ProviderImportRequest = { + name: string; + baseUrl: string; + apiKey: string; + wireApi: string; + relayMode: string; + configContents: string; + authContents: string; +}; + +type PendingProviderImportResult = CommandResult<{ + pending: ProviderImportRequest | null; +}>; + type EnvConflict = { name: string; source: "process" | "user" | string; @@ -579,16 +592,15 @@ type StartupResult = CommandResult<{ showUpdate: boolean; }>; -type Route = "overview" | "relay" | "mobileControl" | "sessions" | "context" | "enhance" | "zedRemote" | "userScripts" | "recommendations" | "maintenance" | "about" | "settings"; +type Route = "overview" | "relay" | "sessions" | "context" | "enhance" | "zedRemote" | "userScripts" | "recommendations" | "maintenance" | "about" | "settings"; type Theme = "dark" | "light"; const routes: Array<{ id: Route; label: string; icon: LucideIcon; badge?: string }> = [ { id: "overview", label: "概览", icon: LayoutDashboard }, { id: "relay", label: "供应商配置", icon: KeyRound }, - { id: "mobileControl", label: "手机控制", icon: MessageCircle, badge: "测试版" }, { id: "sessions", label: "会话管理", icon: MessageCircle }, { id: "context", label: "工具与插件", icon: Network }, - { id: "enhance", label: "页面增强", icon: Hammer }, + { id: "enhance", label: "Codex增强", icon: Hammer }, { id: "zedRemote", label: "Zed 远程项目", icon: ExternalLink }, { id: "userScripts", label: "脚本市场", icon: FileCode2 }, { id: "recommendations", label: "推荐内容", icon: ExternalLink }, @@ -607,15 +619,16 @@ const defaultSettings: BackendSettings = { relayProfilesEnabled: true, enhancementsEnabled: true, computerUseGuardEnabled: false, - codexAppPluginEntryUnlock: true, codexAppPluginMarketplaceUnlock: true, codexAppForcePluginInstall: true, + codexAppPluginAutoExpand: true, codexAppModelWhitelistUnlock: true, codexAppSessionDelete: true, codexAppMarkdownExport: true, codexAppPasteFix: false, + codexAppForceChineseLocale: true, + codexAppFastStartup: true, codexAppProjectMove: true, - codexAppConversationTimeline: true, codexAppThreadIdBadge: false, codexAppConversationView: false, codexAppThreadScrollRestore: true, @@ -625,15 +638,12 @@ const defaultSettings: BackendSettings = { zedRemoteSyncToZedSettings: false, codexAppUpstreamWorktreeCreate: true, codexAppNativeMenuPlacement: true, + codexAppNativeMenuLocalization: true, codexAppServiceTierControls: true, codexAppImageOverlayEnabled: false, codexAppImageOverlayPath: "", codexAppImageOverlayOpacity: 35, codexGoalsEnabled: false, - mobileControlEnabled: false, - mobileControlRelayUrl: LOCAL_MOBILE_RELAY_URL, - mobileControlRoom: "", - mobileControlKey: "", launchMode: "patch", relayBaseUrl: "", relayApiKey: "", @@ -657,6 +667,7 @@ const defaultSettings: BackendSettings = { contextWindow: "", autoCompactLimit: "", modelList: "", + modelWindows: "", userAgent: "", }, ], @@ -676,12 +687,21 @@ export function App() { const [theme, setTheme] = useState(() => loadInitialTheme()); const [route, setRoute] = useState(() => loadInitialRoute()); const [notice, setNotice] = useState<{ title: string; message: string; status?: Status } | null>(null); + const [confirmDialog, setConfirmDialog] = useState<{ + title: string; + message: string; + confirmText: string; + cancelText: string; + resolve: (confirmed: boolean) => void; + } | null>(null); + const [closeConfirmOpen, setCloseConfirmOpen] = useState(false); const [overview, setOverview] = useState(null); const [settings, setSettings] = useState(null); const [relay, setRelay] = useState(null); const [relayFiles, setRelayFiles] = useState(null); const [envConflicts, setEnvConflicts] = useState(null); const [ccsProviders, setCcsProviders] = useState(null); + const [pendingProviderImport, setPendingProviderImport] = useState(null); const [localSessions, setLocalSessions] = useState(null); const [zedRemoteProjects, setZedRemoteProjects] = useState(null); const [liveContextEntries, setLiveContextEntries] = useState(null); @@ -860,6 +880,34 @@ export function App() { } }; + const refreshPendingProviderImport = async (silent = true) => { + const result = await run(() => call("load_pending_provider_import")); + if (result) { + setPendingProviderImport(result.pending); + if (!silent && !isSuccessStatus(result.status)) showResultNotice("Codex++ 导入", result, { silentSuccess: true }); + } + return result; + }; + + const confirmPendingProviderImport = async () => { + const result = await run(() => call("confirm_pending_provider_import")); + if (result) { + setPendingProviderImport(null); + setSettings(result); + setSettingsForm(normalizeSettings(result.settings)); + showResultNotice("Codex++ 导入", result); + await refreshCcsProviders(true); + } + }; + + const dismissPendingProviderImport = async () => { + const result = await run(() => call("dismiss_pending_provider_import")); + if (result) { + setPendingProviderImport(null); + showResultNotice("Codex++ 导入", result, { silentSuccess: true }); + } + }; + const refreshLocalSessions = async (silent = false) => { const result = await run(() => call("list_local_sessions")); if (result) { @@ -907,20 +955,73 @@ export function App() { } }; + const requestDeleteLocalSession = (session: LocalSession) => + call("delete_local_session", { + request: { sessionId: session.id, title: session.title, dbPath: session.dbPath }, + }); + + const confirmSessionDelete = (title: string, message: string) => + new Promise((resolve) => { + setConfirmDialog({ + title, + message, + confirmText: "确认删除", + cancelText: "取消", + resolve, + }); + }); + const deleteLocalSession = async (session: LocalSession) => { const title = session.title || session.id; - if (!window.confirm(`删除会话“${title}”?此操作会删除本地数据库记录和 rollout 文件,并创建备份。`)) return; - const result = await run(() => - call("delete_local_session", { - request: { sessionId: session.id, title: session.title, dbPath: session.dbPath }, - }), - ); + const confirmed = await confirmSessionDelete("删除会话", `删除会话“${title}”?此操作会删除本地数据库记录和 rollout 文件,并创建备份。`); + if (!confirmed) return; + const result = await run(() => requestDeleteLocalSession(session)); if (result) { showResultNotice("会话删除", result); await refreshLocalSessions(true); } }; + const deleteLocalSessions = async (sessions: LocalSession[]) => { + const uniqueSessions = Array.from(new Map(sessions.map((session) => [session.id, session])).values()); + if (!uniqueSessions.length) { + showNotice("批量删除会话", "请先选择要删除的会话。", "failed"); + return; + } + const preview = uniqueSessions + .slice(0, 6) + .map((session) => `- ${truncateSessionDeletePreview(session.title || session.id)}`) + .join("\n"); + const extraCount = uniqueSessions.length > 6 ? `\n...以及另外 ${uniqueSessions.length - 6} 个会话` : ""; + const confirmed = await confirmSessionDelete( + "批量删除会话", + `删除选中的 ${uniqueSessions.length} 个会话?此操作会删除本地数据库记录和 rollout 文件,并为每个会话创建备份。\n\n${preview}${extraCount}`, + ); + if (!confirmed) return; + + let succeeded = 0; + const failed: string[] = []; + for (const session of uniqueSessions) { + const result = await run(() => requestDeleteLocalSession(session)); + if (result && isSuccessStatus(result.status)) { + succeeded += 1; + } else { + failed.push(session.title || session.id); + } + } + + if (failed.length) { + showNotice( + "批量删除会话", + `已删除 ${succeeded} 个,失败 ${failed.length} 个:${failed.slice(0, 3).map(truncateSessionDeletePreview).join("、")}`, + succeeded ? "ok" : "failed", + ); + } else { + showNotice("批量删除会话", `已删除 ${succeeded} 个会话。`, "ok"); + } + await refreshLocalSessions(true); + }; + const refreshLiveContextEntries = async (silent = false) => { const result = await run(() => call("read_live_context_entries")); if (result) { @@ -1299,7 +1400,7 @@ export function App() { if (result) { setSettings(result); setSettingsForm(normalizeSettings(result.settings)); - if (!silent) showNotice("页面增强模式", result.message, result.status); + if (!silent) showNotice("Codex增强模式", result.message, result.status); } return result; }; @@ -1407,14 +1508,14 @@ export function App() { const switched = await clearRelayInjection(true); if (!switched) return; const result = await saveLaunchMode("relay", true); - if (result) showNotice("官方登录模式", "已切回官方登录;页面增强已设为兼容增强。", result.status); + if (result) showNotice("官方登录模式", "已切回官方登录;Codex增强已设为兼容增强。", result.status); }; const switchPureApiMode = async () => { const switched = await applyPureApiInjection(true); if (!switched) return; const result = await saveLaunchMode("patch", true); - if (result) showNotice("纯 API 模式", "已切换到纯 API;页面增强已设为完整增强。", result.status); + if (result) showNotice("纯 API 模式", "已切换到纯 API;Codex增强已设为完整增强。", result.status); }; const switchRelayProfile = async (next: BackendSettings, previousActiveRelayId = settingsForm.activeRelayId) => { @@ -1434,16 +1535,6 @@ export function App() { targetRelayName: targetBeforeSnapshot.name, targetRelayMode: targetBeforeSnapshot.relayMode, }); - const switchSettingsWithSnapshot = await snapshotActiveRelayFilesBeforeSwitch(switchSettings, previousActiveRelayId); - if (!switchSettingsWithSnapshot) { - logDiagnostic("switchRelayProfile.snapshot_failed", { - currentRelayId: previousActiveRelayId, - targetRelayId: switchSettings.activeRelayId, - }); - return; - } - switchSettings = switchSettingsWithSnapshot; - const selectedBeforeSave = activeRelayProfile(switchSettings); const validationError = relayProfileSwitchValidation(selectedBeforeSave); if (validationError) { @@ -1455,7 +1546,8 @@ export function App() { showNotice("供应商配置可能不正确", validationError, "failed"); return; } - const selectedAfterSave = selectedBeforeSave; + switchSettings = await snapshotActiveRelayFilesBeforeSwitch(switchSettings, previousActiveRelayId); + const selectedAfterSave = activeRelayProfile(switchSettings); const command = relayProfileSwitchCommand(selectedAfterSave); logDiagnostic("switchRelayProfile.apply_start", { @@ -1517,38 +1609,21 @@ export function App() { const snapshotActiveRelayFilesBeforeSwitch = async ( next: BackendSettings, previousActiveRelayId: string, - ): Promise => { - const current = activeRelayProfile({ ...settingsForm, activeRelayId: previousActiveRelayId }); - const selected = activeRelayProfile(next); - if (current.id === selected.id) return next; - - logDiagnostic("snapshotActiveRelayFilesBeforeSwitch.start", { - currentRelayId: current.id, - currentRelayName: current.name, - selectedRelayId: selected.id, - selectedRelayName: selected.name, - }); + ): Promise => { + const profileId = previousActiveRelayId.trim(); + if (!profileId) return next; const result = await run(() => call("backfill_relay_profile_from_live", { - request: { settings: next, profileId: current.id }, + request: { settings: next, profileId }, }), ); - if (!result || !isSuccessStatus(result.status)) { - logDiagnostic("snapshotActiveRelayFilesBeforeSwitch.failed", { - currentRelayId: current.id, - selectedRelayId: selected.id, - status: result?.status, - message: result?.message, - }); - showNotice("供应商切换", result?.message ?? "读取当前配置文件失败,已停止切换以避免覆盖用户改动。", result?.status ?? "failed"); - return null; + if (!result) return next; + const normalized = normalizeSettings(result.settings); + if (!isSuccessStatus(result.status)) { + showNotice("供应商切换", result.message, result.status); + return next; } - - logDiagnostic("snapshotActiveRelayFilesBeforeSwitch.ok", { - currentRelayId: current.id, - selectedRelayId: selected.id, - }); - return syncLegacyRelayFields(normalizeSettings(result.settings)); + return normalized; }; const copyText = async (text: string, message: string) => { @@ -1570,6 +1645,34 @@ export function App() { setNotice({ title, message, status }); }; + useEffect(() => { + let active = true; + let unlisten: (() => void) | null = null; + void listen("manager://close-requested", () => { + setCloseConfirmOpen(true); + }).then((cleanup) => { + if (active) { + unlisten = cleanup; + } else { + cleanup(); + } + }); + return () => { + active = false; + unlisten?.(); + }; + }, []); + + const exitManagerApp = async () => { + setCloseConfirmOpen(false); + await call("manager_exit_app"); + }; + + const hideManagerToTray = async () => { + setCloseConfirmOpen(false); + await call("manager_hide_to_tray"); + }; + const showResultNotice = ( title: string, result: Pick, "message" | "status">, @@ -1593,10 +1696,18 @@ export function App() { await refreshRelay(true); await refreshEnvConflicts(true); await refreshProviderSyncTargets(true); + await refreshPendingProviderImport(true); await checkPluginMarketplacePrompt(); })(); }, []); + useEffect(() => { + const timer = window.setInterval(() => { + void refreshPendingProviderImport(true); + }, 1200); + return () => window.clearInterval(timer); + }, []); + useEffect(() => { document.documentElement.classList.toggle("dark", theme === "dark"); document.documentElement.classList.toggle("light", theme === "light"); @@ -1729,6 +1840,7 @@ export function App() { deleteUserScript, refreshLocalSessions, deleteLocalSession, + deleteLocalSessions, refreshZedRemoteProjects, openZedRemoteProject, forgetZedRemoteProject, @@ -1857,9 +1969,6 @@ export function App() { actions={actions} /> ) : null} - {route === "mobileControl" ? ( - - ) : null} {route === "sessions" ? ( setNotice(null)} /> ) : null} + {confirmDialog ? ( + { + confirmDialog.resolve(false); + setConfirmDialog(null); + }} + onConfirm={() => { + confirmDialog.resolve(true); + setConfirmDialog(null); + }} + /> + ) : null} + {closeConfirmOpen ? ( + void exitManagerApp()} + onHide={() => void hideManagerToTray()} + onCancel={() => setCloseConfirmOpen(false)} + /> + ) : null} {pluginMarketplacePrompt ? ( void actions.repairPluginMarketplace()} /> ) : null} + {pendingProviderImport ? ( + void confirmPendingProviderImport()} + onDismiss={() => void dismissPendingProviderImport()} + /> + ) : null}
); } @@ -1971,6 +2107,7 @@ type Actions = { deleteUserScript: (key: string) => Promise; refreshLocalSessions: () => Promise; deleteLocalSession: (session: LocalSession) => Promise; + deleteLocalSessions: (sessions: LocalSession[]) => Promise; refreshZedRemoteProjects: () => Promise; openZedRemoteProject: (project: ZedRemoteProject, strategy?: ZedOpenStrategy) => Promise; forgetZedRemoteProject: (project: ZedRemoteProject) => Promise; @@ -2007,248 +2144,6 @@ type Actions = { checkHealth: () => Promise; }; -type MobileRelayRoomStatus = { - room: string; - hostOnline: boolean; - clientOnline: boolean; - connections: number; - ageSeconds: number; - forwardedMessages: number; - forwardedBytes: number; -}; - -type MobileRelayStatus = { - status: string; - service: string; - version: string; - uptimeSeconds: number; - rooms: number; - activeConnections: number; - totalConnections: number; - forwardedMessages: number; - forwardedBytes: number; - roomDetails: MobileRelayRoomStatus[]; -}; - -function MobileControlScreen({ - form, - onFormChange, - actions, -}: { - form: BackendSettings; - onFormChange: (value: BackendSettings) => void; - actions: Actions; -}) { - const [serverStatuses, setServerStatuses] = useState>({}); - const [statusMessage, setStatusMessage] = useState("尚未刷新"); - const [loadingStatus, setLoadingStatus] = useState(false); - const mobileUrl = mobileRelayShareUrl(form); - const selectedServerId = - mobileRelayServers.find((server) => server.url === form.mobileControlRelayUrl)?.id || mobileRelayServers[0].id; - const selectedServer = mobileRelayServers.find((server) => server.id === selectedServerId) ?? mobileRelayServers[0]; - const selectedStatus = serverStatuses[selectedServer.id] ?? null; - const serverCapacity = selectedServer?.capacity ?? 100; - const serverLoad = selectedStatus?.activeConnections ?? 0; - const saveMobileSettings = async (next: BackendSettings, silent = true) => { - onFormChange(next); - await actions.saveSettingsValue(next, silent); - }; - const selectRelayServer = (serverId: string) => { - const server = mobileRelayServers.find((item) => item.id === serverId); - if (!server) return; - onFormChange({ ...form, mobileControlRelayUrl: server.url }); - }; - const startAndCopyMobileLink = async () => { - const room = form.mobileControlRoom.trim() || randomToken(8); - const key = form.mobileControlKey.trim() || randomToken(32); - const relayUrl = selectedServer.url; - const next = { - ...form, - mobileControlEnabled: true, - mobileControlRelayUrl: relayUrl, - mobileControlRoom: room, - mobileControlKey: key, - }; - await saveMobileSettings(next, true); - const link = mobileRelayShareUrl(next); - if (!link) { - await actions.showMessage("手机控制", "服务器地址无效,无法生成手机链接。", "failed"); - return; - } - await actions.launch(); - try { - await navigator.clipboard?.writeText(link); - await actions.showMessage("手机控制", "已启动并复制手机链接。"); - } catch (error) { - await actions.showMessage("手机控制", `已启动,但复制链接失败:${stringifyError(error)}`, "failed"); - } - }; - const refreshRelayStatus = async () => { - setLoadingStatus(true); - const entries = await Promise.all(mobileRelayServers.map(async (server) => { - const httpUrl = mobileRelayHttpUrl(server.url); - try { - const response = await fetch(`${httpUrl}/status`, { cache: "no-store" }); - if (!response.ok) throw new Error(`HTTP ${response.status}`); - return [server.id, (await response.json()) as MobileRelayStatus, ""] as const; - } catch (error) { - return [server.id, null, `${server.label}: ${error instanceof Error ? error.message : "刷新失败"}`] as const; - } - })); - setServerStatuses(Object.fromEntries(entries.map(([id, data]) => [id, data]))); - const failed = entries.map(([, , error]) => error).filter(Boolean); - setStatusMessage(failed.length ? failed.join(";") : "状态已刷新"); - setLoadingStatus(false); - }; - useEffect(() => { - void refreshRelayStatus(); - }, []); - useEffect(() => { - if (!mobileRelayServers.some((server) => server.url === form.mobileControlRelayUrl)) { - onFormChange({ ...form, mobileControlRelayUrl: mobileRelayServers[0].url }); - } - }, [form.mobileControlRelayUrl]); - return ( - <> - - - -
- {mobileRelayServers.map((server) => { - const isActive = selectedServerId === server.id; - const itemStatus = serverStatuses[server.id] ?? null; - const load = itemStatus?.activeConnections ?? 0; - return ( - - ); - })} -
-
- - -
- - - - - -
-
- - - -
-
-
- {mobileUrl || "未生成手机入口"} - {mobileUrl ? "手机打开后会自动填入房间和 Key 并尝试连接。" : "选择服务器并启动后会生成手机入口。"} -
- {mobileUrl ? ( - - ) : null} -
-
-
-
- - - - {selectedStatus ? ( - <> -
-
- -
- 在线连接 - {selectedStatus.activeConnections} 个在线连接,累计 {selectedStatus.totalConnections} 次连接。 -
- -
-
- -
- 房间数量 - {selectedStatus.rooms} 个房间,已转发 {selectedStatus.forwardedMessages} 条消息。 -
- -
-
-
- {selectedStatus.roomDetails.map((room) => ( -
-
-
- {room.room} - - host {room.hostOnline ? "在线" : "离线"} / client {room.clientOnline ? "在线" : "离线"}, - {room.connections} 个连接,{formatBytes(room.forwardedBytes)} - -
- -
-
- ))} -
- - ) : ( -

点击“刷新服务器状态”查看 relay 负载、在线用户和房间连接情况。

- )} -
-
- - ); -} - function OverviewScreen({ overview, pluginMarketplaceProgress, @@ -2583,7 +2478,7 @@ function EnhanceScreen({ return ( <> - +