diff --git a/.github/workflows/pr-build.yml b/.github/workflows/pr-build.yml index ce3818db7..ebc0f2678 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 }} diff --git a/Cargo.lock b/Cargo.lock index 6d236cc8d..312b3bc3d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -406,7 +406,7 @@ dependencies = [ [[package]] name = "codex-plus-core" -version = "1.2.21" +version = "1.2.23" dependencies = [ "aes-gcm", "anyhow", @@ -433,7 +433,7 @@ dependencies = [ [[package]] name = "codex-plus-data" -version = "1.2.21" +version = "1.2.23" dependencies = [ "anyhow", "base64 0.22.1", @@ -449,7 +449,7 @@ dependencies = [ [[package]] name = "codex-plus-launcher" -version = "1.2.21" +version = "1.2.23" dependencies = [ "anyhow", "async-trait", @@ -463,7 +463,7 @@ dependencies = [ [[package]] name = "codex-plus-manager" -version = "1.2.21" +version = "1.2.23" dependencies = [ "anyhow", "async-trait", @@ -481,7 +481,7 @@ dependencies = [ [[package]] name = "codex-plus-mobile-relay" -version = "1.2.21" +version = "1.2.23" dependencies = [ "anyhow", "futures-util", diff --git a/Cargo.toml b/Cargo.toml index 9e2cd14ac..e3cd6914b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,10 +9,10 @@ members = [ ] [workspace.package] -version = "1.2.21" +version = "1.2.23" edition = "2024" license = "MIT" -repository = "https://github.com/BigPizzaV3/CodexPlusPlus" +repository = "https://github.com/yinsang0910-star/CodexPlusPlus" [workspace.dependencies] aes-gcm = "0.10" diff --git a/apps/codex-plus-launcher/src/main.rs b/apps/codex-plus-launcher/src/main.rs index fab54fa92..0d3dd5224 100644 --- a/apps/codex-plus-launcher/src/main.rs +++ b/apps/codex-plus-launcher/src/main.rs @@ -149,6 +149,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)] { diff --git a/apps/codex-plus-manager/src/App.tsx b/apps/codex-plus-manager/src/App.tsx index e6014c8f8..5c19eb942 100644 --- a/apps/codex-plus-manager/src/App.tsx +++ b/apps/codex-plus-manager/src/App.tsx @@ -645,7 +645,7 @@ const defaultSettings: BackendSettings = { codexAppUpstreamWorktreeCreate: true, codexAppNativeMenuPlacement: true, codexAppNativeMenuLocalization: true, - codexAppServiceTierControls: false, + codexAppServiceTierControls: true, codexAppImageOverlayEnabled: false, codexAppImageOverlayPath: "", codexAppImageOverlayOpacity: 35, @@ -1544,6 +1544,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) { @@ -1555,8 +1565,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", { @@ -1618,21 +1627,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) => { @@ -2733,7 +2759,7 @@ function EnhanceScreen({ setEnhanceFlag("codexAppForcePluginInstall", value)} /> setEnhanceFlag("codexAppPluginAutoExpand", 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 67e1da895..3c178ab22 100644 --- a/assets/inject/renderer-inject.js +++ b/assets/inject/renderer-inject.js @@ -915,7 +915,7 @@ } function defaultCodexPlusSettings() { - return { pluginMarketplaceUnlock: true, forcePluginInstall: true, pluginAutoExpand: true, modelWhitelistUnlock: true, sessionDelete: true, markdownExport: true, pasteFix: false, projectMove: true, threadIdBadge: false, conversationView: false, conversationViewMaxWidth: conversationViewDefaultWidth, threadScrollRestore: true, zedRemoteOpen: true, upstreamWorktreeCreate: true, nativeMenuPlacement: true, serviceTierControls: false }; + return { pluginMarketplaceUnlock: true, forcePluginInstall: true, pluginAutoExpand: true, modelWhitelistUnlock: true, sessionDelete: true, markdownExport: true, pasteFix: false, projectMove: true, threadIdBadge: false, conversationView: false, conversationViewMaxWidth: conversationViewDefaultWidth, threadScrollRestore: true, zedRemoteOpen: true, upstreamWorktreeCreate: true, nativeMenuPlacement: true, serviceTierControls: true }; } const codexPlusBackendSettingMap = { @@ -974,6 +974,7 @@ settings.pluginMarketplaceUnlock = false; settings.forcePluginInstall = false; } + settings.serviceTierControls = true; return settings; } catch { const settings = { ...defaultCodexPlusSettings(), ...backendCodexPlusSettings() }; @@ -981,6 +982,7 @@ settings.pluginMarketplaceUnlock = false; settings.forcePluginInstall = false; } + settings.serviceTierControls = true; return settings; } } @@ -1736,10 +1738,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 || "", @@ -2163,8 +2162,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 2567aac78..2115a1958 100644 --- a/crates/codex-plus-core/src/launcher.rs +++ b/crates/codex-plus-core/src/launcher.rs @@ -1,3 +1,5 @@ +use std::env; +use std::ffi::OsString; use std::net::SocketAddr; use std::path::{Path, PathBuf}; use std::process::Stdio; @@ -481,6 +483,81 @@ fn helper_bind_host() -> String { .unwrap_or_else(|| "127.0.0.1".to_string()) } +struct ServiceTierPreloadEnv { + node_options: String, +} + +fn prepare_service_tier_preload( + settings: &BackendSettings, +) -> anyhow::Result> { + 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 = crate::service_tier_preload::node_options_with_service_tier_preload( + env::var("NODE_OPTIONS").ok().as_deref(), + &preload_path.to_string_lossy(), + ); + 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, + }), + ); + Ok(Some(ServiceTierPreloadEnv { node_options })) + } else { + let _ = crate::diagnostic_log::append_diagnostic_log( + "launcher.service_tier_preload_disabled", + serde_json::json!({ + "enhancements_enabled": settings.enhancements_enabled, + "service_tier_controls": settings.codex_app_service_tier_controls, + }), + ); + Ok(None) + } +} + +struct ScopedEnvVar { + key: &'static str, + previous: Option, +} + +impl ScopedEnvVar { + fn set(key: &'static str, value: &str) -> Self { + let previous = env::var_os(key); + unsafe { + env::set_var(key, value); + } + Self { key, previous } + } +} + +impl Drop for ScopedEnvVar { + fn drop(&mut self) { + unsafe { + if let Some(previous) = &self.previous { + env::set_var(self.key, previous); + } else { + env::remove_var(self.key); + } + } + } +} + +fn apply_service_tier_preload_env(command: &mut Command, preload: &ServiceTierPreloadEnv) { + command.env("NODE_OPTIONS", &preload.node_options); + if env::var_os("HOME").is_none() + && let Some(home) = service_tier_preload_home_dir() + { + command.env("HOME", home); + } +} + +fn service_tier_preload_home_dir() -> Option { + let app_state_dir = crate::paths::default_app_state_dir(); + app_state_dir.parent().map(Path::to_path_buf) +} + #[async_trait(?Send)] impl LaunchHooks for DefaultLaunchHooks { fn resolve_app_dir( @@ -640,6 +717,7 @@ impl LaunchHooks for DefaultLaunchHooks { let native_menu_localization_enabled = settings.codex_app_native_menu_localization; let native_menu_inspector_port = native_menu_localization_enabled.then(|| select_native_menu_inspector_port(debug_port)); + let service_tier_preload = prepare_service_tier_preload(settings)?; if cfg!(windows) { let activation = if let Some(inspector_port) = native_menu_inspector_port { build_packaged_activation_with_native_menu_inspector( @@ -652,6 +730,49 @@ impl LaunchHooks for DefaultLaunchHooks { build_packaged_activation(app_dir, debug_port, extra_args) }; if let Some(activation) = activation { + if let Some(preload) = &service_tier_preload { + let command = if let Some(inspector_port) = native_menu_inspector_port { + build_codex_command_with_native_menu_inspector( + app_dir, + debug_port, + inspector_port, + extra_args, + ) + } else { + build_codex_command(app_dir, debug_port, extra_args) + }; + let executable = command + .first() + .ok_or_else(|| anyhow::anyhow!("Codex command is empty"))?; + let mut child_command = Command::new(executable); + child_command + .args(&command[1..]) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + apply_service_tier_preload_env(&mut child_command, preload); + #[cfg(windows)] + child_command.creation_flags(crate::windows_integration::CREATE_NO_WINDOW); + let child = child_command.spawn().with_context(|| { + format!("failed to launch packaged Codex executable {executable}") + })?; + *self.child.lock().await = Some(child); + let _ = crate::diagnostic_log::append_diagnostic_log( + "launcher.service_tier_preload_direct_packaged_launch", + serde_json::json!({ + "app_dir": app_dir.to_string_lossy(), + "debug_port": debug_port, + "command": &command, + }), + ); + if let Some(inspector_port) = native_menu_inspector_port { + start_native_menu_localizer(inspector_port); + } + return Ok(CodexLaunch::Process { + command, + wait_strategy: ProcessWaitStrategy::TrackedChild, + macos_cleanup_policy: None, + }); + } let CodexLaunch::PackagedActivation { app_user_model_id, arguments, @@ -660,6 +781,9 @@ impl LaunchHooks for DefaultLaunchHooks { else { unreachable!(); }; + let _node_options_guard = service_tier_preload + .as_ref() + .map(|preload| ScopedEnvVar::set("NODE_OPTIONS", &preload.node_options)); let process_id = activate_packaged_app(app_user_model_id, arguments).await?; if let Some(inspector_port) = native_menu_inspector_port { start_native_menu_localizer(inspector_port); @@ -680,6 +804,48 @@ impl LaunchHooks for DefaultLaunchHooks { } if app_dir.extension().and_then(|value| value.to_str()) == Some("app") { + if let Some(preload) = &service_tier_preload { + let command = if let Some(inspector_port) = native_menu_inspector_port { + build_codex_command_with_native_menu_inspector( + app_dir, + debug_port, + inspector_port, + extra_args, + ) + } else { + build_codex_command(app_dir, debug_port, extra_args) + }; + let executable = command + .first() + .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()); + apply_service_tier_preload_env(&mut child_command, preload); + let child = child_command.spawn().with_context(|| { + format!("failed to launch macOS Codex executable {executable}") + })?; + *self.child.lock().await = Some(child); + let _ = crate::diagnostic_log::append_diagnostic_log( + "launcher.service_tier_preload_direct_macos_launch", + serde_json::json!({ + "app_dir": app_dir.to_string_lossy(), + "debug_port": debug_port, + "command": &command, + }), + ); + if let Some(inspector_port) = native_menu_inspector_port { + start_native_menu_localizer(inspector_port); + } + return Ok(CodexLaunch::Process { + command, + wait_strategy: ProcessWaitStrategy::TrackedChild, + macos_cleanup_policy: None, + }); + } + let cleanup_policy = if is_macos_app_running(app_dir).await { MacosCleanupPolicy::SkipQuitBecauseAlreadyRunning } else { @@ -697,11 +863,16 @@ impl LaunchHooks for DefaultLaunchHooks { }; 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 let Some(preload) = &service_tier_preload { + apply_service_tier_preload_env(&mut child_command, preload); + } + let child = child_command .spawn() .context("failed to launch macOS Codex app")?; *self.child.lock().await = Some(child); @@ -710,7 +881,7 @@ impl LaunchHooks for DefaultLaunchHooks { } return Ok(CodexLaunch::Process { command, - wait_strategy: ProcessWaitStrategy::ExternalWaitCommand, + wait_strategy: ProcessWaitStrategy::TrackedChild, macos_cleanup_policy: Some(cleanup_policy), }); } @@ -733,6 +904,9 @@ impl LaunchHooks for DefaultLaunchHooks { .args(&command[1..]) .stdout(Stdio::null()) .stderr(Stdio::null()); + if let Some(preload) = &service_tier_preload { + apply_service_tier_preload_env(&mut child_command, preload); + } #[cfg(windows)] child_command.creation_flags(crate::windows_integration::CREATE_NO_WINDOW); let child = child_command diff --git a/crates/codex-plus-core/src/lib.rs b/crates/codex-plus-core/src/lib.rs index 626898906..c28ebb4df 100644 --- a/crates/codex-plus-core/src/lib.rs +++ b/crates/codex-plus-core/src/lib.rs @@ -29,6 +29,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 000000000..79c5ae8ab --- /dev/null +++ b/crates/codex-plus-core/src/service_tier_preload.rs @@ -0,0 +1,317 @@ +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-4"; +const SERVICE_TIER_SETTINGS_ASSET_RE = /^use-service-tier-settings-.*\.js$/; +const READ_SERVICE_TIER_ASSET_RE = /^read-service-tier-for-request-.*\.js$/; +const HOME_DIR = process.env.HOME || process.env.USERPROFILE || process.cwd(); +const LOG_PATH = path.join(HOME_DIR, ".codex-session-delete", "codex-plus.log"); +const SETTINGS_PATH = path.join(HOME_DIR, ".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 = replaceFirstOf( + patched, + [ + [ + "s=o?.authMethod===`chatgpt`", + "s=o?.authMethod===`chatgpt`||o?.authMethod===`apikey`", + ], + [ + "c=o?.authMethod===`chatgpt`", + "c=o?.authMethod===`chatgpt`||o?.authMethod===`apikey`", + ], + [ + "s=a?.authMethod===`chatgpt`", + "s=a?.authMethod===`chatgpt`||a?.authMethod===`apikey`", + ], + ], + "service tier settings auth gate" + ); + patched = replaceFirstOf( + patched, + [ + ["s&&!f&&u!=null", "s&&!f"], + ["c&&!p&&d!=null", "c&&!p"], + ], + "service tier settings API key config requirement" + ); + return patched; +} + +function patchReadServiceTierAsset(source) { + let patched = source; + 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(g,{authMethod:n,hostId:t})).requirements?.featureRequirements?.fast_mode!==!1:!1", + "return n===`chatgpt`?(await e.query.fetch(g,{authMethod:n,hostId:t})).requirements?.featureRequirements?.fast_mode!==!1:n===`apikey`", + ], + ], + "read service tier auth gate" + ); + 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?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)", + ], + [ + "return s.service_tier==null?d(await T(t,i??s.model),s.service_tier,n):d(null,s.service_tier,n)", + "return s.service_tier==null?d(await T(t,i??s.model),s.service_tier,n):d(await T(t,i??s.model),s.service_tier,n)", + ], + ], + "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 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 { + 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 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 false; + } + return true; +} + +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; + if (!shouldInstallProtocolPatch()) 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 kind = serviceTierAssetKindFromUrl(request && request.url); + if (!kind) return handler.call(this, request); + return patchedAssetResponse(kind, request, handler, this); + }; + 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", { + 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("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("process.env.USERPROFILE")); + 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("s=a?.authMethod===`chatgpt`||a?.authMethod===`apikey`")); + assert!(script.contains("n===`apikey`")); + assert!(script.contains("return s.service_tier==null?d(await T(t,i??s.model),s.service_tier,n):d(await T(t,i??s.model),s.service_tier,n)")); + } +} diff --git a/crates/codex-plus-core/src/settings.rs b/crates/codex-plus-core/src/settings.rs index 0d2ea6fa9..7724ef4b4 100644 --- a/crates/codex-plus-core/src/settings.rs +++ b/crates/codex-plus-core/src/settings.rs @@ -235,7 +235,7 @@ pub struct BackendSettings { pub codex_app_native_menu_placement: bool, #[serde(rename = "codexAppNativeMenuLocalization", default = "default_true")] pub codex_app_native_menu_localization: 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, @@ -325,7 +325,7 @@ impl Default for BackendSettings { codex_app_upstream_worktree_create: true, codex_app_native_menu_placement: true, codex_app_native_menu_localization: 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(), diff --git a/crates/codex-plus-core/src/update.rs b/crates/codex-plus-core/src/update.rs index 2f9fb00c7..20afd49cf 100644 --- a/crates/codex-plus-core/src/update.rs +++ b/crates/codex-plus-core/src/update.rs @@ -3,9 +3,9 @@ use std::path::{Path, PathBuf}; use serde::{Deserialize, Serialize}; use serde_json::Value; -pub const DEFAULT_REPOSITORY: &str = "BigPizzaV3/CodexPlusPlus"; +pub const DEFAULT_REPOSITORY: &str = "yinsang0910-star/CodexPlusPlus"; pub const DEFAULT_LATEST_JSON_URL: &str = - "https://github.com/BigPizzaV3/CodexPlusPlus/releases/latest/download/latest.json"; + "https://github.com/yinsang0910-star/CodexPlusPlus/releases/latest/download/latest.json"; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ReleaseAsset { diff --git a/crates/codex-plus-core/tests/cdp_bridge.rs b/crates/codex-plus-core/tests/cdp_bridge.rs index fb172b95f..7bbc91c0f 100644 --- a/crates/codex-plus-core/tests/cdp_bridge.rs +++ b/crates/codex-plus-core/tests/cdp_bridge.rs @@ -477,8 +477,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")); @@ -559,6 +559,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!( @@ -571,6 +572,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 a47c08e81..1836ec1d1 100644 --- a/crates/codex-plus-core/tests/launcher.rs +++ b/crates/codex-plus-core/tests/launcher.rs @@ -210,10 +210,30 @@ fn launcher_builds_debug_arguments_and_commands() { } #[test] -fn launcher_does_not_override_codex_app_environment() { +fn launcher_macos_direct_command_targets_bundle_executable() { + let command = build_codex_command(Path::new("/Applications/OpenAI Codex.app"), 9229, &[]); + let executable = command[0].replace('\\', "/"); + + assert_eq!( + executable, + "/Applications/OpenAI Codex.app/Contents/MacOS/Codex" + ); + assert!(!command.contains(&"open".to_string())); +} + +#[test] +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("apply_service_tier_preload_env")); + assert!(source.contains("service_tier_preload_direct_packaged_launch")); + assert!(source.contains("service_tier_preload_direct_macos_launch")); + assert!(source.contains("ensure_service_tier_preload")); + assert!(source.contains("ScopedEnvVar::set(\"NODE_OPTIONS\"")); + assert!(source.contains("settings.enhancements_enabled")); + assert!(source.contains("settings.codex_app_service_tier_controls")); + assert!(source.contains("launcher.service_tier_preload_disabled")); assert!(!source.contains("activate_packaged_app_with_environment")); assert!(!source.contains("with_temporary_proxy_environment")); }