From 2a497aa075ab26284e20518c6e02effe7211dfbd Mon Sep 17 00:00:00 2001 From: juan <2930882+juacker@users.noreply.github.com> Date: Wed, 22 Jul 2026 23:25:45 +0200 Subject: [PATCH] fix(windows): stop console windows flashing on every host spawn CLAI's desktop binary is a GUI-subsystem app, so every console-subsystem child it spawns makes CreateProcessW allocate a new visible console: the startup which/where probes, editor/terminal detection, .cmd editor shims, explorer.exe file-open, provider CLI sessions, stdio MCP servers, git for skill sources, taskkill tree-kills and bash_exec children all flashed a console window on Windows. Add a HideConsoleWindow trait (CREATE_NO_WINDOW on Windows, no-op elsewhere) for std and tokio Commands and apply it at every spawn site except terminal launches, where the visible console IS the product (cmd/PowerShell fallbacks); spawn_host_detached now takes an explicit HostWindow::{Hidden,Visible} so that decision is typed at the call site. --- src-tauri/Cargo.toml | 6 +- src-tauri/src/assistant/sandbox/runner.rs | 4 + .../src/assistant/sandbox/unsupported.rs | 4 + src-tauri/src/commands/skills.rs | 10 +- src-tauri/src/lib.rs | 1 + src-tauri/src/mcp/client.rs | 4 + src-tauri/src/providers/mod.rs | 13 ++- src-tauri/src/system_apps.rs | 54 ++++++++--- src-tauri/src/windows_console.rs | 97 +++++++++++++++++++ 9 files changed, 177 insertions(+), 16 deletions(-) create mode 100644 src-tauri/src/windows_console.rs diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index e55cf6a9..52c0695a 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -135,7 +135,11 @@ ts-rs = { version = "12.0.1", features = ["serde-compat", "serde-json-impl"] } dbus-secret-service = "4.1" [target.'cfg(windows)'.dependencies] -windows-sys = { version = "0.61.2", features = ["Win32_Storage_FileSystem"] } +windows-sys = { version = "0.61.2", features = [ + "Win32_Storage_FileSystem", + # CREATE_NO_WINDOW for windows_console.rs (hide spawned-console flashes). + "Win32_System_Threading", +] } # Unix-only: needed to SIGKILL the *whole* process group of a timed-out # bash_exec child (sandbox -> sh -> cargo -> rustc, …), not just the direct diff --git a/src-tauri/src/assistant/sandbox/runner.rs b/src-tauri/src/assistant/sandbox/runner.rs index a9e760c7..45cc8df9 100644 --- a/src-tauri/src/assistant/sandbox/runner.rs +++ b/src-tauri/src/assistant/sandbox/runner.rs @@ -194,11 +194,15 @@ fn kill_process_tree(_pid: Option) {} /// process may kill its own descendants without elevation. #[cfg(windows)] fn kill_process_tree_windows(pid: u32) { + use crate::windows_console::HideConsoleWindow; let _ = std::process::Command::new("taskkill") .args(windows_taskkill_args(pid)) .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::null()) + // taskkill is a console app: unhidden it would flash a console + // window every time a timed-out bash_exec child is killed. + .hide_console_window() .spawn(); } diff --git a/src-tauri/src/assistant/sandbox/unsupported.rs b/src-tauri/src/assistant/sandbox/unsupported.rs index 95153747..9bbac3fc 100644 --- a/src-tauri/src/assistant/sandbox/unsupported.rs +++ b/src-tauri/src/assistant/sandbox/unsupported.rs @@ -2,6 +2,7 @@ use tokio::process::Command; use super::runner::{prepare_stdio, run_spawned_child}; use super::SandboxCommand; +use crate::windows_console::HideConsoleWindow; pub async fn run(command: SandboxCommand) -> Result { let mut argv = command.argv.iter(); @@ -10,6 +11,9 @@ pub async fn run(command: SandboxCommand) -> Result Result<(), String> { /// the same path inside the sandbox (`--filesystem=home`), so the directory /// resolves to the same location on both sides of the hop. fn build_git_command(in_flatpak: bool, current_dir: Option<&Path>) -> Command { - if in_flatpak { + let mut command = if in_flatpak { let mut command = Command::new("flatpak-spawn"); if let Some(current_dir) = current_dir { command.arg(format!("--directory={}", current_dir.display())); @@ -538,7 +539,12 @@ fn build_git_command(in_flatpak: bool, current_dir: Option<&Path>) -> Command { .env("GIT_TERMINAL_PROMPT", "0") .env("GIT_ASKPASS", "true"); command - } + }; + // Background git must not flash a console window on Windows. Applied + // after the branch (a no-op for the Linux-only flatpak arm) so a future + // refactor cannot drop it from one arm. + command.hide_console_window(); + command } fn run_git(current_dir: Option<&Path>, args: I) -> Result<(), String> diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b5ee4fb1..ce800589 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -35,6 +35,7 @@ mod mcp; mod paths; mod providers; mod system_apps; +mod windows_console; mod workspace_index; use std::path::{Path, PathBuf}; diff --git a/src-tauri/src/mcp/client.rs b/src-tauri/src/mcp/client.rs index 65a2ce19..89a82d30 100644 --- a/src-tauri/src/mcp/client.rs +++ b/src-tauri/src/mcp/client.rs @@ -23,6 +23,7 @@ use crate::assistant::auth::McpSecretStorage; use crate::assistant::types::ToolDefinition; use crate::config::{ClaiConfig, McpEnvVar, McpServerAuth, McpServerConfig}; use crate::mcp::oauth; +use crate::windows_console::HideConsoleWindow; /// External MCP connect + tool discovery must not hang the `clai` bridge's /// `list_tools` — Claude Code waits on that call to expose *any* tool, so a @@ -569,6 +570,9 @@ impl McpClientManager { cmd.stdout(Stdio::piped()); cmd.stderr(Stdio::piped()); cmd.kill_on_drop(true); + // Stdio servers are background children of a GUI app; keep + // them from flashing a console window on Windows. + cmd.hide_console_window(); let mut child = cmd.spawn().map_err(|error| { format!( diff --git a/src-tauri/src/providers/mod.rs b/src-tauri/src/providers/mod.rs index 09edfff9..2f6d8ce4 100644 --- a/src-tauri/src/providers/mod.rs +++ b/src-tauri/src/providers/mod.rs @@ -13,6 +13,7 @@ use serde::{Deserialize, Serialize}; use std::process::Command; use crate::config::AiProvider; +use crate::windows_console::HideConsoleWindow; // ============================================================================= // Available Provider Info @@ -87,13 +88,17 @@ pub fn is_snap() -> bool { /// /// In Flatpak, this wraps the command with `flatpak-spawn --host`. pub(crate) fn get_host_command(cmd: &str) -> Command { - if is_flatpak() { + let mut command = if is_flatpak() { let mut command = Command::new("flatpak-spawn"); command.arg("--host").arg(cmd); command } else { Command::new(cmd) - } + }; + // Probes (`which`/`where`, `--version`, `xdg-mime`) must never flash a + // console window on Windows; see windows_console.rs. + command.hide_console_window(); + command } /// Common user-local binary paths to search when command isn't in PATH. @@ -283,6 +288,10 @@ fn build_host_cli_command_impl( if let Some(dir) = working_dir { command.current_dir(dir); } + // Provider CLIs are background children of a GUI app; without this every + // session spawn would flash a console window on Windows + // (windows_console.rs). + command.hide_console_window(); command } diff --git a/src-tauri/src/system_apps.rs b/src-tauri/src/system_apps.rs index 5c75513a..8eee8a75 100644 --- a/src-tauri/src/system_apps.rs +++ b/src-tauri/src/system_apps.rs @@ -29,6 +29,7 @@ use std::path::{Path, PathBuf}; use std::process::Command; use crate::providers::{command_exists, get_host_command, is_flatpak}; +use crate::windows_console::HideConsoleWindow; /// A probe-table entry the UI can offer in a dropdown. #[derive(Debug, Clone, Serialize, Deserialize, ts_rs::TS)] @@ -483,7 +484,9 @@ fn resolve_windows_program(bin: &str) -> String { if bin.contains('\\') || bin.contains('/') { return bin.to_string(); } - if let Ok(output) = Command::new("where").arg(bin).output() { + let mut probe = Command::new("where"); + probe.arg(bin).hide_console_window(); + if let Ok(output) = probe.output() { if output.status.success() { if let Some(first) = String::from_utf8_lossy(&output.stdout) .lines() @@ -497,10 +500,31 @@ fn resolve_windows_program(bin: &str) -> String { bin.to_string() } +/// Whether the spawned host app may show a console window on Windows. +/// +/// Everything CLAI launches is `Hidden` (GUI editors, `.cmd` shims, +/// `explorer.exe` — `CREATE_NO_WINDOW` never affects their GUI windows) +/// EXCEPT terminal launches, where the console window is exactly what the +/// user asked for (`cmd`/`powershell` fallbacks; `wt` draws its own window +/// either way). Not `#[cfg]`-gated so both variants compile on every +/// platform; on non-Windows the distinction is a no-op. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +enum HostWindow { + /// Suppress the child's console window (see windows_console.rs). + Hidden, + /// Let the child create a visible console: it IS the launched terminal. + Visible, +} + /// Spawn a host command detached (fire and forget). Under Flatpak the /// working directory is forwarded with `--directory` (plain /// `current_dir` would only move flatpak-spawn itself). -fn spawn_host_detached(bin: &str, args: &[String], dir: Option<&Path>) -> Result<(), String> { +fn spawn_host_detached( + bin: &str, + args: &[String], + dir: Option<&Path>, + window: HostWindow, +) -> Result<(), String> { let mut command: Command; if is_flatpak() { command = Command::new("flatpak-spawn"); @@ -527,6 +551,9 @@ fn spawn_host_detached(bin: &str, args: &[String], dir: Option<&Path>) -> Result } command.args(args); } + if window == HostWindow::Hidden { + command.hide_console_window(); + } let child = command .spawn() .map_err(|e| format!("Failed to launch `{}`: {}", bin, e))?; @@ -574,7 +601,12 @@ pub fn open_in_editor(config: &SystemAppsConfig, path: &Path, is_dir: bool) -> R .filter(|t| !t.trim().is_empty()) .ok_or_else(|| "Custom editor command is not configured.".to_string())?; let (bin, args) = parse_custom_template(template, "{path}", &path_str)?; - spawn_host_detached(&bin, &args, None) + // Visible: a custom command may be a console editor (vim, hx…) + // with no `in_terminal` flag to route it through a terminal — + // hiding its console would leave it running invisibly. GUI custom + // editors are unaffected either way; only `.cmd` shims of GUI + // editors keep a brief flash, and those have probe-table entries. + spawn_host_detached(&bin, &args, None, HostWindow::Visible) } Some(id) => { let spec = editors() @@ -600,7 +632,7 @@ pub fn open_in_editor(config: &SystemAppsConfig, path: &Path, is_dir: bool) -> R command.extend(args); run_in_terminal(config, dir, &command) } else { - spawn_host_detached(spec.bin, &args, None) + spawn_host_detached(spec.bin, &args, None, HostWindow::Hidden) } } } @@ -611,15 +643,15 @@ pub fn open_in_editor(config: &SystemAppsConfig, path: &Path, is_dir: bool) -> R pub fn open_with_system(path: &Path) -> Result<(), String> { let path_str = path.display().to_string(); if cfg!(target_os = "macos") { - spawn_host_detached("open", &[path_str], None) + spawn_host_detached("open", &[path_str], None, HostWindow::Hidden) } else if cfg!(target_os = "windows") { // `explorer.exe ` opens a file with its associated app or a // folder in Explorer. Spawned directly (not via `cmd`), so an // untrusted path cannot inject `cmd` metacharacters. explorer exits // non-zero even on success; the fire-and-forget reaper ignores it. - spawn_host_detached("explorer.exe", &[path_str], None) + spawn_host_detached("explorer.exe", &[path_str], None, HostWindow::Hidden) } else { - spawn_host_detached("xdg-open", &[path_str], None) + spawn_host_detached("xdg-open", &[path_str], None, HostWindow::Hidden) } } @@ -646,7 +678,7 @@ fn run_in_terminal( .ok_or_else(|| "Custom terminal command is not configured.".to_string())?; let (bin, mut args) = parse_custom_template(template, "{dir}", &dir_str)?; args.extend_from_slice(command); - return spawn_host_detached(&bin, &args, Some(dir)); + return spawn_host_detached(&bin, &args, Some(dir), HostWindow::Visible); } Some(id) if id != "auto" => { let spec = terminals() @@ -661,7 +693,7 @@ fn run_in_terminal( // Auto chain. The xdg-terminal-exec / $TERMINAL conventions are // Linux-only; other platforms go straight to the probe table. if cfg!(target_os = "linux") && command_exists("xdg-terminal-exec") { - return spawn_host_detached("xdg-terminal-exec", command, Some(dir)); + return spawn_host_detached("xdg-terminal-exec", command, Some(dir), HostWindow::Visible); } if let Some(term) = std::env::var("TERMINAL") .ok() @@ -681,7 +713,7 @@ fn run_in_terminal( args.push("-e".to_string()); args.extend_from_slice(command); } - return spawn_host_detached(&term, &args, Some(dir)); + return spawn_host_detached(&term, &args, Some(dir), HostWindow::Visible); } } for spec in terminals() { @@ -726,7 +758,7 @@ fn spawn_terminal_spec( } else { None }; - spawn_host_detached(spec.bin, &args, cwd) + spawn_host_detached(spec.bin, &args, cwd, HostWindow::Visible) } /// Resolve `rel_path` inside `root`, refusing anything that escapes it diff --git a/src-tauri/src/windows_console.rs b/src-tauri/src/windows_console.rs new file mode 100644 index 00000000..b6d51dae --- /dev/null +++ b/src-tauri/src/windows_console.rs @@ -0,0 +1,97 @@ +//! Suppress the console window Windows attaches to spawned processes. +//! +//! CLAI's desktop binary is a GUI-subsystem app: it owns no console, so every +//! console-subsystem child it spawns (`where` probes, `.cmd`/`.bat` editor +//! shims, `git`, `taskkill`, provider CLIs, stdio MCP servers) makes +//! `CreateProcessW` allocate a brand-new **visible** console — the console +//! window that flashes on startup and on every host-app launch on Windows. +//! +//! [`CREATE_NO_WINDOW`] tells `CreateProcessW` to give the child a console +//! without a window. GUI children (explorer, VS Code, Windows Terminal) are +//! unaffected — the flag only suppresses console-window creation — so it is +//! safe to apply to every spawn EXCEPT ones whose visible console *is* the +//! product: launching `cmd`/`powershell` as the user's terminal (see +//! `system_apps::spawn_host_detached`, which routes that decision through +//! `system_apps::HostWindow`). +//! +//! On non-Windows targets the trait is a no-op, so call sites stay +//! platform-unconditional and compile everywhere. + +/// Chainable helper that hides the child's console window on Windows. +/// +/// NOTE: `creation_flags` REPLACES the command's stored flags rather than +/// ORing into them. These are currently the only `creation_flags` callers in +/// the repo; if a spawn site ever needs another flag (e.g. +/// `DETACHED_PROCESS`), combine it with `CREATE_NO_WINDOW` in one call +/// instead of calling `creation_flags` twice. (The flags std/tokio need +/// internally, like `CREATE_UNICODE_ENVIRONMENT`, are ORed in at spawn time +/// and cannot be clobbered from here.) +pub(crate) trait HideConsoleWindow { + /// Apply `CREATE_NO_WINDOW` on Windows; no-op elsewhere. + fn hide_console_window(&mut self) -> &mut Self; +} + +#[cfg(windows)] +use windows_sys::Win32::System::Threading::CREATE_NO_WINDOW; + +impl HideConsoleWindow for std::process::Command { + #[cfg(windows)] + fn hide_console_window(&mut self) -> &mut Self { + use std::os::windows::process::CommandExt; + self.creation_flags(CREATE_NO_WINDOW) + } + + #[cfg(not(windows))] + fn hide_console_window(&mut self) -> &mut Self { + self + } +} + +impl HideConsoleWindow for tokio::process::Command { + #[cfg(windows)] + fn hide_console_window(&mut self) -> &mut Self { + self.creation_flags(CREATE_NO_WINDOW) + } + + #[cfg(not(windows))] + fn hide_console_window(&mut self) -> &mut Self { + self + } +} + +#[cfg(test)] +mod tests { + use super::HideConsoleWindow; + + /// The helper must be chainable mid-builder and must not break spawning + /// for `std::process::Command`. (On Windows this exercises the real + /// `CREATE_NO_WINDOW` path; elsewhere it verifies the no-op.) + #[test] + fn hidden_std_command_still_spawns_and_runs() { + let program = if cfg!(windows) { "cmd" } else { "sh" }; + let flag = if cfg!(windows) { "/C" } else { "-c" }; + let output = std::process::Command::new(program) + .arg(flag) + .arg("exit 0") + .hide_console_window() + .output() + .expect("hidden command should spawn"); + assert!(output.status.success()); + } + + /// Same guarantee for `tokio::process::Command` (the type used by the + /// MCP stdio, sandbox, and provider-CLI spawn paths). + #[tokio::test] + async fn hidden_tokio_command_still_spawns_and_runs() { + let program = if cfg!(windows) { "cmd" } else { "sh" }; + let flag = if cfg!(windows) { "/C" } else { "-c" }; + let output = tokio::process::Command::new(program) + .arg(flag) + .arg("exit 0") + .hide_console_window() + .output() + .await + .expect("hidden command should spawn"); + assert!(output.status.success()); + } +}