From c0f590c1924e35de6d6525ac738f51b98fd51772 Mon Sep 17 00:00:00 2001 From: hcw <1416522360@qq.com> Date: Wed, 8 Jul 2026 23:51:59 +0800 Subject: [PATCH 1/2] feat: hot reload user scripts --- apps/codex-plus-launcher/src/main.rs | 96 +++++++++++++++++-- crates/codex-plus-core/src/routes.rs | 6 +- crates/codex-plus-core/src/user_scripts.rs | 69 +++++++++++++ crates/codex-plus-core/tests/bridge_routes.rs | 66 +++++++++++++ 4 files changed, 224 insertions(+), 13 deletions(-) diff --git a/apps/codex-plus-launcher/src/main.rs b/apps/codex-plus-launcher/src/main.rs index 2fafb6992..05668c4bb 100644 --- a/apps/codex-plus-launcher/src/main.rs +++ b/apps/codex-plus-launcher/src/main.rs @@ -11,6 +11,7 @@ use serde_json::{Value, json}; #[cfg(windows)] use std::os::windows::process::CommandExt; use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; #[derive(Clone)] @@ -499,6 +500,8 @@ struct LauncherRuntimeService { debug_port: Mutex, websocket_url: Mutex>, user_scripts: UserScriptManager, + user_script_snapshot: Mutex>, + user_script_hot_reload_started: AtomicBool, } impl LauncherRuntimeService { @@ -507,6 +510,8 @@ impl LauncherRuntimeService { debug_port: Mutex::new(debug_port), websocket_url: Mutex::new(None), user_scripts, + user_script_snapshot: Mutex::new(None), + user_script_hot_reload_started: AtomicBool::new(false), } } @@ -517,6 +522,69 @@ 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_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.remember_user_script_snapshot(); + self.user_scripts.inventory() + } + + fn remember_user_script_snapshot(&self) { + match self.user_scripts.snapshot() { + Ok(snapshot) => { + *self.user_script_snapshot.lock().unwrap() = Some(snapshot); + } + Err(error) => { + let _ = codex_plus_core::diagnostic_log::append_diagnostic_log( + "user_scripts.snapshot_failed", + json!({ "error": error.to_string() }), + ); + } + } + } + + async fn reload_user_scripts_if_changed(&self) -> anyhow::Result { + let current = self.user_scripts.snapshot()?; + let changed = { + let snapshot = self.user_script_snapshot.lock().unwrap(); + !matches!(snapshot.as_ref(), Some(previous) if previous == ¤t) + }; + if !changed { + return Ok(false); + } + self.reload_user_scripts_now().await?; + let _ = codex_plus_core::diagnostic_log::append_diagnostic_log( + "user_scripts.hot_reload", + json!({ "status": "ok" }), + ); + Ok(true) + } + + fn start_user_script_hot_reload_watchdog(self: &Arc) { + if self + .user_script_hot_reload_started + .swap(true, Ordering::SeqCst) + { + return; + } + let runtime = self.clone(); + tokio::spawn(async move { + let mut interval = tokio::time::interval(std::time::Duration::from_secs(1)); + loop { + interval.tick().await; + if let Err(error) = runtime.reload_user_scripts_if_changed().await { + let _ = codex_plus_core::diagnostic_log::append_diagnostic_log( + "user_scripts.hot_reload_failed", + json!({ "error": error.to_string() }), + ); + } + } + }); + } } #[async_trait::async_trait] @@ -527,26 +595,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 { @@ -708,7 +771,10 @@ async fn try_inject_with_context( }), &new_document_scripts, ) - .await + .await?; + runtime.remember_user_script_snapshot(); + runtime.start_user_script_hot_reload_watchdog(); + Ok(()) } fn default_codex_db_path() -> PathBuf { @@ -826,6 +892,16 @@ mod tests { assert!(source.contains(".start_computer_use_guard_watchdog(settings)")); } + #[test] + fn launcher_starts_user_script_hot_reload_watchdog_after_bridge_injection() { + let source = include_str!("main.rs"); + + assert!(source.contains("start_user_script_hot_reload_watchdog")); + assert!(source.contains("reload_user_scripts_if_changed")); + assert!(source.contains("user_scripts.snapshot()")); + assert!(source.contains("user_script_hot_reload_started")); + } + #[test] fn manager_update_prompt_uses_sidecar_manager_binary_name() { let path = manager_exe_path(); diff --git a/crates/codex-plus-core/src/routes.rs b/crates/codex-plus-core/src/routes.rs index 353efdeaa..389725647 100644 --- a/crates/codex-plus-core/src/routes.rs +++ b/crates/codex-plus-core/src/routes.rs @@ -379,7 +379,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(); @@ -393,7 +393,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()), } @@ -403,7 +403,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()), } diff --git a/crates/codex-plus-core/src/user_scripts.rs b/crates/codex-plus-core/src/user_scripts.rs index 2be3db4ed..906b9a9f8 100644 --- a/crates/codex-plus-core/src/user_scripts.rs +++ b/crates/codex-plus-core/src/user_scripts.rs @@ -1,5 +1,7 @@ use std::collections::BTreeMap; +use std::collections::hash_map::DefaultHasher; use std::fs; +use std::hash::{Hash, Hasher}; use std::path::PathBuf; use std::sync::{Arc, Mutex}; @@ -45,6 +47,11 @@ pub struct UserScriptManager { config_lock: Arc>, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UserScriptSnapshot { + digest: u64, +} + impl UserScriptManager { pub fn new( builtin_dir: impl Into, @@ -203,6 +210,16 @@ impl UserScriptManager { Ok(blocks.join("\n")) } + pub fn snapshot(&self) -> anyhow::Result { + let mut hasher = DefaultHasher::new(); + hash_script_config(&self.config_path, &mut hasher)?; + hash_script_directory("builtin", &self.builtin_dir, &mut hasher)?; + hash_script_directory("user", &self.user_dir, &mut hasher)?; + Ok(UserScriptSnapshot { + digest: hasher.finish(), + }) + } + fn scan_scripts(&self, config: &UserScriptConfig) -> anyhow::Result> { Ok(self .scan_script_files(config)? @@ -283,6 +300,58 @@ impl UserScriptManager { } } +fn hash_script_config(path: &std::path::Path, hasher: &mut DefaultHasher) -> anyhow::Result<()> { + "config".hash(hasher); + hash_optional_file(path, hasher) +} + +fn hash_script_directory( + source: &str, + directory: &std::path::Path, + hasher: &mut DefaultHasher, +) -> anyhow::Result<()> { + source.hash(hasher); + let Ok(entries) = fs::read_dir(directory) else { + "missing".hash(hasher); + return Ok(()); + }; + let mut paths = entries + .filter_map(Result::ok) + .map(|entry| entry.path()) + .filter(|path| path.extension().and_then(|value| value.to_str()) == Some("js")) + .collect::>(); + paths.sort_by_key(|path| { + path.file_name() + .map(|name| name.to_string_lossy().to_lowercase()) + .unwrap_or_default() + }); + + for path in paths { + hash_optional_file(&path, hasher)?; + } + Ok(()) +} + +fn hash_optional_file(path: &std::path::Path, hasher: &mut DefaultHasher) -> anyhow::Result<()> { + path.file_name() + .map(|name| name.to_string_lossy().to_string()) + .unwrap_or_default() + .hash(hasher); + match fs::read(path) { + Ok(content) => { + "present".hash(hasher); + content.hash(hasher); + Ok(()) + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + "missing".hash(hasher); + Ok(()) + } + Err(error) => Err(error) + .with_context(|| format!("failed to read user script snapshot {}", path.display())), + } +} + #[derive(Debug)] struct UserScriptFile { key: String, diff --git a/crates/codex-plus-core/tests/bridge_routes.rs b/crates/codex-plus-core/tests/bridge_routes.rs index 98d6e5cc6..ec5817d41 100644 --- a/crates/codex-plus-core/tests/bridge_routes.rs +++ b/crates/codex-plus-core/tests/bridge_routes.rs @@ -720,6 +720,72 @@ 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_snapshot_changes_when_script_or_config_changes() { + 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.version = 1;").unwrap(); + let manager = UserScriptManager::new( + temp.path().join("builtin"), + user_dir, + temp.path().join("user_scripts.json"), + ); + + let initial = manager.snapshot().unwrap(); + std::fs::write( + temp.path().join("user").join("demo.js"), + "window.version = 2;", + ) + .unwrap(); + let changed_script = manager.snapshot().unwrap(); + manager.set_script_enabled("user:demo.js", false).unwrap(); + let changed_config = manager.snapshot().unwrap(); + + assert_ne!(initial, changed_script); + assert_ne!(changed_script, changed_config); +} + #[tokio::test] async fn core_runtime_open_devtools_uses_inspector_url_opener() { let opened = Arc::new(Mutex::new(Vec::::new())); From 946197cee7f0ab70e05e4f86f187f2065df64a52 Mon Sep 17 00:00:00 2001 From: hcw <1416522360@qq.com> Date: Thu, 9 Jul 2026 00:18:08 +0800 Subject: [PATCH 2/2] test: stabilize ad fallback server --- crates/codex-plus-core/tests/ads.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/codex-plus-core/tests/ads.rs b/crates/codex-plus-core/tests/ads.rs index 55db779e6..3c1eac7a3 100644 --- a/crates/codex-plus-core/tests/ads.rs +++ b/crates/codex-plus-core/tests/ads.rs @@ -136,7 +136,9 @@ async fn fetch_ad_list_tries_backup_url_when_primary_fails() { let request = String::from_utf8_lossy(&buffer[..read]); if request.starts_with("GET /primary.json?") { stream - .write_all(b"HTTP/1.1 503 Service Unavailable\r\nContent-Length: 0\r\n\r\n") + .write_all( + b"HTTP/1.1 503 Service Unavailable\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + ) .unwrap(); } else { assert!(request.starts_with("GET /backup.json?"), "{request}"); @@ -153,7 +155,7 @@ async fn fetch_ad_list_tries_backup_url_when_primary_fails() { }) .to_string(); let response = format!( - "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}", + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", body.len(), body );