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
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -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.

<p align="center">
<img src="docs/images/codex-plus-plus.png" alt="Codex++ 图标" width="160">
</p>
Expand Down
35 changes: 35 additions & 0 deletions TRAY_FEATURE.md
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion apps/codex-plus-manager/src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
49 changes: 49 additions & 0 deletions apps/codex-plus-manager/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
@@ -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(
Expand All @@ -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 {
Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand Down
20 changes: 20 additions & 0 deletions apps/codex-plus-manager/src-tauri/tests/windows_subsystem.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"));
Expand Down
68 changes: 64 additions & 4 deletions crates/codex-plus-core/src/relay_config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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());
Expand All @@ -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<String> {
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<String> {
let current_config = read_optional_text(&home.join("config.toml"))?;
normalize_config_model_provider(config_text, current_config.as_str())
Expand Down Expand Up @@ -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) {
Expand Down
58 changes: 58 additions & 0 deletions crates/codex-plus-core/tests/relay_config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down