From 822d66abce7f95fdbee4b271e078482d30331276 Mon Sep 17 00:00:00 2001 From: hcw <1416522360@qq.com> Date: Sat, 1 Aug 2026 00:04:36 +0800 Subject: [PATCH 1/2] feat: improve user script hot reload --- .gitattributes | 7 ++ apps/codex-plus-launcher/src/main.rs | 40 ++++--- .../src-tauri/src/commands.rs | 101 +++++++++++++++--- apps/codex-plus-manager/src-tauri/src/lib.rs | 1 + .../src-tauri/tests/windows_subsystem.rs | 31 ++++++ apps/codex-plus-manager/src/App.tsx | 28 ++++- apps/codex-plus-manager/src/i18n-en.ts | 2 + crates/codex-plus-core/src/cdp.rs | 30 +++++- crates/codex-plus-core/src/routes.rs | 12 +-- crates/codex-plus-core/src/user_scripts.rs | 17 +++ crates/codex-plus-core/tests/bridge_routes.rs | 85 +++++++++++++++ crates/codex-plus-core/tests/cdp_bridge.rs | 26 +++++ 12 files changed, 339 insertions(+), 41 deletions(-) diff --git a/.gitattributes b/.gitattributes index 561487b9d..39f496144 100644 --- a/.gitattributes +++ b/.gitattributes @@ -14,3 +14,10 @@ assets/inject/upstream/snow-skin/*.css text eol=lf # Keep byte-exact macOS theme assets identical on every checkout platform. assets/inject/upstream/*/macos/*.js text eol=lf assets/inject/upstream/*/macos/*.css text eol=lf + +# Keep the remaining byte-exact upstream theme assets stable on Windows. +assets/inject/upstream/*/windows/*.js text eol=lf +assets/inject/upstream/*/windows/*.css text eol=lf +assets/inject/upstream/glass-vision/*.js text eol=lf +assets/inject/upstream/glass-vision/*.css text eol=lf +assets/inject/upstream/skin-packs/packs/*/theme.json text eol=lf diff --git a/apps/codex-plus-launcher/src/main.rs b/apps/codex-plus-launcher/src/main.rs index af73c5067..489b6efb2 100644 --- a/apps/codex-plus-launcher/src/main.rs +++ b/apps/codex-plus-launcher/src/main.rs @@ -561,6 +561,15 @@ impl LauncherRuntimeService { fn set_websocket_url(&self, websocket_url: &str) { *self.websocket_url.lock().unwrap() = Some(websocket_url.to_string()); } + + async fn reload_user_scripts_now(&self) -> anyhow::Result { + let bundle = self.user_scripts.build_reload_bundle()?; + let websocket_url = self.websocket_url.lock().unwrap().clone(); + if let Some(websocket_url) = websocket_url { + codex_plus_core::bridge::evaluate_script(&websocket_url, &bundle).await?; + } + self.user_scripts.inventory() + } } #[async_trait::async_trait] @@ -579,26 +588,21 @@ impl BridgeRuntimeService for LauncherRuntimeService { async fn set_user_scripts_enabled(&self, enabled: bool) -> anyhow::Result { self.user_scripts.set_global_enabled(enabled)?; - self.user_scripts.inventory() + self.reload_user_scripts_now().await } async fn set_user_script_enabled(&self, key: String, enabled: bool) -> anyhow::Result { self.user_scripts.set_script_enabled(&key, enabled)?; - self.user_scripts.inventory() + self.reload_user_scripts_now().await } async fn delete_user_script(&self, key: String) -> anyhow::Result { self.user_scripts.delete_user_script(&key)?; - self.user_scripts.inventory() + self.reload_user_scripts_now().await } async fn reload_user_scripts(&self) -> anyhow::Result { - let bundle = self.user_scripts.build_enabled_bundle()?; - let websocket_url = self.websocket_url.lock().unwrap().clone(); - if let Some(websocket_url) = websocket_url.filter(|_| !bundle.trim().is_empty()) { - codex_plus_core::bridge::evaluate_script(&websocket_url, &bundle).await?; - } - self.user_scripts.inventory() + self.reload_user_scripts_now().await } async fn open_devtools(&self) -> anyhow::Result { @@ -743,10 +747,7 @@ async fn try_inject_with_context( .load() .unwrap_or_default(); let script = codex_plus_core::assets::injection_script_with_settings(helper_port, &settings); - let user_bundle = runtime - .user_scripts - .build_enabled_bundle() - .unwrap_or_default(); + let user_bundle = runtime.user_scripts.build_enabled_bundle()?; let new_document_scripts = if user_bundle.is_empty() { vec![script] } else { @@ -763,7 +764,8 @@ async fn try_inject_with_context( }), &new_document_scripts, ) - .await + .await?; + Ok(()) } fn default_codex_db_path() -> PathBuf { @@ -881,6 +883,16 @@ mod tests { assert!(source.contains(".start_computer_use_guard_watchdog(settings)")); } + #[test] + fn launcher_uses_event_driven_user_script_reload_without_file_polling() { + let source = include_str!("main.rs"); + + assert!(source.contains("build_reload_bundle")); + assert!(!source.contains("start_user_script_hot_reload_watchdog")); + assert!(!source.contains("user_scripts.snapshot()")); + assert!(!source.contains("tokio::time::interval(std::time::Duration::from_secs(1))")); + } + #[tokio::test] async fn watchdog_reuses_bridge_context_with_data_service() { let test_dir = std::env::temp_dir().join(format!( diff --git a/apps/codex-plus-manager/src-tauri/src/commands.rs b/apps/codex-plus-manager/src-tauri/src/commands.rs index fa76094e1..ee29e0ab5 100644 --- a/apps/codex-plus-manager/src-tauri/src/commands.rs +++ b/apps/codex-plus-manager/src-tauri/src/commands.rs @@ -390,6 +390,12 @@ pub struct LaunchRequest { pub helper_port: u16, } +#[derive(Debug, Clone, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UserScriptReloadRequest { + pub debug_port: u16, +} + #[derive(Debug, Clone, serde::Deserialize)] #[serde(rename_all = "camelCase")] pub struct LogRequest { @@ -2068,7 +2074,10 @@ pub async fn refresh_script_market() -> CommandResult { } #[tauri::command] -pub async fn install_market_script(id: String) -> CommandResult { +pub async fn install_market_script( + id: String, + debug_port: u16, +) -> CommandResult { let trimmed = id.trim(); if trimmed.is_empty() { return failed( @@ -2094,10 +2103,13 @@ pub async fn install_market_script(id: String) -> CommandResult ok( - "脚本已安装。", - script_market_payload_from_manifest(&manifest, "ok", "脚本已安装。"), - ), + Ok(()) => { + let message = script_change_reload_message("脚本已安装。", debug_port).await; + ok( + &message, + script_market_payload_from_manifest(&manifest, "ok", &message), + ) + } Err(error) => failed( &format!("安装脚本失败:{error}"), script_market_payload_from_manifest( @@ -2110,21 +2122,29 @@ pub async fn install_market_script(id: String) -> CommandResult CommandResult { +pub async fn set_user_script_enabled( + key: String, + enabled: bool, + debug_port: u16, +) -> CommandResult { let trimmed = key.trim(); if trimmed.is_empty() { return failed("脚本 key 不能为空。", fallback_settings_payload()); } let manager = default_user_script_manager(); match manager.set_script_enabled(trimmed, enabled) { - Ok(_) => settings_payload( - if enabled { - "脚本已启用。" - } else { - "脚本已禁用。" - }, - "脚本启停失败", - ), + Ok(_) => { + let message = script_change_reload_message( + if enabled { + "脚本已启用。" + } else { + "脚本已禁用。" + }, + debug_port, + ) + .await; + settings_payload(&message, "脚本启停失败") + } Err(error) => failed( &format!("脚本启停失败:{error}"), fallback_settings_payload(), @@ -2133,14 +2153,17 @@ pub fn set_user_script_enabled(key: String, enabled: bool) -> CommandResult CommandResult { +pub async fn delete_user_script(key: String, debug_port: u16) -> CommandResult { let trimmed = key.trim(); if trimmed.is_empty() { return failed("脚本 key 不能为空。", fallback_settings_payload()); } let manager = default_user_script_manager(); match manager.delete_user_script(trimmed) { - Ok(_) => settings_payload("脚本已删除。", "脚本删除失败"), + Ok(_) => { + let message = script_change_reload_message("脚本已删除。", debug_port).await; + settings_payload(&message, "脚本删除失败") + } Err(error) => failed( &format!("脚本删除失败:{error}"), fallback_settings_payload(), @@ -2148,6 +2171,52 @@ pub fn delete_user_script(key: String) -> CommandResult { } } +#[tauri::command] +pub async fn reload_user_scripts( + request: UserScriptReloadRequest, +) -> CommandResult { + let debug_port = request.debug_port; + if debug_port == 0 { + return failed("Codex 调试端口无效。", fallback_settings_payload()); + } + + let result = reload_user_scripts_on_page(debug_port).await; + + match result { + Ok(()) => settings_payload("用户脚本已热重载。", "脚本热重载失败"), + Err(error) => failed( + &format!("脚本热重载失败:{error}"), + fallback_settings_payload(), + ), + } +} + +async fn reload_user_scripts_on_page(debug_port: u16) -> anyhow::Result<()> { + let manager = default_user_script_manager(); + let bundle = manager.build_reload_bundle()?; + let targets = codex_plus_core::cdp::list_targets(debug_port).await?; + let target = codex_plus_core::cdp::pick_injectable_codex_page_target(&targets)?; + let websocket_url = target + .web_socket_debugger_url + .as_deref() + .ok_or_else(|| anyhow::anyhow!("目标页面没有可用的 CDP WebSocket"))?; + codex_plus_core::bridge::evaluate_script(websocket_url, &bundle).await?; + Ok(()) +} + +async fn script_change_reload_message(message: &str, debug_port: u16) -> String { + match reload_user_scripts_on_page(debug_port).await { + Ok(()) => format!("{message} 已热重载当前页面。"), + Err(error) => { + let _ = codex_plus_core::diagnostic_log::append_diagnostic_log( + "user_scripts.change_reload_failed", + json!({ "debug_port": debug_port, "error": error.to_string() }), + ); + format!("{message} 当前页面未能热重载,可点击“热重载”重试。") + } + } +} + #[tauri::command] pub fn open_external_url(url: String) -> CommandResult { let trimmed = url.trim(); diff --git a/apps/codex-plus-manager/src-tauri/src/lib.rs b/apps/codex-plus-manager/src-tauri/src/lib.rs index d881bbe77..d7df266fe 100644 --- a/apps/codex-plus-manager/src-tauri/src/lib.rs +++ b/apps/codex-plus-manager/src-tauri/src/lib.rs @@ -104,6 +104,7 @@ pub fn run() { commands::install_market_script, commands::set_user_script_enabled, commands::delete_user_script, + commands::reload_user_scripts, commands::open_external_url, commands::install_entrypoints, commands::uninstall_entrypoints, diff --git a/apps/codex-plus-manager/src-tauri/tests/windows_subsystem.rs b/apps/codex-plus-manager/src-tauri/tests/windows_subsystem.rs index 51ea49677..4ecf2361a 100644 --- a/apps/codex-plus-manager/src-tauri/tests/windows_subsystem.rs +++ b/apps/codex-plus-manager/src-tauri/tests/windows_subsystem.rs @@ -355,6 +355,37 @@ fn manager_ui_no_longer_exposes_command_wrapper_or_startup_marketplace_prompt() assert!(!app_tsx.contains("await checkPluginMarketplacePrompt()")); } +#[test] +fn manager_script_market_exposes_manual_hot_reload_command() { + let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")); + let app_tsx = manifest_dir.parent().unwrap().join("src/App.tsx"); + let app_tsx = std::fs::read_to_string(&app_tsx).expect("read manager App.tsx"); + let commands_rs = std::fs::read_to_string(manifest_dir.join("src/commands.rs")) + .expect("read manager commands.rs"); + let lib_rs = + std::fs::read_to_string(manifest_dir.join("src/lib.rs")).expect("read manager lib.rs"); + + assert!(app_tsx.contains("const reloadUserScripts = async ()")); + assert!( + app_tsx + .contains("call(\"reload_user_scripts\", { request: { debugPort } })") + ); + assert!(app_tsx.contains("actions.reloadUserScripts()")); + assert!(app_tsx.contains("{t(\"热重载\")}")); + assert!(commands_rs.contains("pub async fn reload_user_scripts(")); + assert!(commands_rs.contains("pub struct UserScriptReloadRequest")); + assert!(commands_rs.contains("async fn reload_user_scripts_on_page(")); + assert!(commands_rs.contains("async fn script_change_reload_message(")); + assert!( + app_tsx.contains("call(\"install_market_script\", { id, debugPort })") + ); + assert!(app_tsx.contains( + "call(\"set_user_script_enabled\", { key, enabled, debugPort })" + )); + assert!(app_tsx.contains("call(\"delete_user_script\", { key, debugPort })")); + assert!(lib_rs.contains("commands::reload_user_scripts")); +} + #[test] fn manager_update_install_keeps_visible_progress_bar() { let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")); diff --git a/apps/codex-plus-manager/src/App.tsx b/apps/codex-plus-manager/src/App.tsx index b41a66b31..6aee1238a 100644 --- a/apps/codex-plus-manager/src/App.tsx +++ b/apps/codex-plus-manager/src/App.tsx @@ -1021,7 +1021,8 @@ export function App() { }; const installMarketScript = async (id: string) => { - const result = await run(() => call("install_market_script", { id })); + const debugPort = overview?.latest_launch?.debug_port ?? parsePort(launchForm.debugPort, 9229); + const result = await run(() => call("install_market_script", { id, debugPort })); if (result) { setScriptMarket(result); setSettings((current) => (current ? { ...current, user_scripts: result.user_scripts } : current)); @@ -1030,7 +1031,8 @@ export function App() { }; const setUserScriptEnabled = async (key: string, enabled: boolean) => { - const result = await run(() => call("set_user_script_enabled", { key, enabled })); + const debugPort = overview?.latest_launch?.debug_port ?? parsePort(launchForm.debugPort, 9229); + const result = await run(() => call("set_user_script_enabled", { key, enabled, debugPort })); if (result) { setSettings(result); setScriptMarket((current) => syncMarketInstalledState(current, result.user_scripts)); @@ -1042,7 +1044,8 @@ export function App() { const script = settings?.user_scripts?.scripts?.find((item) => item.key === key); const name = script?.name || key; if (!window.confirm(tf("删除脚本“{0}”?此操作会移除本地脚本文件。", [name]))) return; - const result = await run(() => call("delete_user_script", { key })); + const debugPort = overview?.latest_launch?.debug_port ?? parsePort(launchForm.debugPort, 9229); + const result = await run(() => call("delete_user_script", { key, debugPort })); if (result) { setSettings(result); setScriptMarket((current) => syncMarketInstalledState(current, result.user_scripts)); @@ -1050,6 +1053,19 @@ export function App() { } }; + const reloadUserScripts = async () => { + const debugPort = overview?.latest_launch?.debug_port ?? parsePort(launchForm.debugPort, 9229); + const result = await run(() => call("reload_user_scripts", { request: { debugPort } })); + if (result) { + setSettings(result); + setScriptMarket((current) => syncMarketInstalledState(current, result.user_scripts)); + showResultNotice(t("脚本市场"), { + ...result, + message: isSuccessStatus(result.status) ? t("用户脚本已热重载。") : result.message, + }); + } + }; + const refreshRelay = async (silent = false) => { const result = await run(() => call("relay_status")); if (result) { @@ -2620,6 +2636,7 @@ export function App() { installMarketScript, setUserScriptEnabled, deleteUserScript, + reloadUserScripts, refreshLocalSessions, deleteLocalSession, deleteLocalSessions, @@ -2966,6 +2983,7 @@ type Actions = { installMarketScript: (id: string) => Promise; setUserScriptEnabled: (key: string, enabled: boolean) => Promise; deleteUserScript: (key: string) => Promise; + reloadUserScripts: () => Promise; refreshLocalSessions: (silent?: boolean, offset?: number) => Promise; deleteLocalSession: (session: LocalSession) => Promise; deleteLocalSessions: (sessions: LocalSession[]) => Promise; @@ -4504,6 +4522,10 @@ function UserScriptsScreen({ settings, market, actions }: { settings: SettingsRe {t("刷新本地")} + diff --git a/apps/codex-plus-manager/src/i18n-en.ts b/apps/codex-plus-manager/src/i18n-en.ts index 4199b9e91..c70599cf5 100644 --- a/apps/codex-plus-manager/src/i18n-en.ts +++ b/apps/codex-plus-manager/src/i18n-en.ts @@ -329,6 +329,8 @@ export const EN_PLAIN: Record = { "刷新当前页面": "Refresh current page", "刷新推荐": "Refresh recommendations", "刷新本地": "Refresh local", + "热重载": "Hot reload", + "用户脚本已热重载。": "User scripts hot reloaded.", "刷新项目": "Refresh projects", "加入当前工作区": "Add to current workspace", "包含版本、路径、设置和平台信息": "Includes version, paths, settings and platform info", diff --git a/crates/codex-plus-core/src/cdp.rs b/crates/codex-plus-core/src/cdp.rs index b8f9b358d..552af759f 100644 --- a/crates/codex-plus-core/src/cdp.rs +++ b/crates/codex-plus-core/src/cdp.rs @@ -253,6 +253,15 @@ pub fn pick_page_target(targets: &[CdpTarget]) -> anyhow::Result { } pub fn pick_injectable_codex_page_target(targets: &[CdpTarget]) -> anyhow::Result { + for target in targets + .iter() + .filter(|target| is_injectable_page_target(target)) + { + if is_preferred_codex_page_target(target) { + return Ok(target.clone()); + } + } + for target in targets .iter() .filter(|target| is_injectable_page_target(target)) @@ -274,19 +283,38 @@ pub fn is_injectable_page_target(target: &CdpTarget) -> bool { } pub fn is_codex_page_target(target: &CdpTarget) -> bool { - if target.target_type != "page" { + if target.target_type != "page" || is_codex_plus_manager_page(target) { return false; } let haystack = format!("{} {}", target.title, target.url).to_lowercase(); haystack.contains("codex") || is_chatgpt_desktop_page(&target.title, &target.url) } +fn is_codex_plus_manager_page(target: &CdpTarget) -> bool { + let title = target.title.trim().to_lowercase(); + title.contains("codex++") && (title.contains("manager") || title.contains("管理工具")) +} + pub fn is_primary_codex_page_target(target: &CdpTarget) -> bool { is_codex_page_target(target) && !is_avatar_overlay_page_target(target) && !is_quick_chat_page_target(target) } +fn is_preferred_codex_page_target(target: &CdpTarget) -> bool { + is_primary_codex_page_target(target) + && (is_codex_app_page(target) || is_chatgpt_desktop_page(&target.title, &target.url)) +} + +fn is_codex_app_page(target: &CdpTarget) -> bool { + let Ok(url) = reqwest::Url::parse(target.url.trim()) else { + return false; + }; + url.scheme().eq_ignore_ascii_case("app") + && url.host_str() == Some("-") + && url.path().eq_ignore_ascii_case("/index.html") +} + pub fn is_avatar_overlay_page_target(target: &CdpTarget) -> bool { initial_route(target).is_some_and(|route| route.eq_ignore_ascii_case("/avatar-overlay")) } diff --git a/crates/codex-plus-core/src/routes.rs b/crates/codex-plus-core/src/routes.rs index 1fe0a4683..e47d5805e 100644 --- a/crates/codex-plus-core/src/routes.rs +++ b/crates/codex-plus-core/src/routes.rs @@ -393,7 +393,7 @@ impl BridgeRuntimeService for CoreRuntimeService { match &self.user_scripts { Some(user_scripts) => { user_scripts.set_global_enabled(enabled)?; - user_scripts.inventory() + self.reload_user_scripts().await } None => { let mut inventory = empty_user_script_inventory(); @@ -407,7 +407,7 @@ impl BridgeRuntimeService for CoreRuntimeService { match &self.user_scripts { Some(user_scripts) => { user_scripts.set_script_enabled(&key, enabled)?; - user_scripts.inventory() + self.reload_user_scripts().await } None => Ok(empty_user_script_inventory()), } @@ -417,7 +417,7 @@ impl BridgeRuntimeService for CoreRuntimeService { match &self.user_scripts { Some(user_scripts) => { user_scripts.delete_user_script(&key)?; - user_scripts.inventory() + self.reload_user_scripts().await } None => Ok(empty_user_script_inventory()), } @@ -429,10 +429,8 @@ impl BridgeRuntimeService for CoreRuntimeService { self.websocket_url.as_deref(), &self.user_script_evaluator, ) { - let bundle = user_scripts.build_enabled_bundle()?; - if !bundle.trim().is_empty() { - evaluator(websocket_url, &bundle)?; - } + let bundle = user_scripts.build_reload_bundle()?; + evaluator(websocket_url, &bundle)?; } self.user_script_inventory().await } diff --git a/crates/codex-plus-core/src/user_scripts.rs b/crates/codex-plus-core/src/user_scripts.rs index e670850c6..47d2d5321 100644 --- a/crates/codex-plus-core/src/user_scripts.rs +++ b/crates/codex-plus-core/src/user_scripts.rs @@ -9,6 +9,16 @@ use serde_json::{Map, Value, json}; use crate::script_market::MarketScript; +const USER_SCRIPT_RELOAD_PRELUDE: &str = r#" +(() => { + const registry = window.__codexPlusUserScripts; + if (registry) { + window.dispatchEvent(new CustomEvent("codex-plus-user-scripts-before-reload", { detail: registry })); + } + window.__codexPlusUserScripts = { scripts: {} }; +})(); +"#; + #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct UserScriptConfig { pub enabled: bool, @@ -214,6 +224,13 @@ impl UserScriptManager { Ok(blocks.join("\n")) } + pub fn build_reload_bundle(&self) -> anyhow::Result { + Ok(format!( + "{USER_SCRIPT_RELOAD_PRELUDE}\n{}", + self.build_enabled_bundle()? + )) + } + fn scan_scripts( &self, config: &UserScriptConfig, diff --git a/crates/codex-plus-core/tests/bridge_routes.rs b/crates/codex-plus-core/tests/bridge_routes.rs index 57dd57e18..d05aed646 100644 --- a/crates/codex-plus-core/tests/bridge_routes.rs +++ b/crates/codex-plus-core/tests/bridge_routes.rs @@ -761,6 +761,91 @@ async fn core_runtime_reload_evaluates_enabled_user_bundle_and_status_is_ok() { assert!(evaluated[0].contains("window.demo = true;")); } +#[tokio::test] +async fn core_runtime_toggling_user_script_reloads_current_page_bundle() { + let temp = tempfile::tempdir().unwrap(); + let user_dir = temp.path().join("user"); + std::fs::create_dir_all(&user_dir).unwrap(); + std::fs::write(user_dir.join("demo.js"), "window.demoReloaded = true;").unwrap(); + let manager = UserScriptManager::new( + temp.path().join("builtin"), + user_dir, + temp.path().join("user_scripts.json"), + ); + let evaluated = Arc::new(Mutex::new(Vec::::new())); + let runtime = CoreRuntimeService::new(9229, StatusStore::default()) + .with_user_scripts(manager) + .with_user_script_evaluator({ + let evaluated = evaluated.clone(); + Arc::new(move |websocket_url, script| { + evaluated + .lock() + .unwrap() + .push(format!("{websocket_url}:{script}")); + Ok(json!({"status": "ok"})) + }) + }) + .with_websocket_url("ws://page"); + let ctx = BridgeContext::core_with_data(Arc::new(runtime), Arc::new(FakeData::default())); + + let toggled = handle_bridge_request( + ctx, + "/user-scripts/set-script-enabled", + json!({"key": "user:demo.js", "enabled": true}), + ) + .await; + + assert_eq!(toggled["scripts"][0]["enabled"], true); + let evaluated = evaluated.lock().unwrap(); + assert_eq!(evaluated.len(), 1); + assert!(evaluated[0].contains("window.demoReloaded = true;")); +} + +#[test] +fn user_script_reload_bundle_resets_runtime_registry_before_enabled_scripts() { + let temp = tempfile::tempdir().unwrap(); + let user_dir = temp.path().join("user"); + std::fs::create_dir_all(&user_dir).unwrap(); + std::fs::write(user_dir.join("demo.js"), "window.reloadVersion = 2;").unwrap(); + let manager = UserScriptManager::new( + temp.path().join("builtin"), + user_dir, + temp.path().join("user_scripts.json"), + ); + + let bundle = manager.build_reload_bundle().unwrap(); + + assert!(bundle.contains("codex-plus-user-scripts-before-reload")); + assert!(bundle.contains("window.__codexPlusUserScripts = { scripts: {} }")); + assert!(bundle.contains("window.reloadVersion = 2;")); + assert!( + bundle + .find("window.__codexPlusUserScripts = { scripts: {} }") + .unwrap() + < bundle.find("window.reloadVersion = 2;").unwrap() + ); +} + +#[test] +fn user_script_reload_bundle_clears_runtime_when_all_scripts_are_disabled() { + let temp = tempfile::tempdir().unwrap(); + let user_dir = temp.path().join("user"); + std::fs::create_dir_all(&user_dir).unwrap(); + std::fs::write(user_dir.join("demo.js"), "window.shouldNotReload = true;").unwrap(); + let manager = UserScriptManager::new( + temp.path().join("builtin"), + user_dir, + temp.path().join("user_scripts.json"), + ); + manager.set_global_enabled(false).unwrap(); + + let bundle = manager.build_reload_bundle().unwrap(); + + assert!(bundle.contains("codex-plus-user-scripts-before-reload")); + assert!(bundle.contains("window.__codexPlusUserScripts = { scripts: {} }")); + assert!(!bundle.contains("window.shouldNotReload = true;")); +} + #[tokio::test] async fn core_runtime_open_devtools_uses_inspector_url_opener() { let opened = Arc::new(Mutex::new(Vec::::new())); diff --git a/crates/codex-plus-core/tests/cdp_bridge.rs b/crates/codex-plus-core/tests/cdp_bridge.rs index d988f23f5..da8ee76e2 100644 --- a/crates/codex-plus-core/tests/cdp_bridge.rs +++ b/crates/codex-plus-core/tests/cdp_bridge.rs @@ -2317,6 +2317,32 @@ fn pick_injectable_codex_page_target_accepts_chatgpt_desktop_error_page() { assert_eq!(picked.id, "chatgpt-error"); } +#[test] +fn pick_injectable_codex_page_target_prefers_native_app_over_manager_page() { + let targets = vec![ + target( + "manager", + "page", + "Codex++ 管理工具", + "http://127.0.0.1:1420/", + Some("ws://manager"), + ), + target( + "main", + "page", + "Codex", + "app://-/index.html", + Some("ws://main"), + ), + ]; + + let picked = pick_injectable_codex_page_target(&targets) + .expect("native Codex app page should outrank the manager page"); + + assert_eq!(picked.id, "main"); + assert!(!is_primary_codex_page_target(&targets[0])); +} + #[test] fn avatar_overlay_target_detection_is_narrow() { let overlay = target( From 71ee237ac07df4a525574527196e75054289aadd Mon Sep 17 00:00:00 2001 From: hcw <1416522360@qq.com> Date: Sat, 1 Aug 2026 00:04:36 +0800 Subject: [PATCH 2/2] fix: verify user scripts after startup injection --- apps/codex-plus-launcher/src/main.rs | 123 +++++++++++++++++- crates/codex-plus-core/src/user_scripts.rs | 31 +++++ crates/codex-plus-core/tests/bridge_routes.rs | 42 +++++- 3 files changed, 190 insertions(+), 6 deletions(-) diff --git a/apps/codex-plus-launcher/src/main.rs b/apps/codex-plus-launcher/src/main.rs index 489b6efb2..cd8bc454e 100644 --- a/apps/codex-plus-launcher/src/main.rs +++ b/apps/codex-plus-launcher/src/main.rs @@ -6,11 +6,16 @@ use codex_plus_core::launcher::{ }; use codex_plus_core::models::{DeleteResult, ExportResult, SessionRef}; use codex_plus_core::routes::{BridgeContext, BridgeDataService, BridgeRuntimeService}; -use codex_plus_core::user_scripts::UserScriptManager; +use codex_plus_core::user_scripts::{ + UserScriptManager, user_script_presence_expression, user_script_presence_from_evaluation, +}; use serde_json::{Value, json}; use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; +const USER_SCRIPT_STARTUP_CHECK_ATTEMPTS: usize = 3; +const USER_SCRIPT_STARTUP_CHECK_DELAY: std::time::Duration = std::time::Duration::from_secs(1); + #[derive(Clone)] struct LauncherHooks { core: Arc, @@ -562,6 +567,20 @@ impl LauncherRuntimeService { *self.websocket_url.lock().unwrap() = Some(websocket_url.to_string()); } + fn is_current_user_script_target(&self, websocket_url: &str, expected_keys: &[String]) -> bool { + let target_matches = self + .websocket_url + .lock() + .unwrap() + .as_deref() + .is_some_and(|current| current == websocket_url); + target_matches + && self + .user_scripts + .enabled_script_keys() + .is_ok_and(|current| current == expected_keys) + } + async fn reload_user_scripts_now(&self) -> anyhow::Result { let bundle = self.user_scripts.build_reload_bundle()?; let websocket_url = self.websocket_url.lock().unwrap().clone(); @@ -570,6 +589,87 @@ impl LauncherRuntimeService { } self.user_scripts.inventory() } + + fn start_user_script_startup_check( + self: &Arc, + websocket_url: String, + expected_keys: Vec, + ) { + if expected_keys.is_empty() { + return; + } + let runtime = self.clone(); + tokio::spawn(async move { + tokio::time::sleep(USER_SCRIPT_STARTUP_CHECK_DELAY).await; + let expression = match user_script_presence_expression(&expected_keys) { + Ok(expression) => expression, + Err(error) => { + let _ = codex_plus_core::diagnostic_log::append_diagnostic_log( + "user_scripts.startup_check_failed", + json!({ "error": error.to_string() }), + ); + return; + } + }; + + for attempt in 1..=USER_SCRIPT_STARTUP_CHECK_ATTEMPTS { + if !runtime.is_current_user_script_target(&websocket_url, &expected_keys) { + let _ = codex_plus_core::diagnostic_log::append_diagnostic_log( + "user_scripts.startup_check", + json!({ "status": "obsolete", "attempt": attempt }), + ); + return; + } + match user_scripts_are_present(&websocket_url, &expression).await { + Ok(true) => { + let _ = codex_plus_core::diagnostic_log::append_diagnostic_log( + "user_scripts.startup_check", + json!({ "status": "ok", "attempt": attempt }), + ); + return; + } + Ok(false) if attempt < USER_SCRIPT_STARTUP_CHECK_ATTEMPTS => { + let bundle = match runtime.user_scripts.build_reload_bundle() { + Ok(bundle) => bundle, + Err(error) => { + let _ = codex_plus_core::diagnostic_log::append_diagnostic_log( + "user_scripts.startup_reload_failed", + json!({ "attempt": attempt, "error": error.to_string() }), + ); + return; + } + }; + if let Err(error) = + codex_plus_core::bridge::evaluate_script(&websocket_url, &bundle).await + { + let _ = codex_plus_core::diagnostic_log::append_diagnostic_log( + "user_scripts.startup_reload_failed", + json!({ "attempt": attempt, "error": error.to_string() }), + ); + } + } + Ok(false) => break, + Err(error) => { + let _ = codex_plus_core::diagnostic_log::append_diagnostic_log( + "user_scripts.startup_check_failed", + json!({ "attempt": attempt, "error": error.to_string() }), + ); + } + } + tokio::time::sleep(USER_SCRIPT_STARTUP_CHECK_DELAY).await; + } + + let _ = codex_plus_core::diagnostic_log::append_diagnostic_log( + "user_scripts.startup_check", + json!({ "status": "missing", "expected_keys": expected_keys }), + ); + }); + } +} + +async fn user_scripts_are_present(websocket_url: &str, expression: &str) -> anyhow::Result { + let response = codex_plus_core::bridge::evaluate_script(websocket_url, expression).await?; + user_script_presence_from_evaluation(&response) } #[async_trait::async_trait] @@ -748,6 +848,7 @@ async fn try_inject_with_context( .unwrap_or_default(); let script = codex_plus_core::assets::injection_script_with_settings(helper_port, &settings); let user_bundle = runtime.user_scripts.build_enabled_bundle()?; + let expected_user_script_keys = runtime.user_scripts.enabled_script_keys()?; let new_document_scripts = if user_bundle.is_empty() { vec![script] } else { @@ -765,6 +866,7 @@ async fn try_inject_with_context( &new_document_scripts, ) .await?; + runtime.start_user_script_startup_check(websocket_url.to_string(), expected_user_script_keys); Ok(()) } @@ -884,13 +986,24 @@ mod tests { } #[test] - fn launcher_uses_event_driven_user_script_reload_without_file_polling() { + fn launcher_uses_finite_user_script_startup_check_without_file_polling() { let source = include_str!("main.rs"); + assert!(source.contains("start_user_script_startup_check")); + assert!(source.contains("USER_SCRIPT_STARTUP_CHECK_ATTEMPTS")); + assert!(source.contains("user_script_presence_expression")); assert!(source.contains("build_reload_bundle")); - assert!(!source.contains("start_user_script_hot_reload_watchdog")); - assert!(!source.contains("user_scripts.snapshot()")); - assert!(!source.contains("tokio::time::interval(std::time::Duration::from_secs(1))")); + for forbidden in [ + ["start_user_script_hot_reload", "_watchdog"].concat(), + ["user_scripts.", "snapshot()"].concat(), + [ + "tokio::time::interval(", + "std::time::Duration::from_secs(1))", + ] + .concat(), + ] { + assert!(!source.contains(&forbidden), "found {forbidden}"); + } } #[tokio::test] diff --git a/crates/codex-plus-core/src/user_scripts.rs b/crates/codex-plus-core/src/user_scripts.rs index 47d2d5321..e96f53b9e 100644 --- a/crates/codex-plus-core/src/user_scripts.rs +++ b/crates/codex-plus-core/src/user_scripts.rs @@ -231,6 +231,19 @@ impl UserScriptManager { )) } + pub fn enabled_script_keys(&self) -> anyhow::Result> { + let config = self.load_config(); + if !config.enabled { + return Ok(Vec::new()); + } + Ok(self + .scan_script_files(&config)? + .into_iter() + .filter(|script| script.enabled) + .map(|script| script.key) + .collect()) + } + fn scan_scripts( &self, config: &UserScriptConfig, @@ -340,6 +353,24 @@ struct UserScriptFile { enabled: bool, } +pub fn user_script_presence_expression(expected_keys: &[String]) -> anyhow::Result { + let expected_keys = serde_json::to_string(expected_keys)?; + Ok(format!( + r#"(() => {{ + const scripts = window.__codexPlusUserScripts && window.__codexPlusUserScripts.scripts; + const expected = {expected_keys}; + return !!scripts && expected.every((key) => Object.prototype.hasOwnProperty.call(scripts, key)); +}})()"# + )) +} + +pub fn user_script_presence_from_evaluation(response: &Value) -> anyhow::Result { + response + .pointer("/result/result/value") + .and_then(Value::as_bool) + .ok_or_else(|| anyhow::anyhow!("user script startup check returned no boolean result")) +} + fn wrap_script(script: &UserScriptFile, source: &str) -> String { format!( r#" diff --git a/crates/codex-plus-core/tests/bridge_routes.rs b/crates/codex-plus-core/tests/bridge_routes.rs index d05aed646..415d4ae68 100644 --- a/crates/codex-plus-core/tests/bridge_routes.rs +++ b/crates/codex-plus-core/tests/bridge_routes.rs @@ -11,7 +11,9 @@ use codex_plus_core::routes::{ }; use codex_plus_core::settings::BackendSettings; use codex_plus_core::status::StatusStore; -use codex_plus_core::user_scripts::UserScriptManager; +use codex_plus_core::user_scripts::{ + UserScriptManager, user_script_presence_expression, user_script_presence_from_evaluation, +}; use serde_json::{Value, json}; #[tokio::test] @@ -801,6 +803,44 @@ async fn core_runtime_toggling_user_script_reloads_current_page_bundle() { assert!(evaluated[0].contains("window.demoReloaded = true;")); } +#[test] +fn user_script_startup_presence_check_only_includes_enabled_scripts() { + let temp = tempfile::tempdir().unwrap(); + let user_dir = temp.path().join("user"); + std::fs::create_dir_all(&user_dir).unwrap(); + std::fs::write(user_dir.join("enabled.js"), "window.enabled = true;").unwrap(); + std::fs::write(user_dir.join("disabled.js"), "window.disabled = true;").unwrap(); + let manager = UserScriptManager::new( + temp.path().join("builtin"), + user_dir, + temp.path().join("user_scripts.json"), + ); + manager + .set_script_enabled("user:disabled.js", false) + .unwrap(); + + let keys = manager.enabled_script_keys().unwrap(); + let expression = user_script_presence_expression(&keys).unwrap(); + + assert_eq!(keys, vec!["user:enabled.js"]); + assert!(expression.contains(r#"["user:enabled.js"]"#)); + assert!(!expression.contains("user:disabled.js")); + + manager.set_global_enabled(false).unwrap(); + assert!(manager.enabled_script_keys().unwrap().is_empty()); +} + +#[test] +fn user_script_startup_presence_check_reads_boolean_cdp_result() { + let present = json!({"result": {"result": {"value": true}}}); + let missing = json!({"result": {"result": {"value": false}}}); + let malformed = json!({"result": {"result": {"type": "undefined"}}}); + + assert!(user_script_presence_from_evaluation(&present).unwrap()); + assert!(!user_script_presence_from_evaluation(&missing).unwrap()); + assert!(user_script_presence_from_evaluation(&malformed).is_err()); +} + #[test] fn user_script_reload_bundle_resets_runtime_registry_before_enabled_scripts() { let temp = tempfile::tempdir().unwrap();