diff --git a/apps/desktop-tauri/src-tauri/src/auto_resume.rs b/apps/desktop-tauri/src-tauri/src/auto_resume.rs index 030fcaeff3..35260c2648 100644 --- a/apps/desktop-tauri/src-tauri/src/auto_resume.rs +++ b/apps/desktop-tauri/src-tauri/src/auto_resume.rs @@ -6,13 +6,12 @@ use codexbar::agent_sessions::{ AgentSession, AgentSessionProvider, AgentSessionSource, AgentSessionState, - LocalAgentSessionScanner, SessionFocusResult, + LocalAgentSessionScanner, }; use codexbar::core::{ProviderId, TokenAccountStore}; use codexbar::settings::Settings; use std::collections::HashMap; use std::path::{Path, PathBuf}; -use std::process::Command; use std::sync::Mutex; use crate::commands::{ProviderUsageSnapshot, RateWindowSnapshot}; @@ -20,10 +19,6 @@ use crate::state::AppState; use tauri::Manager; const MAX_SESSION_ID_LEN: usize = 256; -#[cfg(windows)] -const CREATE_NEW_CONSOLE: u32 = 0x0000_0010; -#[cfg(windows)] -const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200; #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] enum QuotaSlot { @@ -633,19 +628,22 @@ async fn resume_captured_session( } if let Some(session) = matching_session(&target, &result.sessions) { // A live process already owns this exact session. Reopening it would - // create a duplicate terminal, so focus the existing window instead. + // create a duplicate terminal, so point the user at the existing + // window instead. This runs after a background refresh, not a user + // action: flash the window's taskbar button rather than restoring and + // activating it over the app the user is working in. if session.pid.is_some() { if !resume_attempt_is_still_valid(app, &target, operation, account_identity) { clear_provider_if_resume_owner(app, target.provider, operation); return; } - let focus_result = codexbar::agent_sessions::focus_session(session); - let succeeded = matches!(focus_result, SessionFocusResult::Focused); + let attention = codexbar::agent_sessions::request_session_attention(session); + let succeeded = attention.is_ok(); tracing::info!( provider = target.provider.cli_name(), - focus_result = ?focus_result, + attention = ?attention, succeeded, - "captured CLI session is already running; attempting to focus it" + "captured CLI session is already running; flashing its window" ); finish_resume_attempt(app, &target, operation, succeeded); return; @@ -804,14 +802,15 @@ fn launch_resume(target: &ResumeTarget) -> Result<(), String> { .ok_or_else(|| format!("{} CLI was not found", target.provider.display_name()))?; let command = build_resume_command(target, executable)?; - let mut process = Command::new(&command.program); - process.args(&command.args).current_dir(&command.cwd); - #[cfg(windows)] - { - use std::os::windows::process::CommandExt; - process.creation_flags(CREATE_NEW_CONSOLE | CREATE_NEW_PROCESS_GROUP); - } - process.spawn().map(|_| ()).map_err(|error| { + // This runs after a background refresh, not a user action: the console + // starts minimized and inactive, so it waits on the taskbar instead of + // taking focus from the app the user is working in. + codexbar::host::console_launch::spawn_minimized_console( + &command.program, + &command.args, + &command.cwd, + ) + .map_err(|error| { format!( "failed to launch {} CLI: {error}", target.provider.display_name() @@ -835,28 +834,9 @@ fn build_resume_command( _ => return Err("provider does not support auto-resume".to_string()), }; - #[cfg(windows)] - if executable - .extension() - .and_then(|extension| extension.to_str()) - .is_some_and(|extension| matches!(extension.to_ascii_lowercase().as_str(), "cmd" | "bat")) - { - let command_line = std::iter::once(quote_cmd_arg(&executable.to_string_lossy())) - .chain(cli_args.iter().map(|arg| quote_cmd_arg(arg))) - .collect::>() - .join(" "); - return Ok(ResumeCommand { - program: PathBuf::from("cmd.exe"), - args: vec![ - "/d".to_string(), - "/s".to_string(), - "/c".to_string(), - command_line, - ], - cwd: target.cwd.clone(), - }); - } - + // Batch shims such as npm's `claude.cmd` stay the program here; + // `spawn_minimized_console` runs them through cmd.exe with quoting cmd.exe + // parses. Ok(ResumeCommand { program: executable, args: cli_args, @@ -864,11 +844,6 @@ fn build_resume_command( }) } -#[cfg(windows)] -fn quote_cmd_arg(value: &str) -> String { - format!("\"{}\"", value.replace('"', "\\\"")) -} - #[cfg(test)] #[path = "auto_resume_tests.rs"] mod auto_resume_tests; diff --git a/apps/desktop-tauri/src-tauri/src/auto_resume_tests.rs b/apps/desktop-tauri/src-tauri/src/auto_resume_tests.rs index 07b289498f..b8c8fcb660 100644 --- a/apps/desktop-tauri/src-tauri/src/auto_resume_tests.rs +++ b/apps/desktop-tauri/src-tauri/src/auto_resume_tests.rs @@ -86,22 +86,24 @@ mod tests { ); } - #[cfg(windows)] #[test] - fn cmd_wrapper_preserves_exact_resume_arguments() { + fn batch_shims_keep_exact_resume_arguments() { + // cmd.exe wrapping for `.cmd` shims happens at launch, in + // `codexbar::host::console_launch`, which tests its own quoting. let command = build_resume_command( &target(ProviderId::Claude), PathBuf::from(r"C:\Program Files\Claude\claude.cmd"), ) .unwrap(); - assert_eq!(command.program, PathBuf::from("cmd.exe")); - assert_eq!(&command.args[..3], ["/d", "/s", "/c"]); assert_eq!( - command.args[3], - r#""C:\Program Files\Claude\claude.cmd" "--resume" "12345678-1234-1234-1234-123456789abc""# + command.program, + PathBuf::from(r"C:\Program Files\Claude\claude.cmd") + ); + assert_eq!( + command.args, + ["--resume", "12345678-1234-1234-1234-123456789abc"] ); - assert!(!command.args[3].contains("prompt")); } #[test] diff --git a/apps/desktop-tauri/src-tauri/src/commands/surface.rs b/apps/desktop-tauri/src-tauri/src/commands/surface.rs index b2f4aeb4ee..c0ec61bdff 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/surface.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/surface.rs @@ -1,4 +1,5 @@ use super::*; +use crate::shell::activation::Activation; // ── Surface-mode commands ──────────────────────────────────────────── @@ -11,8 +12,15 @@ pub fn set_surface_mode( let mode = SurfaceMode::parse(&mode).ok_or_else(|| format!("unknown surface mode: {mode}"))?; let target = validate_surface_target(mode, target)?; - crate::shell::transition_to_target(window.app_handle(), mode, target, None) - .map(|mode| mode.as_str().to_string()) + // The frontend only calls this from a click (e.g. a Settings tab). + crate::shell::transition_to_target( + window.app_handle(), + mode, + target, + None, + Activation::UserAction, + ) + .map(|mode| mode.as_str().to_string()) } #[tauri::command] @@ -66,7 +74,7 @@ pub async fn open_settings_window(app: tauri::AppHandle, tab: String) -> Result< /// on Windows. #[tauri::command] pub async fn open_flyout_window(app: tauri::AppHandle) -> Result<(), String> { - crate::shell::flyout_window::open_or_focus(&app, None) + crate::shell::flyout_window::open_or_focus(&app, None, Activation::UserAction) } /// Reveal the flyout window after the frontend's first layout pass. Called by @@ -75,6 +83,8 @@ pub async fn open_flyout_window(app: tauri::AppHandle) -> Result<(), String> { /// blank/backing frame. /// /// No-ops when the flyout window doesn't exist or no one-shot reveal is pending. +/// The window takes focus only as far as the pending reveal's activation +/// allows (see `shell::activation`). #[tauri::command] pub fn reveal_tray_panel_window( app: tauri::AppHandle, @@ -86,17 +96,16 @@ pub fn reveal_tray_panel_window( return Ok(()); }; let mut guard = state.lock().map_err(|e| e.to_string())?; - if !guard.take_pending_flyout_reveal() { + let Some(activation) = guard.take_pending_flyout_reveal() else { return Ok(()); - } + }; drop(guard); window.show().map_err(|e| e.to_string())?; state .lock() .map_err(|e| e.to_string())? .mark_tray_panel_shown(std::time::Instant::now()); - window.set_focus().map_err(|e| e.to_string())?; - Ok(()) + crate::shell::activation::apply(&window, activation) } #[tauri::command] diff --git a/apps/desktop-tauri/src-tauri/src/floatbar/window.rs b/apps/desktop-tauri/src-tauri/src/floatbar/window.rs index aabf23f042..fe6ed1858a 100644 --- a/apps/desktop-tauri/src-tauri/src/floatbar/window.rs +++ b/apps/desktop-tauri/src-tauri/src/floatbar/window.rs @@ -179,7 +179,7 @@ pub(super) fn recover_onto_primary>( if window.is_minimized().unwrap_or(false) || is_windows_minimized_position(position.x, position.y) { - let _ = window.unminimize(); + let _ = window.restore_without_activation(); } if window.set_physical_position(target).is_err() { return false; @@ -230,10 +230,10 @@ pub fn opacity_to_alpha(opacity: u8) -> u8 { ((clamped as u32) * 255 / 100) as u8 } -/// Open the floating-bar window, or focus + reapply attributes if already -/// open. Position is restored from the geometry store keyed by -/// `floatbar`; on first launch the window is centered horizontally near -/// the top of the primary monitor. +/// Open the floating-bar window, or re-show it and reapply attributes if +/// already open. The bar never takes focus. Position is restored from the +/// geometry store keyed by `floatbar`; on first launch the window is +/// centered horizontally near the top of the primary monitor. pub fn show( app: &tauri::AppHandle, opacity: u8, @@ -266,6 +266,9 @@ pub fn show( .resizable(false) .always_on_top(true) .skip_taskbar(true) + // Show with SW_SHOWNOACTIVATE: a plain first show activates the bar + // and takes focus from the app the user is typing in. + .focused(false) // Pin Dark: the floatbar is the only window without a theme pin, and // WebView2 resolves prefers-color-scheme per shared process profile, // so an unpinned (light-default) webview flips the Settings window's @@ -381,7 +384,10 @@ pub trait WindowGeometry { fn is_minimized(&self) -> tauri::Result; fn primary_monitor(&self) -> tauri::Result>; fn available_monitors(&self) -> tauri::Result>; - fn unminimize(&self) -> tauri::Result<()>; + /// Un-minimize without taking the foreground. The bar is never meant to + /// be focused, and a plain `unminimize` activates it (see + /// [`crate::shell::activation::restore_minimized_without_activation`]). + fn restore_without_activation(&self) -> tauri::Result<()>; fn set_physical_position(&self, position: PhysicalPosition) -> tauri::Result<()>; } @@ -404,7 +410,14 @@ impl WindowGeometry for tauri::WebviewWindow { fn available_monitors(&self) -> tauri::Result> { tauri::WebviewWindow::available_monitors(self) } - fn unminimize(&self) -> tauri::Result<()> { + fn restore_without_activation(&self) -> tauri::Result<()> { + // Queued on the main thread ahead of the calls below, so the restore + // lands before `unminimize` re-reads the state and before the caller + // moves the window. + let window = self.clone(); + tauri::WebviewWindow::run_on_main_thread(self, move || { + crate::shell::activation::restore_minimized_without_activation(&window); + })?; tauri::WebviewWindow::unminimize(self) } fn set_physical_position(&self, position: PhysicalPosition) -> tauri::Result<()> { @@ -431,7 +444,11 @@ impl WindowGeometry for tauri::Window { fn available_monitors(&self) -> tauri::Result> { tauri::Window::available_monitors(self) } - fn unminimize(&self) -> tauri::Result<()> { + fn restore_without_activation(&self) -> tauri::Result<()> { + let window = self.clone(); + tauri::Window::run_on_main_thread(self, move || { + crate::shell::activation::restore_minimized_without_activation(&window); + })?; tauri::Window::unminimize(self) } fn set_physical_position(&self, position: PhysicalPosition) -> tauri::Result<()> { diff --git a/apps/desktop-tauri/src-tauri/src/main.rs b/apps/desktop-tauri/src-tauri/src/main.rs index df9a86e148..9be96a4fe0 100644 --- a/apps/desktop-tauri/src-tauri/src/main.rs +++ b/apps/desktop-tauri/src-tauri/src/main.rs @@ -51,13 +51,21 @@ fn should_hide_close_request(mode: SurfaceMode) -> bool { /// /// Spawned because building the flyout window synchronously can deadlock on /// Windows (see `shell::flyout_window::open_or_focus`). +/// +/// Launch and relaunch are not clicks in CodexBar, so the panel only asks +/// Windows for the foreground (`Activation::IfAllowed`): a launch from Start +/// or Explorer gets focus, a login-time or background launch does not. fn open_primary_window(app: &tauri::AppHandle, delay: Duration) { let app = app.clone(); tauri::async_runtime::spawn(async move { if !delay.is_zero() { tokio::time::sleep(delay).await; } - if let Err(error) = shell::flyout_window::open_or_focus(&app, None) { + if let Err(error) = shell::flyout_window::open_or_focus( + &app, + None, + shell::activation::Activation::IfAllowed, + ) { tracing::warn!(%error, "failed to open the tray panel window"); } }); @@ -153,6 +161,16 @@ fn main() { initial_state.provider_cache_updated_at = Some(std::time::Instant::now()); } + let context = tauri::generate_context!(); + if is_proof_mode { + // Proof runs show surfaces for automation but never take focus. + shell::activation::suppress_all(); + } else { + // If CodexBar is already running, the single-instance plugin hands + // this launch off to it; pass our foreground permission along first. + shell::activation::grant_foreground_to_running_instance(&context.config().identifier); + } + tauri::Builder::default() .manage(Mutex::new(initial_state)) .plugin(shortcut_bridge::plugin()) @@ -415,7 +433,7 @@ fn main() { _ => {} } }) - .run(tauri::generate_context!()) + .run(context) .expect("failed to run CodexBar desktop shell"); } diff --git a/apps/desktop-tauri/src-tauri/src/proof_harness.rs b/apps/desktop-tauri/src-tauri/src/proof_harness.rs index 4a08484f39..4058decea1 100644 --- a/apps/desktop-tauri/src-tauri/src/proof_harness.rs +++ b/apps/desktop-tauri/src-tauri/src/proof_harness.rs @@ -15,7 +15,9 @@ //! //! In proof mode the shell immediately transitions to the requested surface //! and suppresses blur-dismiss so the window stays visible for automated -//! screenshot capture. +//! screenshot capture. Surfaces are shown without being activated: no +//! `set_focus`, no foreground change (see `shell::activation::suppress_all`), +//! so a proof run never takes focus from the app the user is working in. //! //! `CODEXBAR_SEED_USAGE_JSON=` additionally seeds one synthetic, //! bridge-shaped Codex [`ProviderUsageSnapshot`] into the provider cache at @@ -30,6 +32,7 @@ use tauri::{AppHandle, Manager}; use crate::commands::{CostSnapshotBridge, ProviderUsageSnapshot, RateWindowSnapshot}; use crate::shell; +use crate::shell::activation::Activation; use crate::state::AppState; use crate::surface::SurfaceMode; use crate::surface_target::{SurfaceTarget, is_supported_settings_tab}; @@ -116,7 +119,8 @@ pub fn activate(app: &AppHandle) { if target == SurfaceMode::PopOut { tracing::info!("proof-harness: opening the tray-panel flyout window"); // Called from the async setup task, so building the window is safe. - if let Err(err) = shell::flyout_window::open_or_focus(app, None) { + // Proof automation must never take focus from the user's app. + if let Err(err) = shell::flyout_window::open_or_focus(app, None, Activation::Never) { tracing::error!("proof-harness: flyout open FAILED: {err}"); } return; @@ -134,7 +138,13 @@ pub fn activate(app: &AppHandle) { position, ); - match shell::transition_to_target(app, target, config.surface_target(), position) { + match shell::transition_to_target( + app, + target, + config.surface_target(), + position, + Activation::Never, + ) { Ok(mode) => tracing::info!("proof-harness: transition succeeded → {mode:?}"), Err(err) => tracing::error!("proof-harness: transition FAILED: {err}"), } diff --git a/apps/desktop-tauri/src-tauri/src/shell/activation.rs b/apps/desktop-tauri/src-tauri/src/shell/activation.rs new file mode 100644 index 0000000000..a3b49b2812 --- /dev/null +++ b/apps/desktop-tauri/src-tauri/src/shell/activation.rs @@ -0,0 +1,261 @@ +//! When a CodexBar window may take keyboard focus. +//! +//! CodexBar is a tray utility. It takes the foreground only on a direct user +//! action: a tray click, a tray menu item, the global hotkey, or a click on a +//! control inside one of its own windows. Startup, a second instance handing +//! off to this one, refreshes, events and proof automation may show a +//! surface, but they must not move the foreground away from the app the user +//! is working in. +//! +//! `WebviewWindow::set_focus` is not a polite request on Windows. tao 0.34.8 +//! (`src/platform_impl/windows/window.rs`, `Window::set_focus`, lines +//! 175-186) calls `force_window_active` (lines 1500-1527), which retries a +//! refused `SetForegroundWindow` after injecting a synthetic Alt press and +//! release through `SendInput` ("a little hack which can 'steal' the +//! foreground window permission"). That bypasses the foreground lock Windows +//! applies to background processes, and the injected Alt can open the menu +//! bar of the app the user is typing in. So `set_focus` is reserved for +//! [`Activation::UserAction`]. +//! +//! Showing is kept apart from focusing: every CodexBar window is built with +//! `focused(false)` (`"focus": false` for `main` in `tauri.conf.json`), which +//! makes tao show it with `SW_SHOWNOACTIVATE` instead of `SW_SHOW` (tao +//! `window_state.rs`, `WindowFlags::apply_diff`, lines 325-337). [`apply`] is +//! the only place the focusing step happens. + +use std::sync::atomic::{AtomicBool, Ordering}; + +use tauri::WebviewWindow; + +/// Why a surface is being shown, which decides whether it may take focus. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Activation { + /// A direct user action (tray click, tray menu item, hotkey, a click in + /// CodexBar's own UI). Windows lets the process that received the input + /// take the foreground, so the window is focused. + UserAction, + /// Nothing the user clicked in CodexBar, but they may still expect the + /// window in front: a launch from Start or Explorer, or a second instance + /// they started handing off to this one. Windows is asked once with a + /// plain `SetForegroundWindow`; if the foreground lock refuses, the + /// window stays visible without focus. + IfAllowed, + /// Background work (proof automation, hide-to-tray recovery). The + /// foreground is never touched. + Never, +} + +/// Set once at startup in proof mode (`CODEXBAR_PROOF_MODE`): surfaces are +/// shown for automation but never activated, whatever the caller asked for. +static SUPPRESSED: AtomicBool = AtomicBool::new(false); + +/// Stop every CodexBar window from taking focus for the rest of the process. +pub fn suppress_all() { + SUPPRESSED.store(true, Ordering::Relaxed); +} + +fn is_suppressed() -> bool { + SUPPRESSED.load(Ordering::Relaxed) +} + +/// What [`apply`] does for one activation request. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum FocusStep { + /// Leave the foreground alone. + Skip, + /// `WebviewWindow::set_focus` (tao `force_window_active`). + Force, + /// One plain `SetForegroundWindow` that Windows may refuse. + Request, +} + +fn focus_step(activation: Activation, suppressed: bool) -> FocusStep { + if suppressed { + return FocusStep::Skip; + } + match activation { + Activation::UserAction => FocusStep::Force, + Activation::IfAllowed => FocusStep::Request, + Activation::Never => FocusStep::Skip, + } +} + +/// Focus an already shown window as far as `activation` allows. +/// +/// Call it after `WebviewWindow::show`. Both go through the event loop in +/// order, so the window is visible by the time the foreground request runs. +pub fn apply(window: &WebviewWindow, activation: Activation) -> Result<(), String> { + match focus_step(activation, is_suppressed()) { + FocusStep::Skip => Ok(()), + FocusStep::Force => window.set_focus().map_err(|e| e.to_string()), + FocusStep::Request => request_foreground(window), + } +} + +#[cfg(windows)] +fn request_foreground(window: &WebviewWindow) -> Result<(), String> { + let target = window.clone(); + window + .run_on_main_thread(move || { + let Some(hwnd) = root_hwnd(&target) else { + return; + }; + // SAFETY: `hwnd` is the live top-level window of `target`, which + // this closure keeps alive; these calls only read window state or + // ask the window manager for the foreground, and never write + // through the handle. + unsafe { + if IsWindowVisible(hwnd) == 0 || IsIconic(hwnd) != 0 { + return; + } + if GetForegroundWindow() == hwnd { + return; + } + if SetForegroundWindow(hwnd) == 0 { + tracing::debug!( + "shell: Windows kept the current foreground window; the surface stays unfocused" + ); + } + } + }) + .map_err(|e| e.to_string()) +} + +#[cfg(not(windows))] +fn request_foreground(_window: &WebviewWindow) -> Result<(), String> { + Ok(()) +} + +/// Let the CodexBar instance that is already running take the foreground when +/// this process hands off to it. +/// +/// A second launch is usually a user action (Start menu, Explorer, a +/// shortcut), so this process may set the foreground but the running one may +/// not. `tauri-plugin-single-instance` 2.4.1 (`src/platform_impl/windows.rs`) +/// finds the running instance through a hidden window of class `{id}-sic` +/// and title `{id}-siw`, sends it `WM_COPYDATA` and exits. Passing our +/// foreground permission on with `AllowSetForegroundWindow` first lets the +/// running instance's [`Activation::IfAllowed`] request succeed without the +/// `SendInput` Alt hack. Does nothing when no other instance is running. +#[cfg(windows)] +pub fn grant_foreground_to_running_instance(identifier: &str) { + let class = wide(&format!("{identifier}-sic")); + let title = wide(&format!("{identifier}-siw")); + // SAFETY: both buffers are NUL-terminated UTF-16 strings that outlive the + // call; the returned handle is only passed back to user32 as a value. + let hwnd = unsafe { FindWindowW(class.as_ptr(), title.as_ptr()) }; + if hwnd == 0 { + return; + } + let mut pid = 0u32; + // SAFETY: `pid` is a live out-parameter owned by this frame. + unsafe { GetWindowThreadProcessId(hwnd, &mut pid) }; + if pid == 0 || pid == std::process::id() { + return; + } + // SAFETY: plain value arguments; the call only updates the window + // manager's foreground permission for `pid`. + if unsafe { AllowSetForegroundWindow(pid) } == 0 { + tracing::debug!("shell: could not pass foreground permission to the running instance"); + } +} + +#[cfg(not(windows))] +pub fn grant_foreground_to_running_instance(_identifier: &str) {} + +/// Restore a minimized window without activating it. Run it on the main +/// thread. +/// +/// tao's `unminimize` uses `ShowWindow(SW_RESTORE)`, which activates the +/// window (tao `window_state.rs`, `WindowFlags::apply_diff`, lines 390-402). +/// `SW_SHOWNOACTIVATE` restores the previous size and position and leaves +/// the foreground where it is. Call `unminimize` afterwards to refresh tao's +/// cached minimized flag: tao re-reads `IsIconic` before it diffs +/// (`window.rs`, `Window::set_minimized`, lines 584-598), finds the window +/// already restored and issues no `SW_RESTORE`. +#[cfg(windows)] +pub fn restore_minimized_without_activation(window: &impl raw_window_handle::HasWindowHandle) { + const SW_SHOWNOACTIVATE: i32 = 4; + let Some(hwnd) = root_hwnd(window) else { + return; + }; + // SAFETY: `hwnd` is the live top-level window behind `window`; IsIconic + // only reads its state and ShowWindow only changes its show state. + unsafe { + if IsIconic(hwnd) != 0 { + ShowWindow(hwnd, SW_SHOWNOACTIVATE); + } + } +} + +/// Other platforms have no foreground-lock hack to avoid, so the runtime's +/// own `unminimize` does the restore. +#[cfg(not(windows))] +pub fn restore_minimized_without_activation(_window: &impl raw_window_handle::HasWindowHandle) {} + +#[cfg(windows)] +fn root_hwnd(window: &impl raw_window_handle::HasWindowHandle) -> Option { + const GA_ROOT: u32 = 2; + let handle = window.window_handle().ok()?; + let raw_window_handle::RawWindowHandle::Win32(h) = handle.as_raw() else { + return None; + }; + let inner = h.hwnd.get(); + // SAFETY: `inner` is a live window handle from tao; GetAncestor only reads + // the window tree. + let root = unsafe { GetAncestor(inner, GA_ROOT) }; + Some(if root != 0 { root } else { inner }) +} + +#[cfg(windows)] +fn wide(value: &str) -> Vec { + value.encode_utf16().chain(std::iter::once(0)).collect() +} + +#[cfg(windows)] +#[link(name = "user32")] +// SAFETY: FFI declarations for user32 window-manager calls; every call site +// passes live window handles, NUL-terminated strings or caller-owned +// out-parameters. +unsafe extern "system" { + fn GetAncestor(hwnd: isize, flags: u32) -> isize; + fn IsWindowVisible(hwnd: isize) -> i32; + fn IsIconic(hwnd: isize) -> i32; + fn GetForegroundWindow() -> isize; + fn SetForegroundWindow(hwnd: isize) -> i32; + fn FindWindowW(class_name: *const u16, window_name: *const u16) -> isize; + fn GetWindowThreadProcessId(hwnd: isize, process_id: *mut u32) -> u32; + fn AllowSetForegroundWindow(process_id: u32) -> i32; + fn ShowWindow(hwnd: isize, cmd_show: i32) -> i32; +} + +#[cfg(test)] +mod tests { + use super::{Activation, FocusStep, focus_step}; + + #[test] + fn user_actions_take_focus() { + assert_eq!(focus_step(Activation::UserAction, false), FocusStep::Force); + } + + #[test] + fn launches_and_handoffs_only_ask_windows_for_the_foreground() { + assert_eq!(focus_step(Activation::IfAllowed, false), FocusStep::Request); + } + + #[test] + fn background_work_never_touches_the_foreground() { + assert_eq!(focus_step(Activation::Never, false), FocusStep::Skip); + } + + #[test] + fn proof_mode_never_activates_any_surface() { + for activation in [ + Activation::UserAction, + Activation::IfAllowed, + Activation::Never, + ] { + assert_eq!(focus_step(activation, true), FocusStep::Skip); + } + } +} diff --git a/apps/desktop-tauri/src-tauri/src/shell/dwm.rs b/apps/desktop-tauri/src-tauri/src/shell/dwm.rs index 9993269b1a..8a79e311c0 100644 --- a/apps/desktop-tauri/src-tauri/src/shell/dwm.rs +++ b/apps/desktop-tauri/src-tauri/src/shell/dwm.rs @@ -264,11 +264,13 @@ fn force_dark_caption_inner(win: &tauri::WebviewWindow, keep_resize: bool) { } } - // Force frame recalculation + // Force frame recalculation. SWP_NOACTIVATE: without it SetWindowPos + // activates the window, and this runs on every surface transition. const SWP_FRAMECHANGED: u32 = 0x0020; const SWP_NOMOVE: u32 = 0x0002; const SWP_NOSIZE: u32 = 0x0001; const SWP_NOZORDER: u32 = 0x0004; + const SWP_NOACTIVATE: u32 = 0x0010; SetWindowPos( hwnd, 0, @@ -276,7 +278,7 @@ fn force_dark_caption_inner(win: &tauri::WebviewWindow, keep_resize: bool) { 0, 0, 0, - SWP_FRAMECHANGED | SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER, + SWP_FRAMECHANGED | SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE, ); } } diff --git a/apps/desktop-tauri/src-tauri/src/shell/flyout_window.rs b/apps/desktop-tauri/src-tauri/src/shell/flyout_window.rs index 40fbd16b6a..0a2f1dae7a 100644 --- a/apps/desktop-tauri/src-tauri/src/shell/flyout_window.rs +++ b/apps/desktop-tauri/src-tauri/src/shell/flyout_window.rs @@ -20,6 +20,7 @@ use std::time::{Duration, Instant}; use codexbar::settings::Settings; use tauri::{AppHandle, Manager, PhysicalPosition, WebviewUrl}; +use super::activation::{self, Activation}; use crate::geometry_store::{self, StoredSize}; use crate::state::AppState; use crate::surface::SurfaceMode; @@ -72,14 +73,21 @@ pub fn is_open(app: &AppHandle) -> bool { .is_some_and(|w| w.is_visible().unwrap_or(false)) } -/// Build (first open) or show + focus (subsequent opens) the flyout window at +/// Build (first open) or show (subsequent opens) the flyout window at /// `position`, if given, else the default tray-anchored position. +/// `activation` says whether it may take focus once shown (see +/// [`super::activation`]); on first open the frontend reveals the window, so +/// the request is stored and applied by `reveal_tray_panel_window`. /// /// `WebviewWindowBuilder::build` deadlocks when called synchronously from a /// Tauri command on Windows (see `commands/surface.rs::open_settings_window` /// precedent) — callers must invoke this from an async context (an `async` /// command, or `tauri::async_runtime::spawn`), never a sync command handler. -pub fn open_or_focus(app: &AppHandle, position: Option<(i32, i32)>) -> Result<(), String> { +pub fn open_or_focus( + app: &AppHandle, + position: Option<(i32, i32)>, + activation: Activation, +) -> Result<(), String> { let settings = Settings::load(); if let Some(window) = app.get_webview_window(FLYOUT_LABEL) { apply_window_always_on_top(&window, settings.tray_panel_always_on_top)?; @@ -91,7 +99,7 @@ pub fn open_or_focus(app: &AppHandle, position: Option<(i32, i32)>) -> Result<() reanchor(app)?; } window.show().map_err(|e| e.to_string())?; - window.set_focus().map_err(|e| e.to_string())?; + activation::apply(&window, activation)?; if show_grace_starts_now(false) { mark_shown(app); } @@ -127,6 +135,10 @@ pub fn open_or_focus(app: &AppHandle, position: Option<(i32, i32)>) -> Result<() // `dragDropEnabled: false` in tauri.conf.json for why this must be // disabled explicitly on every window that hosts that grid. .disable_drag_drop_handler() + // Showing never activates the flyout by itself (tao shows it with + // SW_SHOWNOACTIVATE); `activation::apply` decides whether it takes + // focus. + .focused(false) .visible(false); if let (Some(min_w), Some(min_h)) = (props.min_width, props.min_height) { builder = builder.min_inner_size(min_w, min_h); @@ -152,7 +164,7 @@ pub fn open_or_focus(app: &AppHandle, position: Option<(i32, i32)>) -> Result<() if show_grace_starts_now(true) { mark_shown(app); } - arm_reveal(app)?; + arm_reveal(app, activation)?; Ok(()) } @@ -181,11 +193,14 @@ fn show_grace_starts_now(first_build_hidden: bool) -> bool { !first_build_hidden } -fn arm_reveal(app: &AppHandle) -> Result<(), String> { +fn arm_reveal(app: &AppHandle, activation: Activation) -> Result<(), String> { let state = app .try_state::>() .ok_or_else(|| "app state unavailable".to_string())?; - state.lock().map_err(|e| e.to_string())?.arm_flyout_reveal(); + state + .lock() + .map_err(|e| e.to_string())? + .arm_flyout_reveal(activation); Ok(()) } @@ -214,7 +229,8 @@ pub fn toggle_with_blur_consume(app: &AppHandle, position: Option<(i32, i32)>) { } else { // Tray-toggle is fire-and-forget; open_or_focus surfaces its own // errors via tracing, so the toggle call site discards the result. - let _open = open_or_focus(app, position); + // Both callers (tray left-click, global hotkey) are user actions. + let _open = open_or_focus(app, position, Activation::UserAction); } } diff --git a/apps/desktop-tauri/src-tauri/src/shell/mod.rs b/apps/desktop-tauri/src-tauri/src/shell/mod.rs index a692281f62..3741d5446d 100644 --- a/apps/desktop-tauri/src-tauri/src/shell/mod.rs +++ b/apps/desktop-tauri/src-tauri/src/shell/mod.rs @@ -6,6 +6,7 @@ use std::sync::{LazyLock, Mutex}; use crate::surface::SurfaceMode; use crate::surface_target::SurfaceTarget; +pub mod activation; pub(crate) mod dwm; pub mod flyout_window; mod geometry; diff --git a/apps/desktop-tauri/src-tauri/src/shell/settings_window.rs b/apps/desktop-tauri/src-tauri/src/shell/settings_window.rs index a9abc81493..9518ba3657 100644 --- a/apps/desktop-tauri/src-tauri/src/shell/settings_window.rs +++ b/apps/desktop-tauri/src-tauri/src/shell/settings_window.rs @@ -3,18 +3,24 @@ use tauri::{Emitter, Manager, PhysicalPosition, WebviewUrl}; +use super::activation::{self, Activation}; + const SETTINGS_LABEL: &str = "settings"; const SETTINGS_WIDTH: f64 = 720.0; const SETTINGS_HEIGHT: f64 = 580.0; /// Open the detached Settings window, or focus it if already open. /// +/// Every caller is a user action (the tray menu or a Settings button in +/// CodexBar's own UI), so the window takes focus; proof mode still keeps it +/// unfocused (see [`super::activation`]). +/// /// When the window already exists, emits `settings-change-tab` so the /// frontend can switch to the requested tab without a full reload. pub fn open_or_focus(app: &tauri::AppHandle, tab: &str) -> Result<(), String> { if let Some(window) = app.get_webview_window(SETTINGS_LABEL) { window.show().map_err(|e| e.to_string())?; - window.set_focus().map_err(|e| e.to_string())?; + activation::apply(&window, Activation::UserAction)?; app.emit_to(SETTINGS_LABEL, "settings-change-tab", tab) .map_err(|e| e.to_string())?; return Ok(()); @@ -22,6 +28,8 @@ pub fn open_or_focus(app: &tauri::AppHandle, tab: &str) -> Result<(), String> { let url = WebviewUrl::App(format!("index.html?window=settings&tab={tab}").into()); + // `focused(false)`: showing the window never activates it by itself; + // `activation::apply` below decides whether it takes focus. let win = tauri::WebviewWindowBuilder::new(app, SETTINGS_LABEL, url) .title("CodexBar Settings") .inner_size(SETTINGS_WIDTH, SETTINGS_HEIGHT) @@ -29,6 +37,7 @@ pub fn open_or_focus(app: &tauri::AppHandle, tab: &str) -> Result<(), String> { .shadow(false) .theme(Some(tauri::Theme::Dark)) .resizable(true) + .focused(false) .build() .map_err(|e| e.to_string())?; @@ -48,7 +57,7 @@ pub fn open_or_focus(app: &tauri::AppHandle, tab: &str) -> Result<(), String> { let _ = win.set_position(PhysicalPosition::new(x, y)); } - Ok(()) + activation::apply(&win, Activation::UserAction) } /// Dismiss Settings without exiting CodexBar. diff --git a/apps/desktop-tauri/src-tauri/src/shell/transition.rs b/apps/desktop-tauri/src-tauri/src/shell/transition.rs index 0d2b3784bb..6b079ebc1b 100644 --- a/apps/desktop-tauri/src-tauri/src/shell/transition.rs +++ b/apps/desktop-tauri/src-tauri/src/shell/transition.rs @@ -11,6 +11,7 @@ use crate::surface::{SurfaceMode, SurfaceTransition, WindowProperties}; use crate::surface_target::SurfaceTarget; use crate::window_positioner::{self, PanelSize, Rect}; +use super::activation::Activation; use super::geometry::surface_panel_size; use super::position::default_surface_position; use super::window::{apply_window_layout, apply_window_properties, show_window}; @@ -75,11 +76,14 @@ pub(super) enum TransitionResolution { }, } +/// Move `main` to `mode`/`target`. `activation` says whether the surface may +/// take focus once shown (see [`super::activation`]). pub fn transition_to_target( app: &AppHandle, mode: SurfaceMode, target: SurfaceTarget, position: Option<(i32, i32)>, + activation: Activation, ) -> Result { apply_transition_request_with_strategy( app, @@ -89,6 +93,7 @@ pub fn transition_to_target( position, }, false, + activation, ) } @@ -96,14 +101,16 @@ fn apply_transition_request_with_strategy( app: &AppHandle, request: ShellTransitionRequest, force_same_mode_apply: bool, + activation: Activation, ) -> Result { - apply_transition_request(app, request, force_same_mode_apply) + apply_transition_request(app, request, force_same_mode_apply, activation) } fn apply_transition_request( app: &AppHandle, request: ShellTransitionRequest, force_same_mode_apply: bool, + activation: Activation, ) -> Result { let _transition_guard = SHELL_TRANSITION_SERIAL.lock().unwrap(); let window = app @@ -128,11 +135,17 @@ fn apply_transition_request( .or_else(|| preserved_visible_mode_change_position(&window, &resolution)); match resolution { - TransitionResolution::ModeChange { transition, target } => { - apply_transition(app, &window, &transition, &previous, target, position) - } + TransitionResolution::ModeChange { transition, target } => apply_transition( + app, + &window, + &transition, + &previous, + target, + position, + activation, + ), TransitionResolution::SameModeRetarget { mode, target } => { - apply_same_mode_target_update(app, &window, mode, target, position) + apply_same_mode_target_update(app, &window, mode, target, position, activation) } TransitionResolution::SameModeReopen { mode, target } => { let transition = SurfaceTransition { @@ -140,7 +153,15 @@ fn apply_transition_request( to: mode, properties: mode.window_properties(), }; - apply_transition(app, &window, &transition, &previous, target, position) + apply_transition( + app, + &window, + &transition, + &previous, + target, + position, + activation, + ) } TransitionResolution::Noop { mode } => Ok(mode), } @@ -413,6 +434,7 @@ fn apply_same_mode_target_update( mode: SurfaceMode, target: SurfaceTarget, position: Option<(i32, i32)>, + activation: Activation, ) -> Result { if let Some((x, y)) = position { let _ = window.set_position(os_position(window, x, y)); @@ -428,7 +450,7 @@ fn apply_same_mode_target_update( }, )?; events::emit_surface_mode_changed(app, mode, mode, target); - if show_window(window).is_ok() && mode == SurfaceMode::TrayPanel { + if show_window(window, activation).is_ok() && mode == SurfaceMode::TrayPanel { mark_tray_panel_shown(app); } Ok(mode) @@ -441,6 +463,7 @@ pub(super) fn apply_transition( previous: &SurfaceSnapshot, current_target: SurfaceTarget, position: Option<(i32, i32)>, + activation: Activation, ) -> Result { if let Some((x, y)) = position { let _ = window.set_position(os_position(window, x, y)); @@ -468,7 +491,7 @@ pub(super) fn apply_transition( // ever target Hidden/PopOut/Settings, none of which defer their // own reveal.) if needs_show { - let _ = show_window(window); + let _ = show_window(window, activation); } clamp_current_window_to_work_area(window); @@ -477,8 +500,10 @@ pub(super) fn apply_transition( Err(err) => { let recovery = recovery_snapshot_for_failed_transition(transition, previous, ¤t_target); + // Putting the previous surface back is not something the user + // asked for, so it never takes focus. if let Err(recovery_err) = restore_recovery_surface(&recovery, |mode, properties| { - apply_window_properties(window, mode, properties) + apply_window_properties(window, mode, properties, Activation::Never) }) { let hidden = hidden_surface_snapshot(); if let Err(hide_err) = window.hide().map_err(|e| e.to_string()) { diff --git a/apps/desktop-tauri/src-tauri/src/shell/window.rs b/apps/desktop-tauri/src-tauri/src/shell/window.rs index 256926ccc9..470e8c0187 100644 --- a/apps/desktop-tauri/src-tauri/src/shell/window.rs +++ b/apps/desktop-tauri/src-tauri/src/shell/window.rs @@ -9,6 +9,7 @@ use crate::surface::{SurfaceMode, SurfaceTransition, WindowProperties}; use crate::surface_target::SurfaceTarget; use super::SHELL_TRANSITION_SERIAL; +use super::activation::Activation; use super::transition::{SurfaceSnapshot, apply_transition, current_surface_snapshot}; pub(super) struct HideToTrayPlan { @@ -22,10 +23,11 @@ pub fn apply_window_properties( window: &WebviewWindow, mode: SurfaceMode, props: &WindowProperties, + activation: Activation, ) -> Result<(), String> { let needs_show = apply_window_layout(window, mode, props)?; if needs_show { - show_window(window)?; + show_window(window, activation)?; } Ok(()) } @@ -149,12 +151,12 @@ fn capped_logical_size(window: &WebviewWindow, width: f64, height: f64) -> (f64, (width.min(max_width), height.min(max_height)) } -/// Make the window visible and give it input focus. -pub fn show_window(window: &WebviewWindow) -> Result<(), String> { - let map_err = |e: tauri::Error| e.to_string(); - window.show().map_err(map_err)?; - window.set_focus().map_err(map_err)?; - Ok(()) +/// Make the window visible, then focus it as far as `activation` allows (see +/// [`super::activation`]). `main` is created with `"focus": false`, so `show` +/// itself never activates it. +pub fn show_window(window: &WebviewWindow, activation: Activation) -> Result<(), String> { + window.show().map_err(|e| e.to_string())?; + super::activation::apply(window, activation) } pub fn hide_to_tray_if_current

( @@ -181,7 +183,16 @@ where }; if let Some(transition) = plan.transition { - apply_transition(app, &window, &transition, &plan.previous, plan.target, None).map(Some) + apply_transition( + app, + &window, + &transition, + &plan.previous, + plan.target, + None, + Activation::Never, + ) + .map(Some) } else { let _ = window.hide(); Ok(Some(SurfaceMode::Hidden)) diff --git a/apps/desktop-tauri/src-tauri/src/state.rs b/apps/desktop-tauri/src-tauri/src/state.rs index 10d626ec43..28a203efd4 100644 --- a/apps/desktop-tauri/src-tauri/src/state.rs +++ b/apps/desktop-tauri/src-tauri/src/state.rs @@ -5,6 +5,7 @@ use std::path::PathBuf; use crate::commands::ProviderUsageSnapshot; use crate::proof_harness::ProofConfig; +use crate::shell::activation::Activation; use crate::surface::{SurfaceMode, SurfaceStateMachine, SurfaceTransition}; use crate::surface_target::SurfaceTarget; @@ -154,8 +155,9 @@ pub struct AppState { /// One-shot grace for a blur event caused while revealing the tray panel /// during explicit startup. pub startup_tray_blur_grace_until: Option, - /// One-shot permission for frontend layout code to reveal a newly opened flyout. - pub flyout_reveal_pending: bool, + /// One-shot permission for frontend layout code to reveal a newly opened + /// flyout, carrying whether the revealed window may take focus. + pub flyout_reveal_pending: Option, /// Active while a user gesture (resize drag, HTML5 drag-reorder) is /// running a Win32 modal loop that transiently steals focus from the /// WebView2 child. `(began, until)` — `until` is the hard expiry; @@ -208,7 +210,7 @@ impl AppState { last_shown_at: None, last_blur_dismissed_at: None, startup_tray_blur_grace_until: None, - flyout_reveal_pending: false, + flyout_reveal_pending: None, gesture_blur_guard: None, auto_resume: crate::auto_resume::AutoResumeState::default(), } @@ -247,16 +249,17 @@ impl AppState { .is_some_and(|until| now <= until) } - pub fn arm_flyout_reveal(&mut self) { - self.flyout_reveal_pending = true; + pub fn arm_flyout_reveal(&mut self, activation: Activation) { + self.flyout_reveal_pending = Some(activation); } pub fn clear_flyout_reveal(&mut self) { - self.flyout_reveal_pending = false; + self.flyout_reveal_pending = None; } - pub fn take_pending_flyout_reveal(&mut self) -> bool { - std::mem::take(&mut self.flyout_reveal_pending) + /// Consume the pending reveal, returning how the flyout may be activated. + pub fn take_pending_flyout_reveal(&mut self) -> Option { + self.flyout_reveal_pending.take() } /// Arm the gesture blur guard for 15s. Called when the frontend reports @@ -336,6 +339,7 @@ impl AppState { #[cfg(test)] mod tests { use super::AppState; + use crate::shell::activation::Activation; use crate::surface::SurfaceMode; use crate::surface_target::SurfaceTarget; @@ -460,27 +464,39 @@ mod tests { fn hidden_flyout_cannot_be_revealed_by_stale_layout_work() { let mut state = AppState::new(); - assert!(!state.take_pending_flyout_reveal()); + assert_eq!(state.take_pending_flyout_reveal(), None); } #[test] fn pending_flyout_reveal_is_consumed_once() { let mut state = AppState::new(); - state.arm_flyout_reveal(); + state.arm_flyout_reveal(Activation::UserAction); - assert!(state.take_pending_flyout_reveal()); - assert!(!state.take_pending_flyout_reveal()); + assert_eq!( + state.take_pending_flyout_reveal(), + Some(Activation::UserAction) + ); + assert_eq!(state.take_pending_flyout_reveal(), None); + } + + #[test] + fn pending_flyout_reveal_keeps_the_requested_activation() { + let mut state = AppState::new(); + + state.arm_flyout_reveal(Activation::Never); + + assert_eq!(state.take_pending_flyout_reveal(), Some(Activation::Never)); } #[test] fn pending_flyout_reveal_can_be_cleared_without_revealing() { let mut state = AppState::new(); - state.arm_flyout_reveal(); + state.arm_flyout_reveal(Activation::IfAllowed); state.clear_flyout_reveal(); - assert!(!state.take_pending_flyout_reveal()); + assert_eq!(state.take_pending_flyout_reveal(), None); } #[test] diff --git a/apps/desktop-tauri/src-tauri/src/tray_bridge.rs b/apps/desktop-tauri/src-tauri/src/tray_bridge.rs index cb11675767..2b77cd5881 100644 --- a/apps/desktop-tauri/src-tauri/src/tray_bridge.rs +++ b/apps/desktop-tauri/src-tauri/src/tray_bridge.rs @@ -294,7 +294,11 @@ fn handle_menu_event(app: &AppHandle, id: &str) { // default position (same placement chain the old TrayPanel // transition used) when no explicit position is given. crate::auto_refresh::note_menu_open(); - let _ = shell::flyout_window::open_or_focus(app, None); + let _ = shell::flyout_window::open_or_focus( + app, + None, + shell::activation::Activation::UserAction, + ); } Some(MenuAction::Refresh) => { let handle = app.clone(); diff --git a/apps/desktop-tauri/src-tauri/tauri.conf.json b/apps/desktop-tauri/src-tauri/tauri.conf.json index 99017595a6..36df315d0f 100644 --- a/apps/desktop-tauri/src-tauri/tauri.conf.json +++ b/apps/desktop-tauri/src-tauri/tauri.conf.json @@ -23,6 +23,7 @@ "decorations": false, "shadow": false, "visible": false, + "focus": false, "skipTaskbar": true, "alwaysOnTop": true, "theme": "Dark", diff --git a/rust/src/agent_sessions.rs b/rust/src/agent_sessions.rs index 1780c4893e..c54acc06f5 100644 --- a/rust/src/agent_sessions.rs +++ b/rust/src/agent_sessions.rs @@ -343,7 +343,7 @@ mod focus; mod parsers; pub mod pi_family; mod remote; -pub use focus::focus_session; +pub use focus::{focus_session, request_session_attention}; struct CodexRollout { path: PathBuf, diff --git a/rust/src/agent_sessions/focus.rs b/rust/src/agent_sessions/focus.rs index 2b4f728685..77ea8e3db6 100644 --- a/rust/src/agent_sessions/focus.rs +++ b/rust/src/agent_sessions/focus.rs @@ -1,21 +1,44 @@ use super::*; pub fn focus_session(session: &AgentSession) -> SessionFocusResult { + match local_process(session) { + Ok(pid) => focus_process(pid), + Err(result) => result, + } +} + +/// Flash the session's taskbar button without restoring or activating its +/// window. +/// +/// For callers that are not answering a user action, such as auto-resume +/// after a quota reset. Windows does not let a background process activate +/// another app's window, and [`focus_session`]'s `ShowWindow(SW_RESTORE)` +/// would still pop a minimized terminal up over the app the user is working +/// in. The flash stops once the user switches to the window. +/// +/// Returns `Ok(())` once the window was found and is flashing; otherwise the +/// same reason [`focus_session`] would report. +pub fn request_session_attention(session: &AgentSession) -> Result<(), SessionFocusResult> { + flash_process(local_process(session)?) +} + +/// The local process that owns `session`'s window, or why there is none. +fn local_process(session: &AgentSession) -> Result { match session.focus_target { - AgentSessionFocusTarget::Transcript { .. } => SessionFocusResult::unsupported( + AgentSessionFocusTarget::Transcript { .. } => Err(SessionFocusResult::unsupported( "This file-only session has no focusable Windows window.", - ), - AgentSessionFocusTarget::None => { - SessionFocusResult::unsupported("This session has no focus target on Windows.") - } + )), + AgentSessionFocusTarget::None => Err(SessionFocusResult::unsupported( + "This session has no focus target on Windows.", + )), AgentSessionFocusTarget::Process { pid } => { - if !is_local_host(&session.host) { - return SessionFocusResult::unsupported( + if is_local_host(&session.host) { + Ok(pid) + } else { + Err(SessionFocusResult::unsupported( "Remote session focus is not supported from this Windows desktop.", - ); + )) } - - focus_process(pid) } } } @@ -34,12 +57,12 @@ fn is_local_host(host: &str) -> bool { .unwrap_or(false) } +/// The first visible top-level window owned by `pid`. #[cfg(windows)] -fn focus_process(pid: u32) -> SessionFocusResult { +fn find_process_window(pid: u32) -> Result { use windows::Win32::Foundation::{BOOL, HWND, LPARAM}; use windows::Win32::UI::WindowsAndMessaging::{ - EnumWindows, GetWindowThreadProcessId, IsWindowVisible, SW_RESTORE, SetForegroundWindow, - ShowWindow, + EnumWindows, GetWindowThreadProcessId, IsWindowVisible, }; struct Search { @@ -74,11 +97,23 @@ fn focus_process(pid: u32) -> SessionFocusResult { // `search` lives on the stack; the LPARAM encodes a valid pointer to it. let result = unsafe { EnumWindows(Some(find_window), LPARAM(&mut search as *mut _ as isize)) }; if result.is_err() { - return SessionFocusResult::failed("Windows could not enumerate application windows."); + return Err(SessionFocusResult::failed( + "Windows could not enumerate application windows.", + )); } - let Some(window) = search.window else { - return SessionFocusResult::failed("No focusable window was found for this session."); + search.window.ok_or_else(|| { + SessionFocusResult::failed("No focusable window was found for this session.") + }) +} + +#[cfg(windows)] +fn focus_process(pid: u32) -> SessionFocusResult { + use windows::Win32::UI::WindowsAndMessaging::{SW_RESTORE, SetForegroundWindow, ShowWindow}; + + let window = match find_process_window(pid) { + Ok(window) => window, + Err(result) => return result, }; // SAFETY: `window` is an HWND returned by EnumWindows, so it refers to a // live window; ShowWindow/SetForegroundWindow only read the handle and @@ -93,7 +128,38 @@ fn focus_process(pid: u32) -> SessionFocusResult { } } +#[cfg(windows)] +fn flash_process(pid: u32) -> Result<(), SessionFocusResult> { + use windows::Win32::UI::WindowsAndMessaging::{ + FLASHW_TIMERNOFG, FLASHW_TRAY, FLASHWINFO, FlashWindowEx, + }; + + let window = find_process_window(pid)?; + let size = u32::try_from(std::mem::size_of::()) + .map_err(|_| SessionFocusResult::failed("Windows could not flash this session."))?; + let info = FLASHWINFO { + cbSize: size, + hwnd: window, + // Flash the taskbar button until the window reaches the foreground. + dwFlags: FLASHW_TRAY | FLASHW_TIMERNOFG, + uCount: 0, + dwTimeout: 0, + }; + // SAFETY: `info` is a fully initialized FLASHWINFO that outlives the call + // and `window` is a live HWND from EnumWindows. The return value is the + // window's previous caption state, not an error code. + let _was_active = unsafe { FlashWindowEx(&info) }; + Ok(()) +} + #[cfg(not(windows))] fn focus_process(_pid: u32) -> SessionFocusResult { SessionFocusResult::unsupported("Process focus requires the Windows desktop shell.") } + +#[cfg(not(windows))] +fn flash_process(_pid: u32) -> Result<(), SessionFocusResult> { + Err(SessionFocusResult::unsupported( + "Process focus requires the Windows desktop shell.", + )) +} diff --git a/rust/src/agent_sessions/tests.rs b/rust/src/agent_sessions/tests.rs index 2c2da7b174..c9342d4db9 100644 --- a/rust/src/agent_sessions/tests.rs +++ b/rust/src/agent_sessions/tests.rs @@ -546,6 +546,14 @@ bad line focus_session(&file_only), SessionFocusResult::Unsupported { .. } )); + assert!(matches!( + request_session_attention(&remote), + Err(SessionFocusResult::Unsupported { .. }) + )); + assert!(matches!( + request_session_attention(&file_only), + Err(SessionFocusResult::Unsupported { .. }) + )); } #[test] diff --git a/rust/src/core/hooks.rs b/rust/src/core/hooks.rs index 1d55c2c747..6febcea286 100644 --- a/rust/src/core/hooks.rs +++ b/rust/src/core/hooks.rs @@ -460,15 +460,24 @@ impl HookRunner { } let env = build_hook_environment(base_env, event); - let mut child = Command::new(&rule.executable) + let mut command = Command::new(&rule.executable); + command .args(&rule.arguments) .env_clear() .envs(env) .stdin(Stdio::piped()) .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn() - .map_err(|e| format!("launch failed: {e}"))?; + .stderr(Stdio::null()); + // Hooks fire on background events and their output is discarded: + // without this, a console hook opens a console window over the app the + // user is working in. + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + const CREATE_NO_WINDOW: u32 = 0x0800_0000; + command.creation_flags(CREATE_NO_WINDOW); + } + let mut child = command.spawn().map_err(|e| format!("launch failed: {e}"))?; if let Some(mut stdin) = child.stdin.take() { // Fire-and-forget payload delivery: a hook that closed stdin early diff --git a/rust/src/host/console_launch.rs b/rust/src/host/console_launch.rs new file mode 100644 index 0000000000..426510153d --- /dev/null +++ b/rust/src/host/console_launch.rs @@ -0,0 +1,295 @@ +//! Start a console CLI in its own console window without taking focus. +//! +//! Auto-resume reopens a captured CLI session after a background refresh, not +//! in answer to a user action, so the new console must not come up over the +//! app the user is working in. `std::process::Command` cannot set +//! `STARTUPINFOW.wShowWindow` on stable Rust (`CommandExt::show_window` is +//! unstable, rust-lang/rust#127544). On Windows this module calls +//! `CreateProcessW` directly with `STARTF_USESHOWWINDOW` and +//! `SW_SHOWMINNOACTIVE`, the request `start /min` makes: the console starts +//! minimized on the taskbar and the foreground window keeps focus. + +use std::path::Path; + +/// Start `program` with `args` in a new console window that starts minimized +/// and does not take focus, with `cwd` as its working directory. +/// +/// Batch shims (`.cmd` and `.bat`, such as npm's `claude.cmd`) run through +/// `cmd.exe` from the Windows system directory, with the script path and each +/// argument quoted. cmd.exe has no escape for `"`, `%` or control characters +/// inside quotes, so a script path or argument containing one is rejected. +/// +/// The child inherits this process's environment but no handles, and runs in +/// its own process group. Returns once the process has started. +#[cfg(windows)] +pub fn spawn_minimized_console(program: &Path, args: &[String], cwd: &Path) -> Result<(), String> { + use std::os::windows::io::{FromRawHandle, OwnedHandle}; + use windows::Win32::System::Threading::{ + CREATE_NEW_CONSOLE, CREATE_NEW_PROCESS_GROUP, CreateProcessW, PROCESS_INFORMATION, + STARTF_USESHOWWINDOW, STARTUPINFOW, + }; + use windows::Win32::UI::WindowsAndMessaging::SW_SHOWMINNOACTIVE; + use windows::core::{PCWSTR, PWSTR}; + + let program = + std::path::absolute(program).map_err(|error| format!("invalid program path: {error}"))?; + let LaunchLine { + application, + mut command_line, + } = launch_line(&program, args, &system_directory()?)?; + let application = wide_nul(application.as_os_str())?; + let cwd = wide_nul(cwd.as_os_str())?; + let startup = STARTUPINFOW { + cb: u32::try_from(std::mem::size_of::()) + .map_err(|error| format!("invalid startup info size: {error}"))?, + dwFlags: STARTF_USESHOWWINDOW, + wShowWindow: u16::try_from(SW_SHOWMINNOACTIVE.0) + .map_err(|error| format!("invalid show-window command: {error}"))?, + ..Default::default() + }; + let mut info = PROCESS_INFORMATION::default(); + // SAFETY: `application`, `command_line` and `cwd` are NUL-terminated UTF-16 + // buffers and `startup` is an initialized STARTUPINFOW; all of them outlive + // the call. `command_line` is a mutable buffer, as CreateProcessW requires. + // The kernel fills `info` on success. + unsafe { + CreateProcessW( + PCWSTR(application.as_ptr()), + PWSTR(command_line.as_mut_ptr()), + None, + None, + false, + CREATE_NEW_CONSOLE | CREATE_NEW_PROCESS_GROUP, + None, + PCWSTR(cwd.as_ptr()), + &startup, + &mut info, + ) + } + .map_err(|error| error.to_string())?; + // SAFETY: CreateProcessW succeeded, so `hThread` is a valid handle owned + // by this process; the OwnedHandle closes it exactly once. + drop(unsafe { OwnedHandle::from_raw_handle(info.hThread.0) }); + // SAFETY: CreateProcessW succeeded, so `hProcess` is a valid handle owned + // by this process; the OwnedHandle closes it exactly once. The child keeps + // running after its handle closes. + drop(unsafe { OwnedHandle::from_raw_handle(info.hProcess.0) }); + Ok(()) +} + +/// Start `program` with `args` in `cwd`. Only Windows has console windows to +/// keep in the background. +#[cfg(not(windows))] +pub fn spawn_minimized_console(program: &Path, args: &[String], cwd: &Path) -> Result<(), String> { + std::process::Command::new(program) + .args(args) + .current_dir(cwd) + .spawn() + .map(|_child| ()) + .map_err(|error| error.to_string()) +} + +/// What `CreateProcessW` runs for one launch. +#[cfg(windows)] +#[derive(Debug, PartialEq, Eq)] +struct LaunchLine { + /// Absolute path of the module to execute (`lpApplicationName`). + application: std::path::PathBuf, + /// Full command line as NUL-terminated UTF-16 (`lpCommandLine`). + command_line: Vec, +} + +/// Build the module path and command line for `program`, which must be +/// absolute. +/// +/// Executables get the MSVC argument quoting their C runtime parses. Batch +/// scripts run as `"\cmd.exe" /d /v:off /s /c ""