From 3dcb625cedbb991d25dfa7e4d0329cd14ba01688 Mon Sep 17 00:00:00 2001 From: PDW Date: Wed, 27 May 2026 10:25:44 +0800 Subject: [PATCH 1/2] feat: minimize manager to system tray --- README.md | 7 +++ TRAY_FEATURE.md | 35 +++++++++++++ apps/codex-plus-manager/src-tauri/Cargo.toml | 2 +- apps/codex-plus-manager/src-tauri/src/lib.rs | 49 +++++++++++++++++++ .../src-tauri/tests/windows_subsystem.rs | 20 ++++++++ 5 files changed, 112 insertions(+), 1 deletion(-) create mode 100644 TRAY_FEATURE.md diff --git a/README.md b/README.md index 14d248ab..59ab3a03 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,12 @@ # Codex++ +> Fork note: this repository is based on +> [BigPizzaV3/CodexPlusPlus](https://github.com/BigPizzaV3/CodexPlusPlus) +> v1.1.8. This fork adds a system tray workflow for the Codex++ Manager: +> closing the manager window minimizes it to the Windows notification area, +> left-clicking the tray icon restores it, and the tray menu provides +> "Open" and "Exit" actions for an explicit shutdown. +

Codex++ 图标

diff --git a/TRAY_FEATURE.md b/TRAY_FEATURE.md new file mode 100644 index 00000000..c7c9ba11 --- /dev/null +++ b/TRAY_FEATURE.md @@ -0,0 +1,35 @@ +# Codex++ Manager Tray Minimize Feature + +This fork is based on [BigPizzaV3/CodexPlusPlus](https://github.com/BigPizzaV3/CodexPlusPlus) v1.1.8. + +## What Changed + +- The Codex++ Manager window no longer exits when the window close button is clicked. +- Closing the manager window hides it to the Windows system tray. +- Left-clicking the tray icon restores and focuses the manager window. +- The tray icon menu includes: + - `Open`: restore the manager window. + - `Exit`: fully close the manager process. + +## Implementation Notes + +- The feature is implemented in `apps/codex-plus-manager/src-tauri/src/lib.rs`. +- The Tauri `tray-icon` feature is enabled in `apps/codex-plus-manager/src-tauri/Cargo.toml`. +- A regression test was added in `apps/codex-plus-manager/src-tauri/tests/windows_subsystem.rs`. + +## Verification + +The following checks were run locally: + +```powershell +npm run check +npm run vite:build +cargo test -p codex-plus-manager --test windows_subsystem manager_close_button_minimizes_to_system_tray +cargo build -p codex-plus-manager --release --bin codex-plus-plus-manager +``` + +## License And Attribution + +This fork keeps the upstream MIT license and attribution. The original project is maintained at: + +https://github.com/BigPizzaV3/CodexPlusPlus diff --git a/apps/codex-plus-manager/src-tauri/Cargo.toml b/apps/codex-plus-manager/src-tauri/Cargo.toml index 184337a2..55420e6b 100644 --- a/apps/codex-plus-manager/src-tauri/Cargo.toml +++ b/apps/codex-plus-manager/src-tauri/Cargo.toml @@ -21,7 +21,7 @@ codex-plus-data = { path = "../../../crates/codex-plus-data" } directories.workspace = true serde.workspace = true serde_json.workspace = true -tauri = { version = "2", features = ["custom-protocol"] } +tauri = { version = "2", features = ["custom-protocol", "tray-icon"] } tauri-plugin-dialog = "2" [build-dependencies] diff --git a/apps/codex-plus-manager/src-tauri/src/lib.rs b/apps/codex-plus-manager/src-tauri/src/lib.rs index f118a799..5a81c12f 100644 --- a/apps/codex-plus-manager/src-tauri/src/lib.rs +++ b/apps/codex-plus-manager/src-tauri/src/lib.rs @@ -1,6 +1,10 @@ pub mod commands; pub mod install; +use tauri::menu::{Menu, MenuItem}; +use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}; +use tauri::{AppHandle, Manager, WindowEvent}; + pub fn run() { install_panic_logger(); let _ = codex_plus_core::diagnostic_log::append_diagnostic_log( @@ -16,6 +20,7 @@ pub fn run() { let run_result = tauri::Builder::default() .plugin(tauri_plugin_dialog::init()) .setup(move |app| { + install_tray(app)?; let url = if show_update { "index.html?showUpdate=1" } else { @@ -28,6 +33,12 @@ pub fn run() { .build()?; Ok(()) }) + .on_window_event(|window, event| { + if let WindowEvent::CloseRequested { api, .. } = event { + api.prevent_close(); + let _ = window.hide(); + } + }) .invoke_handler(tauri::generate_handler![ commands::backend_version, commands::startup_options, @@ -87,6 +98,44 @@ pub fn run() { } } +fn install_tray(app: &tauri::App) -> tauri::Result<()> { + let show = MenuItem::with_id(app, "show", "打开", true, None::<&str>)?; + let quit = MenuItem::with_id(app, "quit", "退出", true, None::<&str>)?; + let menu = Menu::new(app)?; + menu.append(&show)?; + menu.append(&quit)?; + + let mut tray = TrayIconBuilder::new().tooltip("Codex++ 管理工具"); + if let Some(icon) = app.default_window_icon() { + tray = tray.icon(icon.clone()); + } + tray.menu(&menu) + .on_menu_event(|app, event| match event.id().as_ref() { + "show" => show_main_window(app), + "quit" => app.exit(0), + _ => {} + }) + .on_tray_icon_event(|tray, event| { + if let TrayIconEvent::Click { + button: MouseButton::Left, + button_state: MouseButtonState::Up, + .. + } = event + { + show_main_window(tray.app_handle()); + } + }) + .build(app)?; + Ok(()) +} + +fn show_main_window(app: &AppHandle) { + if let Some(window) = app.get_webview_window("main") { + let _ = window.show(); + let _ = window.set_focus(); + } +} + fn install_panic_logger() { std::panic::set_hook(Box::new(|panic_info| { let payload = panic_info 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 48b88aff..8f414f94 100644 --- a/apps/codex-plus-manager/src-tauri/tests/windows_subsystem.rs +++ b/apps/codex-plus-manager/src-tauri/tests/windows_subsystem.rs @@ -31,6 +31,26 @@ fn manager_uses_single_instance_guard_before_starting_tauri() { assert!(lib_rs.contains("manager.already_running")); } +#[test] +fn manager_close_button_minimizes_to_system_tray() { + let lib_rs = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/src/lib.rs")) + .expect("read manager lib.rs"); + let cargo_toml = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/Cargo.toml")) + .expect("read manager Cargo.toml"); + + assert!(cargo_toml.contains("\"tray-icon\"")); + assert!(lib_rs.contains("TrayIconBuilder::new()")); + assert!(lib_rs.contains("on_window_event")); + assert!(lib_rs.contains("WindowEvent::CloseRequested")); + assert!(lib_rs.contains("api.prevent_close()")); + assert!(lib_rs.contains("window.hide()")); + assert!(lib_rs.contains("Menu::new(app)?")); + assert!(lib_rs.contains("MenuItem::with_id(app, \"show\"")); + assert!(lib_rs.contains("MenuItem::with_id(app, \"quit\"")); + assert!(lib_rs.contains("on_menu_event")); + assert!(lib_rs.contains("app.exit(0)")); +} + #[test] fn launcher_binary_embeds_codex_icon_resource() { let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")); From 17f422fb27ba7d5ea910630cf840f1291fd6d7c2 Mon Sep 17 00:00:00 2001 From: PDW Date: Wed, 27 May 2026 13:31:00 +0800 Subject: [PATCH 2/2] fix: preserve plugins across manager restarts --- crates/codex-plus-core/src/relay_config.rs | 68 ++++++++++++++++++-- crates/codex-plus-core/tests/relay_config.rs | 58 +++++++++++++++++ 2 files changed, 122 insertions(+), 4 deletions(-) diff --git a/crates/codex-plus-core/src/relay_config.rs b/crates/codex-plus-core/src/relay_config.rs index 5d4f4d3e..db40edea 100644 --- a/crates/codex-plus-core/src/relay_config.rs +++ b/crates/codex-plus-core/src/relay_config.rs @@ -798,15 +798,21 @@ fn write_codex_live_atomic( let config_path = home.join("config.toml"); let auth_path = home.join("auth.json"); - if let Some(config_text) = config_text { + let old_config = read_optional_bytes(&config_path)?; + let old_auth = read_optional_bytes(&auth_path)?; + let config_text = config_text + .map(|config_text| { + preserve_live_extension_config_sections(config_text, old_config.as_deref()) + }) + .transpose()?; + + if let Some(config_text) = config_text.as_deref() { validate_toml_config(config_text, &config_path)?; } if let Some(auth_bytes) = auth_bytes { validate_auth_json(auth_bytes, &auth_path)?; } - let old_config = read_optional_bytes(&config_path)?; - let old_auth = read_optional_bytes(&auth_path)?; let backup_path = create_live_backup(home, old_config.as_deref(), old_auth.as_deref())?; let mut auth_written = false; @@ -817,7 +823,7 @@ fn write_codex_live_atomic( auth_written = true; } - if let Some(config_text) = config_text { + if let Some(config_text) = config_text.as_deref() { if let Err(error) = crate::settings::atomic_write(&config_path, config_text.as_bytes()) { if auth_written { let _ = restore_optional_file(&auth_path, old_auth.as_deref()); @@ -830,6 +836,40 @@ fn write_codex_live_atomic( Ok(backup_path) } +fn preserve_live_extension_config_sections( + config_text: &str, + old_config: Option<&[u8]>, +) -> anyhow::Result { + let Some(old_config) = old_config else { + return Ok(config_text.to_string()); + }; + let Ok(old_config) = std::str::from_utf8(old_config) else { + return Ok(config_text.to_string()); + }; + if old_config.trim().is_empty() { + return Ok(config_text.to_string()); + } + + let mut target = parse_toml_document(config_text)?; + let Ok(source) = parse_toml_document(old_config) else { + return Ok(ensure_trailing_newline(target.to_string())); + }; + + for table_name in ["marketplaces", "mcp_servers", "skills", "plugins"] { + let Some(source_item) = source.get(table_name) else { + continue; + }; + match target.get_mut(table_name) { + Some(target_item) => merge_missing_toml_item(target_item, source_item), + None => { + target[table_name] = source_item.clone(); + } + } + } + + Ok(normalize_optional_toml(target)) +} + fn normalize_live_config_model_provider(home: &Path, config_text: &str) -> anyhow::Result { let current_config = read_optional_text(&home.join("config.toml"))?; normalize_config_model_provider(config_text, current_config.as_str()) @@ -1205,6 +1245,26 @@ fn merge_toml_item(target: &mut Item, source: &Item) { *target = source.clone(); } +fn merge_missing_toml_item(target: &mut Item, source: &Item) { + if let Some(source_table) = source.as_table_like() { + if let Some(target_table) = target.as_table_like_mut() { + merge_missing_toml_table_like(target_table, source_table); + return; + } + } +} + +fn merge_missing_toml_table_like(target: &mut dyn TableLike, source: &dyn TableLike) { + for (key, source_item) in source.iter() { + match target.get_mut(key) { + Some(target_item) => merge_missing_toml_item(target_item, source_item), + None => { + target.insert(key, source_item.clone()); + } + } + } +} + fn merge_toml_table_like(target: &mut dyn TableLike, source: &dyn TableLike) { for (key, source_item) in source.iter() { match target.get_mut(key) { diff --git a/crates/codex-plus-core/tests/relay_config.rs b/crates/codex-plus-core/tests/relay_config.rs index 58027e66..757dd24a 100644 --- a/crates/codex-plus-core/tests/relay_config.rs +++ b/crates/codex-plus-core/tests/relay_config.rs @@ -1534,6 +1534,64 @@ requires_openai_auth = true assert!(!config.contains(r#"model_provider = "max_ai""#)); } +#[test] +fn apply_relay_profile_to_home_preserves_live_plugin_marketplace_and_mcp_entries() { + let temp = tempfile::tempdir().unwrap(); + std::fs::write( + temp.path().join("config.toml"), + r#"model = "old" +model_provider = "CodexPlusPlus" + +[model_providers.CodexPlusPlus] +name = "CodexPlusPlus" +wire_api = "responses" +requires_openai_auth = true +base_url = "https://old.example/v1" +experimental_bearer_token = "sk-old" + +[marketplaces.openai-curated] +source_type = "git" +source = "https://github.com/openai/codex" + +[plugins."github@openai-curated"] +enabled = true + +[mcp_servers.live_tool] +command = "node" + +[skills.live_skill] +enabled = true +"#, + ) + .unwrap(); + let profile = RelayProfile { + id: "relay-a".to_string(), + relay_mode: RelayMode::PureApi, + config_contents: r#"model = "gpt-5.5" +model_provider = "CodexPlusPlus" + +[model_providers.CodexPlusPlus] +name = "CodexPlusPlus" +wire_api = "responses" +requires_openai_auth = true +base_url = "https://relay.example/v1" +"# + .to_string(), + auth_contents: r#"{"OPENAI_API_KEY":"sk-new"}"#.to_string(), + ..RelayProfile::default() + }; + + apply_relay_profile_to_home_with_switch_rules(temp.path(), &profile, "").unwrap(); + + let config = std::fs::read_to_string(temp.path().join("config.toml")).unwrap(); + assert!(config.contains(r#"model = "gpt-5.5""#)); + assert!(config.contains(r#"base_url = "https://relay.example/v1""#)); + assert!(config.contains("[marketplaces.openai-curated]")); + assert!(config.contains("[plugins.\"github@openai-curated\"]")); + assert!(config.contains("[mcp_servers.live_tool]")); + assert!(config.contains("[skills.live_skill]")); +} + #[test] fn apply_relay_profile_to_home_with_switch_rules_writes_provider_even_when_auth_has_no_api_key() { let temp = tempfile::tempdir().unwrap();