Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 86 additions & 10 deletions apps/codex-plus-launcher/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down Expand Up @@ -499,6 +500,8 @@ struct LauncherRuntimeService {
debug_port: Mutex<u16>,
websocket_url: Mutex<Option<String>>,
user_scripts: UserScriptManager,
user_script_snapshot: Mutex<Option<codex_plus_core::user_scripts::UserScriptSnapshot>>,
user_script_hot_reload_started: AtomicBool,
}

impl LauncherRuntimeService {
Expand All @@ -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),
}
}

Expand All @@ -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<Value> {
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<bool> {
let current = self.user_scripts.snapshot()?;
let changed = {
let snapshot = self.user_script_snapshot.lock().unwrap();
!matches!(snapshot.as_ref(), Some(previous) if previous == &current)
};
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<Self>) {
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]
Expand All @@ -527,26 +595,21 @@ impl BridgeRuntimeService for LauncherRuntimeService {

async fn set_user_scripts_enabled(&self, enabled: bool) -> anyhow::Result<Value> {
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<Value> {
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<Value> {
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<Value> {
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<Value> {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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();
Expand Down
6 changes: 3 additions & 3 deletions crates/codex-plus-core/src/routes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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()),
}
Expand All @@ -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()),
}
Expand Down
69 changes: 69 additions & 0 deletions crates/codex-plus-core/src/user_scripts.rs
Original file line number Diff line number Diff line change
@@ -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};

Expand Down Expand Up @@ -45,6 +47,11 @@ pub struct UserScriptManager {
config_lock: Arc<Mutex<()>>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UserScriptSnapshot {
digest: u64,
}

impl UserScriptManager {
pub fn new(
builtin_dir: impl Into<PathBuf>,
Expand Down Expand Up @@ -203,6 +210,16 @@ impl UserScriptManager {
Ok(blocks.join("\n"))
}

pub fn snapshot(&self) -> anyhow::Result<UserScriptSnapshot> {
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<Vec<Value>> {
Ok(self
.scan_script_files(config)?
Expand Down Expand Up @@ -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::<Vec<_>>();
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,
Expand Down
6 changes: 4 additions & 2 deletions crates/codex-plus-core/tests/ads.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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}");
Expand All @@ -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
);
Expand Down
66 changes: 66 additions & 0 deletions crates/codex-plus-core/tests/bridge_routes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<String>::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::<String>::new()));
Expand Down
Loading