From 65c977037df84ec6a4f8115e8a803c629118ae39 Mon Sep 17 00:00:00 2001 From: Factory Agent Date: Fri, 23 Oct 2026 13:24:54 +0700 Subject: [PATCH 1/3] Add proof-only tray menu dump and activation Let automation read and activate the native tray menu without a right-click, which would take focus. - CODEXBAR_PROOF_TRAY_MENU_DUMP= writes the live menu as JSON in proof mode after setup and each menu rebuild. Atomic write, with a per-process generation and a UTC timestamp. - proof_activate_tray_item(id) runs the native click handler on the main thread. It is refused outside proof mode and for ids that do not resolve to a menu action. - Tests: dump format, the activation gate, and drift guards that every actionable spec and account id resolves and status rows do not. --- AGENTS.md | 2 +- apps/desktop-tauri/src-tauri/src/main.rs | 2 + .../src-tauri/src/proof_tray_menu.rs | 320 ++++++++++++++++++ .../src-tauri/src/tray_accounts.rs | 91 +++++ .../src-tauri/src/tray_bridge.rs | 103 +++++- apps/desktop-tauri/src-tauri/src/tray_menu.rs | 20 +- docs/WINDOWS_PROOF.md | 44 +++ 7 files changed, 575 insertions(+), 7 deletions(-) create mode 100644 apps/desktop-tauri/src-tauri/src/proof_tray_menu.rs diff --git a/AGENTS.md b/AGENTS.md index 0c472cd2660..42a031a4eb5 100755 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,7 +25,7 @@ Win-CodexBar is the Windows port of CodexBar: a tray app that shows AI-provider - **Settings.** `%APPDATA%\CodexBar\settings.json` is read and written by `Settings::load` / `save` through `secure_file`, which wraps it with DPAPI on Windows. The frontend sends an `updateSettings` patch; the shell saves it and the UI reloads on `codexbar:settings-updated` and the float-bar config events. - **Tray.** `tray_bridge` and `tray_menu` own the icon and the menu. The icon pixels come from the shared `codexbar::tray::{render_bar_icon_rgba, render_percent_icon_rgba}`. - **Float bar.** `floatbar/` owns the always-on-top window. Its builder must pin `.theme(Some(tauri::Theme::Dark))`. WebView2 resolves `prefers-color-scheme` on a process profile shared by all windows, so an unpinned float bar flips the other windows when the theme is `auto`. Call this failure the **theme flip**. -- **Proof harness.** `CODEXBAR_PROOF_MODE` (for example `settings:menu`) opens one surface and turns off blur-dismiss for automation. `CODEXBAR_SEED_USAGE_JSON=` seeds synthetic bridge-shaped `ProviderUsageSnapshot` data into the provider cache at launch and into the tray icon and tooltip. Seeds are pinned per provider: a normal refresh fetches only the unseeded enabled providers, and a forced refresh replaces them. A malformed seed file logs a warning and is skipped. In proof mode, `CODEXBAR_PROOF_WINDOW_ORIGIN=,` (physical pixels) opens the flyout, Settings and float bar at that point on the monitor that contains it. See [docs/WINDOWS_PROOF.md](docs/WINDOWS_PROOF.md#proof-mode). +- **Proof harness.** `CODEXBAR_PROOF_MODE` (for example `settings:menu`) opens one surface and turns off blur-dismiss for automation. `CODEXBAR_SEED_USAGE_JSON=` seeds synthetic bridge-shaped `ProviderUsageSnapshot` data into the provider cache at launch and into the tray icon and tooltip. Seeds are pinned per provider: a normal refresh fetches only the unseeded enabled providers, and a forced refresh replaces them. A malformed seed file logs a warning and is skipped. In proof mode, `CODEXBAR_PROOF_WINDOW_ORIGIN=,` (physical pixels) opens the flyout, Settings and float bar at that point on the monitor that contains it. `CODEXBAR_PROOF_TRAY_MENU_DUMP=` writes the live tray menu as JSON. See [docs/WINDOWS_PROOF.md](docs/WINDOWS_PROOF.md#proof-mode). ## Key directories diff --git a/apps/desktop-tauri/src-tauri/src/main.rs b/apps/desktop-tauri/src-tauri/src/main.rs index 632a84e8fce..291cd692c14 100644 --- a/apps/desktop-tauri/src-tauri/src/main.rs +++ b/apps/desktop-tauri/src-tauri/src/main.rs @@ -12,6 +12,7 @@ mod geometry_store; mod http_server; mod powertoys; mod proof_harness; +mod proof_tray_menu; mod shell; mod shortcut_bridge; mod state; @@ -353,6 +354,7 @@ fn main() { commands::set_ui_language, commands::open_path, tray_visibility::tray_visibility_status, + tray_bridge::proof_activate_tray_item, floatbar::resize_float_bar, ]) .setup(move |app| { diff --git a/apps/desktop-tauri/src-tauri/src/proof_tray_menu.rs b/apps/desktop-tauri/src-tauri/src/proof_tray_menu.rs new file mode 100644 index 00000000000..a8440b4b016 --- /dev/null +++ b/apps/desktop-tauri/src-tauri/src/proof_tray_menu.rs @@ -0,0 +1,320 @@ +//! Proof-only readback of the native tray context menu. +//! +//! Automation can't open the tray menu without a right-click, and a right-click +//! needs `SetForegroundWindow`, which steals the user's focus. In proof mode, +//! `CODEXBAR_PROOF_TRAY_MENU_DUMP=` makes the shell write the +//! live menu as JSON after every menu rebuild. The matching activation command +//! is `tray_bridge::proof_activate_tray_item`. +//! +//! Nothing here runs unless proof mode is active and the env var is set. + +use std::io; +use std::path::{Path, PathBuf}; +use std::sync::{Mutex, PoisonError}; + +use serde::Serialize; +use tauri::menu::{Menu, MenuItemKind}; +use tauri::{AppHandle, Wry}; + +#[cfg(test)] +use crate::tray_menu::TrayMenuEntry; + +pub(crate) const DUMP_ENV_VAR: &str = "CODEXBAR_PROOF_TRAY_MENU_DUMP"; + +/// Generation of the last dump written by this process. It is held across the +/// file write so generation order matches file order. Never held while the +/// menu is read, because those reads may wait on the main thread. +static GENERATION: Mutex = Mutex::new(0); + +/// Top-level dump document. +#[derive(Debug, Clone, PartialEq, Serialize)] +pub(crate) struct TrayMenuDump { + pub(crate) generation: u64, + /// RFC 3339 UTC time of the write. + pub(crate) timestamp: String, + pub(crate) items: Vec, +} + +/// One menu row. `kind` is `item`, `check`, `submenu`, `separator` or `icon`. +/// `checked` is `null` unless `kind` is `check`. `id` is `null` for separators. +/// `children` is empty unless `kind` is `submenu`. +#[derive(Debug, Clone, PartialEq, Serialize)] +pub(crate) struct DumpNode { + pub(crate) id: Option, + pub(crate) text: String, + pub(crate) kind: &'static str, + pub(crate) enabled: bool, + pub(crate) checked: Option, + pub(crate) children: Vec, +} + +/// Dump path for this process, or `None` when the dump is off. +/// +/// Outside proof mode the dump is off. A relative path is rejected, because it +/// would resolve against an unpredictable working directory. +pub(crate) fn dump_target(proof_mode: bool, raw: &str) -> Option { + if !proof_mode { + return None; + } + let raw = raw.trim(); + if raw.is_empty() { + return None; + } + let path = PathBuf::from(raw); + if !path.is_absolute() { + tracing::warn!("{} must be an absolute path; ignoring it", DUMP_ENV_VAR); + return None; + } + Some(path) +} + +/// Dump nodes built from the menu spec. This mirrors how `tray_bridge` turns +/// each `TrayMenuEntry` into a native item, so the spec tests can pin the format. +#[cfg(test)] +pub(crate) fn nodes_from_spec(entries: &[TrayMenuEntry]) -> Vec { + entries.iter().map(node_from_spec).collect() +} + +#[cfg(test)] +fn node_from_spec(entry: &TrayMenuEntry) -> DumpNode { + if entry.is_separator { + return separator_node(); + } + let kind = if !entry.children.is_empty() { + "submenu" + } else if entry.checked.is_some() { + "check" + } else { + "item" + }; + DumpNode { + id: entry.id.clone(), + text: entry.label.clone(), + kind, + enabled: !entry.disabled, + checked: entry.checked, + children: nodes_from_spec(&entry.children), + } +} + +/// Dump nodes read back from the live native menu. Text and state come from the +/// tauri objects, so the dump shows what is actually set on the tray. +pub(crate) fn nodes_from_native(items: &[MenuItemKind]) -> tauri::Result> { + items.iter().map(node_from_native).collect() +} + +fn node_from_native(item: &MenuItemKind) -> tauri::Result { + Ok(match item { + MenuItemKind::MenuItem(item) => DumpNode { + id: Some(item.id().as_ref().to_string()), + text: item.text()?, + kind: "item", + enabled: item.is_enabled()?, + checked: None, + children: Vec::new(), + }, + MenuItemKind::Check(item) => DumpNode { + id: Some(item.id().as_ref().to_string()), + text: item.text()?, + kind: "check", + enabled: item.is_enabled()?, + checked: Some(item.is_checked()?), + children: Vec::new(), + }, + MenuItemKind::Submenu(submenu) => DumpNode { + id: Some(submenu.id().as_ref().to_string()), + text: submenu.text()?, + kind: "submenu", + enabled: submenu.is_enabled()?, + checked: None, + children: nodes_from_native(&submenu.items()?)?, + }, + MenuItemKind::Icon(item) => DumpNode { + id: Some(item.id().as_ref().to_string()), + text: item.text()?, + kind: "icon", + enabled: item.is_enabled()?, + checked: None, + children: Vec::new(), + }, + MenuItemKind::Predefined(_) => separator_node(), + }) +} + +fn separator_node() -> DumpNode { + DumpNode { + id: None, + text: String::new(), + kind: "separator", + enabled: true, + checked: None, + children: Vec::new(), + } +} + +/// Write the live tray menu to the dump path. Call it right after the menu is +/// set on the tray. Does nothing unless the env var is set and proof mode is on. +pub(crate) fn write_if_enabled(app: &AppHandle, menu: &Menu) { + let Some(raw) = std::env::var(DUMP_ENV_VAR).ok() else { + return; + }; + let Some(path) = dump_target(crate::proof_harness::is_proof_mode(app), &raw) else { + return; + }; + let items = match menu.items().and_then(|items| nodes_from_native(&items)) { + Ok(items) => items, + Err(error) => { + tracing::warn!(%error, "proof: failed to read the tray menu"); + return; + } + }; + let mut generation = GENERATION.lock().unwrap_or_else(PoisonError::into_inner); + *generation += 1; + let dump = TrayMenuDump { + generation: *generation, + timestamp: chrono::Utc::now().to_rfc3339(), + items, + }; + if let Err(error) = write_atomic(&path, &dump) { + tracing::warn!(%error, "proof: failed to write the tray menu dump"); + } +} + +/// Write the dump to a temp file beside `path`, then rename it over `path`, so +/// a reader sees either the previous dump or the new one. +fn write_atomic(path: &Path, dump: &TrayMenuDump) -> io::Result<()> { + let bytes = serde_json::to_vec_pretty(dump).map_err(io::Error::other)?; + let file_name = path + .file_name() + .ok_or_else(|| io::Error::other("dump path has no file name"))?; + let temp = path.with_file_name(format!( + ".{}.{}.tmp", + file_name.to_string_lossy(), + std::process::id() + )); + std::fs::write(&temp, bytes)?; + std::fs::rename(&temp, path) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::tray_menu::tests::sample_provider_catalog; + use crate::tray_menu::{MenuState, build_tray_menu_with}; + use codexbar::settings::Language; + use serde_json::json; + + #[test] + fn dump_target_requires_proof_mode_and_an_absolute_path() { + let absolute = if cfg!(windows) { + r"C:\proof\tray-menu.json" + } else { + "/tmp/tray-menu.json" + }; + assert_eq!(dump_target(false, absolute), None); + assert_eq!(dump_target(true, ""), None); + assert_eq!(dump_target(true, " "), None); + assert_eq!(dump_target(true, "relative/tray-menu.json"), None); + assert_eq!(dump_target(true, absolute), Some(PathBuf::from(absolute))); + } + + #[test] + fn dump_serializes_each_row_kind_with_checked_state() { + let items = vec![ + TrayMenuEntry::status_row("status_claude", "Claude 60%"), + TrayMenuEntry::separator(), + TrayMenuEntry::item("refresh", "Refresh all"), + TrayMenuEntry::check_item("toggle_float_bar", "Show Float Bar", true), + TrayMenuEntry::submenu( + "providers", + "Providers", + vec![TrayMenuEntry::check_item( + "toggle_provider:codex", + "Codex", + false, + )], + ), + ]; + let dump = TrayMenuDump { + generation: 7, + timestamp: "2026-01-02T03:04:05+00:00".into(), + items: nodes_from_spec(&items), + }; + assert_eq!( + serde_json::to_value(&dump).unwrap(), + json!({ + "generation": 7, + "timestamp": "2026-01-02T03:04:05+00:00", + "items": [ + {"id": "status_claude", "text": "Claude 60%", "kind": "item", + "enabled": false, "checked": null, "children": []}, + {"id": null, "text": "", "kind": "separator", + "enabled": true, "checked": null, "children": []}, + {"id": "refresh", "text": "Refresh all", "kind": "item", + "enabled": true, "checked": null, "children": []}, + {"id": "toggle_float_bar", "text": "Show Float Bar", "kind": "check", + "enabled": true, "checked": true, "children": []}, + {"id": "providers", "text": "Providers", "kind": "submenu", + "enabled": true, "checked": null, "children": [ + {"id": "toggle_provider:codex", "text": "Codex", "kind": "check", + "enabled": true, "checked": false, "children": []} + ]} + ] + }) + ); + } + + #[test] + fn dump_of_built_menu_lists_status_rows_then_actions() { + let labels = vec![("claude".to_string(), "Claude 60%".to_string())]; + let enabled = ["claude".to_string()].into_iter().collect(); + let spec = build_tray_menu_with( + &sample_provider_catalog(), + &labels, + &enabled, + MenuState { + float_bar_enabled: true, + ..MenuState::default() + }, + Language::English, + ); + let items = nodes_from_spec(&spec); + let ids: Vec> = items.iter().map(|node| node.id.as_deref()).collect(); + assert_eq!( + ids, + vec![ + Some("status_claude"), + None, + Some("refresh"), + Some("pop_out"), + Some("toggle_float_bar"), + None, + Some("providers"), + None, + Some("settings"), + Some("check_for_updates"), + Some("about"), + None, + Some("quit"), + ] + ); + let providers = items + .iter() + .find(|node| node.id.as_deref() == Some("providers")) + .expect("providers submenu"); + assert_eq!(providers.kind, "submenu"); + let claude = providers + .children + .iter() + .find(|node| node.id.as_deref() == Some("toggle_provider:claude")) + .expect("claude toggle"); + assert_eq!(claude.kind, "check"); + assert_eq!(claude.checked, Some(true)); + let codex = providers + .children + .iter() + .find(|node| node.id.as_deref() == Some("toggle_provider:codex")) + .expect("codex toggle"); + assert_eq!(codex.checked, Some(false)); + } +} diff --git a/apps/desktop-tauri/src-tauri/src/tray_accounts.rs b/apps/desktop-tauri/src-tauri/src/tray_accounts.rs index 4c2dcf062b9..285517e41c2 100644 --- a/apps/desktop-tauri/src-tauri/src/tray_accounts.rs +++ b/apps/desktop-tauri/src-tauri/src/tray_accounts.rs @@ -484,6 +484,97 @@ mod tests { assert!(resolve_action("switch_grok_account:").is_none()); } + #[test] + fn account_submenu_actionable_ids_resolve_and_empty_rows_do_not() { + use codexbar::codex_accounts::{CodexAccountSource, utc_now}; + use codexbar::providers::claude::accounts::ClaudeAccount; + use codexbar::providers::grok::accounts::GrokAccount; + let codex = CodexAccount::new( + uuid::Uuid::new_v4(), + Some("Work".into()), + None, + None, + Some("Work".into()), + std::path::PathBuf::from("work"), + CodexAccountSource::ManagedByApp, + utc_now(), + utc_now(), + None, + ); + let claude = ClaudeAccount { + id: "a:org".into(), + email: "a@example.com".into(), + organization: None, + plan: None, + is_active: false, + is_saved: true, + }; + let grok = GrokAccount { + id: "user-a".into(), + email: "a@example.com".into(), + organization: None, + plan: None, + is_active: false, + is_saved: true, + }; + let menus = [ + codex_accounts_menu( + std::slice::from_ref(&codex), + Some(&codex), + Language::English, + false, + ), + codex_accounts_menu(&[], None, Language::English, false), + claude_accounts_menu( + std::slice::from_ref(&claude), + Language::English, + false, + true, + &HashMap::new(), + ), + claude_accounts_menu(&[], Language::English, false, true, &HashMap::new()), + grok_accounts_menu(std::slice::from_ref(&grok), Language::English, false), + grok_accounts_menu(&[], Language::English, false), + ]; + + let mut resolved = Vec::new(); + for menu in &menus { + for id in crate::tray_menu::tests::actionable_ids(std::slice::from_ref(menu)) { + assert!( + crate::tray_bridge::resolve_menu_action(&id).is_some(), + "account id {id} does not resolve to a menu action" + ); + resolved.push(id); + } + } + for expected in [ + "add_codex_account", + "switch_claude_account:a:org", + "add_claude_account", + "save_claude_account", + "cancel_claude_login", + "switch_grok_account:user-a", + "add_grok_account", + "save_grok_account", + "cancel_grok_login", + ] { + assert!( + resolved.iter().any(|id| id == expected), + "{expected} should be actionable" + ); + } + for empty_row in [ + "codex_accounts_empty", + "claude_accounts_empty", + "grok_accounts_empty", + ] { + assert!( + crate::tray_bridge::resolve_menu_action(empty_row).is_none(), + "{empty_row} is a disabled status row and must not dispatch" + ); + } + } + #[test] fn codex_accounts_can_be_switched_or_added_from_tray() { use codexbar::codex_accounts::{CodexAccountSource, utc_now}; diff --git a/apps/desktop-tauri/src-tauri/src/tray_bridge.rs b/apps/desktop-tauri/src-tauri/src/tray_bridge.rs index fd594513d9b..7bd87266a39 100644 --- a/apps/desktop-tauri/src-tauri/src/tray_bridge.rs +++ b/apps/desktop-tauri/src-tauri/src/tray_bridge.rs @@ -147,7 +147,7 @@ fn build_native_tray_menu( Menu::with_items(app, &item_refs) } -enum MenuAction { +pub(crate) enum MenuAction { /// Open Settings/About in a detached window. OpenSettings(String), /// Open (or focus) the dedicated flyout ("Pop Out Dashboard") window. @@ -162,7 +162,7 @@ enum MenuAction { Quit, } -fn resolve_menu_action(id: &str) -> Option { +pub(crate) fn resolve_menu_action(id: &str) -> Option { if let Some(action) = crate::tray_accounts::resolve_action(id) { return Some(MenuAction::Account(action)); } @@ -249,6 +249,7 @@ pub fn setup(app: &mut tauri::App) -> Result<(), Box> { handle_menu_event(app, event.id().as_ref()); }) .build(app)?; + crate::proof_tray_menu::write_if_enabled(app.handle(), &menu); // Apply tray promotion on startup. The NotifyIconSettings entry is created // by Windows only after the icon is first registered, so on first run / @@ -355,6 +356,26 @@ fn handle_menu_event(app: &AppHandle, id: &str) { } } +/// Pure gate for `proof_activate_tray_item`: proof mode only, and only ids that +/// resolve to a menu action. Status rows and unknown ids never dispatch. +pub(crate) fn activation_allowed(proof_mode: bool, id: &str) -> bool { + proof_mode && resolve_menu_action(id).is_some() +} + +/// Proof-only: run the same handler a native click on `id` runs. +/// +/// The handler runs on the main thread through `run_on_main_thread`, not in +/// this command, so window-building actions stay out of an IPC command. +#[tauri::command] +pub(crate) async fn proof_activate_tray_item(app: AppHandle, id: String) -> Result<(), String> { + if !activation_allowed(crate::proof_harness::is_proof_mode(&app), &id) { + return Err("tray item is not a proof-activatable menu action".into()); + } + let handle = app.clone(); + app.run_on_main_thread(move || handle_menu_event(&handle, &id)) + .map_err(|error| error.to_string()) +} + /// Rebuild the native tray menu from current provider + settings state. pub(crate) fn rebuild_tray_menu(app: &AppHandle) { let catalog = crate::commands::get_provider_catalog(); @@ -372,7 +393,8 @@ pub(crate) fn rebuild_tray_menu(app: &AppHandle) { if let Ok(menu) = build_native_tray_menu(app, &catalog, &status_labels) && let Some(tray) = app.tray_by_id("codexbar-main") { - let _ = tray.set_menu(Some(menu)); + let _ = tray.set_menu(Some(menu.clone())); + crate::proof_tray_menu::write_if_enabled(app, &menu); } } @@ -392,7 +414,8 @@ pub fn update_tray_status_items( if let Ok(menu) = build_native_tray_menu(app, &catalog, &status_labels) && let Some(tray) = app.tray_by_id("codexbar-main") { - let _ = tray.set_menu(Some(menu)); + let _ = tray.set_menu(Some(menu.clone())); + crate::proof_tray_menu::write_if_enabled(app, &menu); } } @@ -560,7 +583,79 @@ mod tests { use super::*; use crate::surface::SurfaceMode; + use crate::tray_menu::tests::{actionable_ids, flatten, sample_provider_catalog}; use crate::tray_presentation::tests::{fake_snapshot, fake_snapshot_with}; + use codexbar::settings::Language; + + #[test] + fn proof_activation_requires_proof_mode_and_a_menu_action() { + assert!(!activation_allowed(false, "refresh")); + assert!(activation_allowed(true, "refresh")); + assert!(activation_allowed(true, "settings")); + assert!(activation_allowed(true, "toggle_provider:codex")); + assert!(activation_allowed(true, "switch_claude_account:a:org")); + assert!(!activation_allowed(true, "status_codex")); + assert!(!activation_allowed(true, "stay_awake_active")); + assert!(!activation_allowed(true, "not_a_menu_item")); + assert!(!activation_allowed(true, "")); + } + + #[test] + fn every_spec_action_resolves_and_status_rows_do_not() { + let labels = vec![ + ("claude".to_string(), "Claude 60%".to_string()), + ("codex".to_string(), "Codex 30%".to_string()), + ]; + let spec = build_tray_menu_with( + &sample_provider_catalog(), + &labels, + &crate::tray_menu::tests::both_enabled(), + MenuState { + float_bar_enabled: true, + stay_awake_held: true, + }, + Language::English, + ); + + let actionable = actionable_ids(&spec); + for expected in [ + "refresh", + "pop_out", + "toggle_float_bar", + "toggle_provider:codex", + "toggle_provider:claude", + "settings", + "check_for_updates", + "about", + "quit", + ] { + assert!( + actionable.iter().any(|id| id == expected), + "{expected} should be actionable in the spec" + ); + } + for id in &actionable { + assert!( + resolve_menu_action(id).is_some(), + "spec id {id} does not resolve to a menu action" + ); + } + + let mut inert = Vec::new(); + for entry in flatten(&spec) { + if let Some(id) = entry.id.as_deref() + && (id.starts_with("status_") || id == "stay_awake_active") + { + assert!(resolve_menu_action(id).is_none(), "{id} must not dispatch"); + inert.push(id.to_string()); + } + } + assert_eq!( + inert, + vec!["status_claude", "status_codex", "stay_awake_active"], + "the drift guard must see every status row" + ); + } #[test] fn toggle_float_bar_routes_to_toggle_action() { diff --git a/apps/desktop-tauri/src-tauri/src/tray_menu.rs b/apps/desktop-tauri/src-tauri/src/tray_menu.rs index b6522fee282..f608b572f20 100644 --- a/apps/desktop-tauri/src-tauri/src/tray_menu.rs +++ b/apps/desktop-tauri/src-tauri/src/tray_menu.rs @@ -189,7 +189,23 @@ pub(crate) mod tests { }) } - fn sample_provider_catalog() -> Vec { + /// Every entry in the tree, parents before children. + pub(crate) fn flatten(menu: &[TrayMenuEntry]) -> Vec<&TrayMenuEntry> { + menu.iter() + .flat_map(|entry| std::iter::once(entry).chain(flatten(&entry.children))) + .collect() + } + + /// Ids of the rows a user can click: not a separator, not disabled, and not a submenu. + pub(crate) fn actionable_ids(menu: &[TrayMenuEntry]) -> Vec { + flatten(menu) + .into_iter() + .filter(|entry| !entry.is_separator && !entry.disabled && entry.children.is_empty()) + .filter_map(|entry| entry.id.clone()) + .collect() + } + + pub(crate) fn sample_provider_catalog() -> Vec { vec![ ProviderCatalogEntry { id: "codex".into(), @@ -206,7 +222,7 @@ pub(crate) mod tests { ] } - fn both_enabled() -> HashSet { + pub(crate) fn both_enabled() -> HashSet { ["codex".to_string(), "claude".to_string()] .into_iter() .collect() diff --git a/docs/WINDOWS_PROOF.md b/docs/WINDOWS_PROOF.md index 1ad0e2c9eaf..5c05fc2f836 100644 --- a/docs/WINDOWS_PROOF.md +++ b/docs/WINDOWS_PROOF.md @@ -86,6 +86,50 @@ When it is set in proof mode: The app ignores the variable outside proof mode. It logs a warning and uses normal placement when the value isn't two whole numbers separated by a comma, or when no monitor contains the point. +### Read and activate the tray menu + +A right-click on the tray icon takes focus, so a proof run can't open the tray menu the normal way. Two proof-only hooks cover it. + +`CODEXBAR_PROOF_TRAY_MENU_DUMP` names an absolute path. In proof mode the app writes the live tray context menu there as JSON after startup and after every menu rebuild (provider toggles, status updates after a refresh, the float bar toggle, Stay Awake). Each write replaces the file in one step, so a reader sees a whole document. Outside proof mode, or when the variable is unset, nothing is written. + +```powershell +$env:CODEXBAR_PROOF_TRAY_MENU_DUMP = 'C:\proof\tray-menu.json' +``` + +The document holds `generation` (1 for the first write in a process, then +1 per write), `timestamp` (UTC, RFC 3339) and `items`. Each node has `id`, `text`, `kind`, `enabled`, `checked` and `children`: + +- `kind` is `item`, `check`, `submenu`, `separator` or `icon`. +- `checked` is `true` or `false` for `check` rows and `null` otherwise. +- `id` is `null` for separators. +- `text` is the label as built, so Hide Personal Info masking shows here as it does on screen. + +```json +{ + "generation": 2, + "timestamp": "2026-05-01T12:00:00.123456789+00:00", + "items": [ + { "id": "status_claude", "text": "Claude 60% session", "kind": "item", "enabled": false, "checked": null, "children": [] }, + { "id": null, "text": "", "kind": "separator", "enabled": true, "checked": null, "children": [] }, + { "id": "refresh", "text": "Refresh all", "kind": "item", "enabled": true, "checked": null, "children": [] }, + { "id": "toggle_float_bar", "text": "Show Float Bar", "kind": "check", "enabled": true, "checked": false, "children": [] }, + { "id": "providers", "text": "Providers", "kind": "submenu", "enabled": true, "checked": null, "children": [ + { "id": "toggle_provider:codex", "text": "Codex", "kind": "check", "enabled": true, "checked": true, "children": [] } + ] } + ] +} +``` + +`proof_activate_tray_item` runs the handler a native click on a row would run. Call it through the Tauri bridge from a CDP session attached to a CodexBar page target (found by URL, never the first target): + +```js +await window.__TAURI_INTERNALS__.invoke('proof_activate_tray_item', { id: 'toggle_float_bar' }); +``` + +- It fails with an error unless proof mode is active and `id` is a menu action. Status rows (`status_*`), disabled rows and unknown ids are refused. The error doesn't echo the id. +- The handler runs on the main thread. Nothing on screen opens or takes focus unless the action itself opens a window. +- Some actions write to the running profile. A provider toggle saves `settings.json`, and Quit exits the app. Use an isolated `APPDATA` home for proof runs. +- Rows that change a setting show the new state once the menu is rebuilt, and the activation call returns before that happens. Account actions run asynchronously, so the dump can still show the old state right after the call. Poll for a higher `generation` before you read the state. + ## Drive the app Drive the app without taking the user's focus or keyboard. Don't use cua-driver's `bring_to_front`, foreground mode, `hotkey` or `type_text`. From 58a3ba27e90f441f85ccc36b0730a07b6e2b41d8 Mon Sep 17 00:00:00 2001 From: Factory Agent Date: Sat, 24 Oct 2026 05:15:41 +0700 Subject: [PATCH 2/3] Write the tray menu dump only after the menu is installed --- apps/desktop-tauri/src-tauri/src/tray_bridge.rs | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/apps/desktop-tauri/src-tauri/src/tray_bridge.rs b/apps/desktop-tauri/src-tauri/src/tray_bridge.rs index 7bd87266a39..9777093bf77 100644 --- a/apps/desktop-tauri/src-tauri/src/tray_bridge.rs +++ b/apps/desktop-tauri/src-tauri/src/tray_bridge.rs @@ -393,8 +393,10 @@ pub(crate) fn rebuild_tray_menu(app: &AppHandle) { if let Ok(menu) = build_native_tray_menu(app, &catalog, &status_labels) && let Some(tray) = app.tray_by_id("codexbar-main") { - let _ = tray.set_menu(Some(menu.clone())); - crate::proof_tray_menu::write_if_enabled(app, &menu); + match tray.set_menu(Some(menu.clone())) { + Ok(()) => crate::proof_tray_menu::write_if_enabled(app, &menu), + Err(error) => tracing::warn!("Failed to install tray menu: {error}"), + } } } @@ -414,8 +416,10 @@ pub fn update_tray_status_items( if let Ok(menu) = build_native_tray_menu(app, &catalog, &status_labels) && let Some(tray) = app.tray_by_id("codexbar-main") { - let _ = tray.set_menu(Some(menu.clone())); - crate::proof_tray_menu::write_if_enabled(app, &menu); + match tray.set_menu(Some(menu.clone())) { + Ok(()) => crate::proof_tray_menu::write_if_enabled(app, &menu), + Err(error) => tracing::warn!("Failed to install tray menu: {error}"), + } } } From ad5efd55a6d1029f8f3217674285d2ece3eb94b5 Mon Sep 17 00:00:00 2001 From: Factory Agent Date: Sat, 24 Oct 2026 05:52:15 +0700 Subject: [PATCH 3/3] Install the tray menu on the main thread and gate proof activation on it --- .../src-tauri/src/proof_tray_menu.rs | 80 +++++++++++-- .../src-tauri/src/tray_bridge.rs | 105 ++++++++++++------ docs/WINDOWS_PROOF.md | 6 +- 3 files changed, 144 insertions(+), 47 deletions(-) diff --git a/apps/desktop-tauri/src-tauri/src/proof_tray_menu.rs b/apps/desktop-tauri/src-tauri/src/proof_tray_menu.rs index a8440b4b016..7895e9cbee5 100644 --- a/apps/desktop-tauri/src-tauri/src/proof_tray_menu.rs +++ b/apps/desktop-tauri/src-tauri/src/proof_tray_menu.rs @@ -4,10 +4,12 @@ //! needs `SetForegroundWindow`, which steals the user's focus. In proof mode, //! `CODEXBAR_PROOF_TRAY_MENU_DUMP=` makes the shell write the //! live menu as JSON after every menu rebuild. The matching activation command -//! is `tray_bridge::proof_activate_tray_item`. +//! is `tray_bridge::proof_activate_tray_item`; it accepts only enabled rows of +//! the menu currently installed on the tray. //! //! Nothing here runs unless proof mode is active and the env var is set. +use std::collections::BTreeSet; use std::io; use std::path::{Path, PathBuf}; use std::sync::{Mutex, PoisonError}; @@ -26,6 +28,10 @@ pub(crate) const DUMP_ENV_VAR: &str = "CODEXBAR_PROOF_TRAY_MENU_DUMP"; /// menu is read, because those reads may wait on the main thread. static GENERATION: Mutex = Mutex::new(0); +/// Enabled leaf ids of the menu last installed on the tray. Proof activation +/// dispatches only these, so it can't act on a row the menu doesn't show. +static INSTALLED_IDS: Mutex> = Mutex::new(BTreeSet::new()); + /// Top-level dump document. #[derive(Debug, Clone, PartialEq, Serialize)] pub(crate) struct TrayMenuDump { @@ -152,22 +158,56 @@ fn separator_node() -> DumpNode { } } -/// Write the live tray menu to the dump path. Call it right after the menu is -/// set on the tray. Does nothing unless the env var is set and proof mode is on. -pub(crate) fn write_if_enabled(app: &AppHandle, menu: &Menu) { - let Some(raw) = std::env::var(DUMP_ENV_VAR).ok() else { - return; - }; - let Some(path) = dump_target(crate::proof_harness::is_proof_mode(app), &raw) else { +/// Ids of the enabled leaf rows (items, checks, icons) in `nodes`, at any depth. +/// Submenus, separators and disabled rows such as status lines are left out. +pub(crate) fn activatable_ids(nodes: &[DumpNode]) -> BTreeSet { + fn collect(nodes: &[DumpNode], ids: &mut BTreeSet) { + for node in nodes { + if node.kind == "submenu" { + collect(&node.children, ids); + } else if node.enabled + && let Some(id) = &node.id + { + ids.insert(id.clone()); + } + } + } + let mut ids = BTreeSet::new(); + collect(nodes, &mut ids); + ids +} + +/// Whether `id` is an enabled leaf of the menu last installed on the tray. +pub(crate) fn is_installed_leaf(id: &str) -> bool { + INSTALLED_IDS + .lock() + .unwrap_or_else(PoisonError::into_inner) + .contains(id) +} + +/// Record the menu just installed on the tray and, when the env var is set, +/// write it to the dump path. Call it on the main thread right after `set_menu` +/// succeeds: the main thread runs installs one at a time, so each record +/// matches the menu on the tray. Does nothing outside proof mode. +pub(crate) fn record_installed(app: &AppHandle, menu: &Menu) { + if !crate::proof_harness::is_proof_mode(app) { return; - }; + } let items = match menu.items().and_then(|items| nodes_from_native(&items)) { Ok(items) => items, Err(error) => { + *INSTALLED_IDS.lock().unwrap_or_else(PoisonError::into_inner) = BTreeSet::new(); tracing::warn!(%error, "proof: failed to read the tray menu"); return; } }; + *INSTALLED_IDS.lock().unwrap_or_else(PoisonError::into_inner) = activatable_ids(&items); + let Some(raw) = std::env::var(DUMP_ENV_VAR).ok() else { + return; + }; + let Some(path) = dump_target(true, &raw) else { + return; + }; let mut generation = GENERATION.lock().unwrap_or_else(PoisonError::into_inner); *generation += 1; let dump = TrayMenuDump { @@ -264,6 +304,28 @@ mod tests { ); } + #[test] + fn activatable_ids_are_enabled_leaves_at_any_depth() { + let items = vec![ + TrayMenuEntry::status_row("status_claude", "Claude 60%"), + TrayMenuEntry::separator(), + TrayMenuEntry::item("refresh", "Refresh all"), + TrayMenuEntry::submenu( + "providers", + "Providers", + vec![TrayMenuEntry::check_item( + "toggle_provider:codex", + "Codex", + false, + )], + ), + ]; + let ids: Vec = activatable_ids(&nodes_from_spec(&items)) + .into_iter() + .collect(); + assert_eq!(ids, vec!["refresh", "toggle_provider:codex"]); + } + #[test] fn dump_of_built_menu_lists_status_rows_then_actions() { let labels = vec![("claude".to_string(), "Claude 60%".to_string())]; diff --git a/apps/desktop-tauri/src-tauri/src/tray_bridge.rs b/apps/desktop-tauri/src-tauri/src/tray_bridge.rs index 9777093bf77..831bacde1d4 100644 --- a/apps/desktop-tauri/src-tauri/src/tray_bridge.rs +++ b/apps/desktop-tauri/src-tauri/src/tray_bridge.rs @@ -249,7 +249,7 @@ pub fn setup(app: &mut tauri::App) -> Result<(), Box> { handle_menu_event(app, event.id().as_ref()); }) .build(app)?; - crate::proof_tray_menu::write_if_enabled(app.handle(), &menu); + crate::proof_tray_menu::record_installed(app.handle(), &menu); // Apply tray promotion on startup. The NotifyIconSettings entry is created // by Windows only after the icon is first registered, so on first run / @@ -356,24 +356,63 @@ fn handle_menu_event(app: &AppHandle, id: &str) { } } -/// Pure gate for `proof_activate_tray_item`: proof mode only, and only ids that -/// resolve to a menu action. Status rows and unknown ids never dispatch. -pub(crate) fn activation_allowed(proof_mode: bool, id: &str) -> bool { - proof_mode && resolve_menu_action(id).is_some() +/// Pure gate for `proof_activate_tray_item`: proof mode only, only ids that +/// resolve to a menu action, and only enabled rows of the installed menu. +/// Status rows, unknown ids and rows the menu doesn't show never dispatch. +pub(crate) fn activation_allowed(proof_mode: bool, id: &str, in_installed_menu: bool) -> bool { + proof_mode && in_installed_menu && resolve_menu_action(id).is_some() } +const NOT_ACTIVATABLE: &str = "tray item is not a proof-activatable menu action"; + /// Proof-only: run the same handler a native click on `id` runs. /// -/// The handler runs on the main thread through `run_on_main_thread`, not in -/// this command, so window-building actions stay out of an IPC command. +/// The check and the handler run on the main thread through +/// `run_on_main_thread`, not in this command: menu installs also run there, so +/// the check sees the menu that is on the tray, and window-building actions +/// stay out of an IPC command. #[tauri::command] pub(crate) async fn proof_activate_tray_item(app: AppHandle, id: String) -> Result<(), String> { - if !activation_allowed(crate::proof_harness::is_proof_mode(&app), &id) { - return Err("tray item is not a proof-activatable menu action".into()); + let proof_mode = crate::proof_harness::is_proof_mode(&app); + if !proof_mode { + return Err(NOT_ACTIVATABLE.into()); } + let (sender, receiver) = tokio::sync::oneshot::channel(); + let handle = app.clone(); + app.run_on_main_thread(move || { + let in_menu = crate::proof_tray_menu::is_installed_leaf(&id); + let allowed = activation_allowed(proof_mode, &id, in_menu); + if allowed { + handle_menu_event(&handle, &id); + } + let _ = sender.send(allowed); + }) + .map_err(|error| error.to_string())?; + match receiver.await { + Ok(true) => Ok(()), + Ok(false) => Err(NOT_ACTIVATABLE.into()), + Err(_) => Err("tray item activation did not run".into()), + } +} + +/// Install `menu` on the tray from the main thread. The event loop runs these +/// tasks in order and each one records its own menu, so a later install is +/// never followed by an earlier proof snapshot. Runs inline when already on +/// the main thread. +fn install_tray_menu(app: &AppHandle, menu: Menu) { let handle = app.clone(); - app.run_on_main_thread(move || handle_menu_event(&handle, &id)) - .map_err(|error| error.to_string()) + let posted = app.run_on_main_thread(move || { + let Some(tray) = handle.tray_by_id("codexbar-main") else { + return; + }; + match tray.set_menu(Some(menu.clone())) { + Ok(()) => crate::proof_tray_menu::record_installed(&handle, &menu), + Err(error) => tracing::warn!("Failed to install tray menu: {error}"), + } + }); + if let Err(error) = posted { + tracing::warn!("Failed to schedule the tray menu install: {error}"); + } } /// Rebuild the native tray menu from current provider + settings state. @@ -390,13 +429,8 @@ pub(crate) fn rebuild_tray_menu(app: &AppHandle) { } else { vec![] }; - if let Ok(menu) = build_native_tray_menu(app, &catalog, &status_labels) - && let Some(tray) = app.tray_by_id("codexbar-main") - { - match tray.set_menu(Some(menu.clone())) { - Ok(()) => crate::proof_tray_menu::write_if_enabled(app, &menu), - Err(error) => tracing::warn!("Failed to install tray menu: {error}"), - } + if let Ok(menu) = build_native_tray_menu(app, &catalog, &status_labels) { + install_tray_menu(app, menu); } } @@ -413,13 +447,8 @@ pub fn update_tray_status_items( let status_labels = TrayPresentationPlan::resolve(&settings, snapshots, rates_cache) .status_labels(settings.ui_language); - if let Ok(menu) = build_native_tray_menu(app, &catalog, &status_labels) - && let Some(tray) = app.tray_by_id("codexbar-main") - { - match tray.set_menu(Some(menu.clone())) { - Ok(()) => crate::proof_tray_menu::write_if_enabled(app, &menu), - Err(error) => tracing::warn!("Failed to install tray menu: {error}"), - } + if let Ok(menu) = build_native_tray_menu(app, &catalog, &status_labels) { + install_tray_menu(app, menu); } } @@ -592,16 +621,22 @@ mod tests { use codexbar::settings::Language; #[test] - fn proof_activation_requires_proof_mode_and_a_menu_action() { - assert!(!activation_allowed(false, "refresh")); - assert!(activation_allowed(true, "refresh")); - assert!(activation_allowed(true, "settings")); - assert!(activation_allowed(true, "toggle_provider:codex")); - assert!(activation_allowed(true, "switch_claude_account:a:org")); - assert!(!activation_allowed(true, "status_codex")); - assert!(!activation_allowed(true, "stay_awake_active")); - assert!(!activation_allowed(true, "not_a_menu_item")); - assert!(!activation_allowed(true, "")); + fn proof_activation_requires_proof_mode_a_menu_action_and_an_installed_row() { + assert!(!activation_allowed(false, "refresh", true)); + assert!(activation_allowed(true, "refresh", true)); + assert!(activation_allowed(true, "settings", true)); + assert!(activation_allowed(true, "toggle_provider:codex", true)); + assert!(activation_allowed( + true, + "switch_claude_account:a:org", + true + )); + assert!(!activation_allowed(true, "status_codex", true)); + assert!(!activation_allowed(true, "stay_awake_active", true)); + assert!(!activation_allowed(true, "not_a_menu_item", true)); + assert!(!activation_allowed(true, "", true)); + assert!(!activation_allowed(true, "refresh", false)); + assert!(!activation_allowed(true, "toggle_provider:unlisted", false)); } #[test] diff --git a/docs/WINDOWS_PROOF.md b/docs/WINDOWS_PROOF.md index 5c05fc2f836..75264d1eb3b 100644 --- a/docs/WINDOWS_PROOF.md +++ b/docs/WINDOWS_PROOF.md @@ -125,10 +125,10 @@ The document holds `generation` (1 for the first write in a process, then +1 per await window.__TAURI_INTERNALS__.invoke('proof_activate_tray_item', { id: 'toggle_float_bar' }); ``` -- It fails with an error unless proof mode is active and `id` is a menu action. Status rows (`status_*`), disabled rows and unknown ids are refused. The error doesn't echo the id. -- The handler runs on the main thread. Nothing on screen opens or takes focus unless the action itself opens a window. +- It fails with an error unless proof mode is active and `id` is an enabled row of the menu now on the tray that maps to a menu action. Status rows (`status_*`), disabled rows, unknown ids and rows the current menu doesn't show (such as `toggle_provider:` for a provider missing from the menu) are refused. The error doesn't echo the id. +- The check and the handler run on the main thread, after any menu install queued before the call. Nothing on screen opens or takes focus unless the action itself opens a window. - Some actions write to the running profile. A provider toggle saves `settings.json`, and Quit exits the app. Use an isolated `APPDATA` home for proof runs. -- Rows that change a setting show the new state once the menu is rebuilt, and the activation call returns before that happens. Account actions run asynchronously, so the dump can still show the old state right after the call. Poll for a higher `generation` before you read the state. +- Rows that change a setting show the new state once the menu is rebuilt. The call returns when the handler's synchronous part is done, and some rebuilds and all account actions run after that, so the dump can still show the old state right after the call. Poll for a higher `generation` before you read the state. ## Drive the app