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.
+
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"));
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();