Skip to content
Closed
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
7 changes: 7 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
Expand Up @@ -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
155 changes: 140 additions & 15 deletions apps/codex-plus-launcher/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<DefaultLaunchHooks>,
Expand Down Expand Up @@ -561,6 +566,110 @@ impl LauncherRuntimeService {
fn set_websocket_url(&self, websocket_url: &str) {
*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<Value> {
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()
}

fn start_user_script_startup_check(
self: &Arc<Self>,
websocket_url: String,
expected_keys: Vec<String>,
) {
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<bool> {
let response = codex_plus_core::bridge::evaluate_script(websocket_url, expression).await?;
user_script_presence_from_evaluation(&response)
}

#[async_trait::async_trait]
Expand All @@ -579,26 +688,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 @@ -743,10 +847,8 @@ 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 expected_user_script_keys = runtime.user_scripts.enabled_script_keys()?;
let new_document_scripts = if user_bundle.is_empty() {
vec![script]
} else {
Expand All @@ -763,7 +865,9 @@ async fn try_inject_with_context(
}),
&new_document_scripts,
)
.await
.await?;
runtime.start_user_script_startup_check(websocket_url.to_string(), expected_user_script_keys);
Ok(())
}

fn default_codex_db_path() -> PathBuf {
Expand Down Expand Up @@ -881,6 +985,27 @@ mod tests {
assert!(source.contains(".start_computer_use_guard_watchdog(settings)"));
}

#[test]
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"));
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]
async fn watchdog_reuses_bridge_context_with_data_service() {
let test_dir = std::env::temp_dir().join(format!(
Expand Down
101 changes: 85 additions & 16 deletions apps/codex-plus-manager/src-tauri/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -2068,7 +2074,10 @@ pub async fn refresh_script_market() -> CommandResult<ScriptMarketPayload> {
}

#[tauri::command]
pub async fn install_market_script(id: String) -> CommandResult<ScriptMarketPayload> {
pub async fn install_market_script(
id: String,
debug_port: u16,
) -> CommandResult<ScriptMarketPayload> {
let trimmed = id.trim();
if trimmed.is_empty() {
return failed(
Expand All @@ -2094,10 +2103,13 @@ pub async fn install_market_script(id: String) -> CommandResult<ScriptMarketPayl
};
let manager = default_user_script_manager();
match script_market::install_market_script(&manager, script).await {
Ok(()) => 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(
Expand All @@ -2110,21 +2122,29 @@ pub async fn install_market_script(id: String) -> CommandResult<ScriptMarketPayl
}

#[tauri::command]
pub fn set_user_script_enabled(key: String, enabled: bool) -> CommandResult<SettingsPayload> {
pub async fn set_user_script_enabled(
key: String,
enabled: bool,
debug_port: u16,
) -> CommandResult<SettingsPayload> {
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(),
Expand All @@ -2133,21 +2153,70 @@ pub fn set_user_script_enabled(key: String, enabled: bool) -> CommandResult<Sett
}

#[tauri::command]
pub fn delete_user_script(key: String) -> CommandResult<SettingsPayload> {
pub async fn delete_user_script(key: String, debug_port: u16) -> CommandResult<SettingsPayload> {
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(),
),
}
}

#[tauri::command]
pub async fn reload_user_scripts(
request: UserScriptReloadRequest,
) -> CommandResult<SettingsPayload> {
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<Value> {
let trimmed = url.trim();
Expand Down
1 change: 1 addition & 0 deletions apps/codex-plus-manager/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading