Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions src-tauri/src/assistant/sandbox/runner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -194,11 +194,15 @@ fn kill_process_tree(_pid: Option<u32>) {}
/// 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();
}

Expand Down
4 changes: 4 additions & 0 deletions src-tauri/src/assistant/sandbox/unsupported.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<super::SandboxCommandOutput, String> {
let mut argv = command.argv.iter();
Expand All @@ -10,6 +11,9 @@ pub async fn run(command: SandboxCommand) -> Result<super::SandboxCommandOutput,
.ok_or_else(|| "Sandbox command argv cannot be empty".to_string())?;
let mut child_command = Command::new(program);
child_command.args(argv).current_dir(&command.cwd);
// Shell commands run headless with piped stdio; on Windows an unflagged
// console child would flash a console window per bash_exec invocation.
child_command.hide_console_window();
prepare_stdio(&mut child_command);

let child = child_command
Expand Down
10 changes: 8 additions & 2 deletions src-tauri/src/commands/skills.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ use crate::config::{
discover_skills, discover_skills_with_diagnostics, SkillDefinition, SkillSourceConfig,
SkillSourceDiagnostic, SkillSourceKind,
};
use crate::windows_console::HideConsoleWindow;
use crate::AppState;

const GIT_SYNC_TIMEOUT: Duration = Duration::from_secs(120);
Expand Down Expand Up @@ -518,7 +519,7 @@ fn sync_git_skill_source(source: &mut SkillSourceConfig) -> 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()));
Expand All @@ -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<I, S>(current_dir: Option<&Path>, args: I) -> Result<(), String>
Expand Down
1 change: 1 addition & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ mod mcp;
mod paths;
mod providers;
mod system_apps;
mod windows_console;
mod workspace_index;

use std::path::{Path, PathBuf};
Expand Down
4 changes: 4 additions & 0 deletions src-tauri/src/mcp/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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!(
Expand Down
13 changes: 11 additions & 2 deletions src-tauri/src/providers/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ use serde::{Deserialize, Serialize};
use std::process::Command;

use crate::config::AiProvider;
use crate::windows_console::HideConsoleWindow;

// =============================================================================
// Available Provider Info
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
}

Expand Down
54 changes: 43 additions & 11 deletions src-tauri/src/system_apps.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down Expand Up @@ -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()
Expand All @@ -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");
Expand All @@ -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))?;
Expand Down Expand Up @@ -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()
Expand All @@ -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)
}
}
}
Expand All @@ -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 <path>` 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)
}
}

Expand All @@ -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()
Expand All @@ -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()
Expand All @@ -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() {
Expand Down Expand Up @@ -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
Expand Down
97 changes: 97 additions & 0 deletions src-tauri/src/windows_console.rs
Original file line number Diff line number Diff line change
@@ -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());
}
}
Loading