From 0e016d9e86ceea3dec985b771f551910baeb630c Mon Sep 17 00:00:00 2001 From: su-fen <715041@qq.com> Date: Mon, 6 Jul 2026 02:13:55 +0800 Subject: [PATCH] fix(gui): improve Windows compatibility across shell tools and previews - ManagedProcess no longer rejects Windows commands containing `&` (PowerShell call operator / cmd separator); mirrors the existing platform gate on the Bash tool, with a regression test - file_identity on Windows now uses volume_serial_number + file_index (the dev:ino analogue) instead of a lowercased path string, falling back to the path form when the OS leaves those fields unset - legacy .doc/.rtf preview conversion now works off macOS via headless LibreOffice (soffice) with an isolated profile; discovery covers LIVEAGENT_SOFFICE_PATH, PATH, and common install locations - move macOS-only window keys (titleBarStyle, trafficLightPosition) from the base tauri.conf.json into tauri.macos.conf.json - replace a hardcoded /tmp fallback with std::env::temp_dir() in the zsh prompt overlay helper - drop the never-wired find_windows_git_bash dead-code cluster Co-Authored-By: Claude Fable 5 --- .../src-tauri/src/commands/workspace/fs.rs | 170 +++++++++++++++++- .../src-tauri/src/runtime/shell_runner.rs | 77 -------- .../src-tauri/src/runtime/terminal/shell.rs | 2 +- crates/agent-gui/src-tauri/tauri.conf.json | 4 +- .../agent-gui/src-tauri/tauri.macos.conf.json | 14 ++ crates/agent-gui/src/lib/tools/shellTools.ts | 2 +- .../agent-gui/test/tools/shell-tools.test.mjs | 49 +++++ 7 files changed, 231 insertions(+), 87 deletions(-) diff --git a/crates/agent-gui/src-tauri/src/commands/workspace/fs.rs b/crates/agent-gui/src-tauri/src/commands/workspace/fs.rs index 0c57305ad..f297df700 100644 --- a/crates/agent-gui/src-tauri/src/commands/workspace/fs.rs +++ b/crates/agent-gui/src-tauri/src/commands/workspace/fs.rs @@ -233,8 +233,12 @@ fn file_identity(meta: &fs::Metadata, _canon: &Path) -> String { } #[cfg(windows)] -fn file_identity(_meta: &fs::Metadata, canon: &Path) -> String { - format!("path:{}", display_path(canon).to_lowercase()) +fn file_identity(meta: &fs::Metadata, canon: &Path) -> String { + use std::os::windows::fs::MetadataExt; + match (meta.volume_serial_number(), meta.file_index()) { + (Some(volume), Some(index)) => format!("{volume}:{index}"), + _ => format!("path:{}", display_path(canon).to_lowercase()), + } } fn levenshtein_at_most(a: &str, b: &str, max: usize) -> bool { @@ -893,7 +897,6 @@ fn infer_workspace_preview_mime(path: &Path, bytes: &[u8]) -> Option<&'static st } } -#[cfg(target_os = "macos")] fn truncate_process_error(bytes: &[u8]) -> String { let text = String::from_utf8_lossy(bytes).trim().to_string(); let mut chars = text.chars(); @@ -941,8 +944,165 @@ fn convert_document_to_html_preview(target: &Path) -> Result, String> { } #[cfg(not(target_os = "macos"))] -fn convert_document_to_html_preview(_target: &Path) -> Result, String> { - Err("Legacy Word/RTF preview conversion is only available on macOS via textutil".to_string()) +fn soffice_candidate_names() -> &'static [&'static str] { + if cfg!(windows) { + &["soffice.exe", "soffice.com"] + } else { + &["soffice", "libreoffice"] + } +} + +#[cfg(not(target_os = "macos"))] +fn find_soffice_binary() -> Option { + if let Ok(raw) = std::env::var("LIVEAGENT_SOFFICE_PATH") { + let trimmed = raw.trim().trim_matches('"'); + if !trimmed.is_empty() { + let path = expand_tilde_path(trimmed); + if path.is_file() { + return Some(path); + } + } + } + + let path_var = std::env::var_os("PATH").unwrap_or_default(); + for dir in std::env::split_paths(&path_var) { + for name in soffice_candidate_names() { + let candidate = dir.join(name); + if candidate.is_file() { + return Some(candidate); + } + } + } + + #[cfg(windows)] + { + let roots = ["ProgramFiles", "ProgramW6432", "ProgramFiles(x86)"] + .iter() + .filter_map(|var| std::env::var_os(var).map(PathBuf::from)) + .chain([ + PathBuf::from(r"C:\Program Files"), + PathBuf::from(r"C:\Program Files (x86)"), + ]); + for root in roots { + let candidate = root.join("LibreOffice").join("program").join("soffice.exe"); + if candidate.is_file() { + return Some(candidate); + } + } + } + + #[cfg(not(windows))] + { + for candidate in [ + "/usr/bin/soffice", + "/usr/local/bin/soffice", + "/opt/libreoffice/program/soffice", + ] { + let candidate = Path::new(candidate); + if candidate.is_file() { + return Some(candidate.to_path_buf()); + } + } + } + + None +} + +#[cfg(not(target_os = "macos"))] +fn path_to_file_url(path: &Path) -> String { + let raw = path.to_string_lossy().replace('\\', "/"); + let mut encoded = String::with_capacity(raw.len()); + for ch in raw.chars() { + match ch { + 'A'..='Z' | 'a'..='z' | '0'..='9' | '/' | ':' | '.' | '-' | '_' | '~' => { + encoded.push(ch); + } + _ => { + let mut buf = [0u8; 4]; + for byte in ch.encode_utf8(&mut buf).as_bytes() { + encoded.push_str(&format!("%{byte:02X}")); + } + } + } + } + if encoded.starts_with('/') { + format!("file://{encoded}") + } else { + format!("file:///{encoded}") + } +} + +#[cfg(not(target_os = "macos"))] +fn run_soffice_document_conversion( + soffice: &Path, + target: &Path, + out_dir: &Path, +) -> Result, String> { + // A dedicated profile keeps headless conversion from clashing with a running LibreOffice. + let profile_url = path_to_file_url(&out_dir.join("profile")); + let mut command = Command::new(soffice); + #[cfg(windows)] + crate::runtime::process::configure_child_process_group(&mut command); + let output = command + .arg(format!("-env:UserInstallation={profile_url}")) + .arg("--headless") + .arg("--norestore") + .arg("--convert-to") + .arg("html") + .arg("--outdir") + .arg(out_dir) + .arg(target) + .output() + .map_err(|e| format!("Failed to start LibreOffice for document preview: {e}"))?; + + if !output.status.success() { + let detail = truncate_process_error(&output.stderr); + return Err(if detail.is_empty() { + "Failed to convert document preview with LibreOffice".to_string() + } else { + format!("Failed to convert document preview with LibreOffice: {detail}") + }); + } + + let stem = target + .file_stem() + .ok_or_else(|| "Document preview target has no file name".to_string())?; + let html_path = out_dir.join(stem).with_extension("html"); + let bytes = fs::read(&html_path) + .map_err(|e| format!("LibreOffice did not produce a document preview: {e}"))?; + if bytes.is_empty() { + return Err("Document preview conversion returned empty HTML".to_string()); + } + if bytes.len() > READ_MAX_PREVIEW_BYTES { + return Err(FsError::Other(format!( + "Converted document preview is too large ({} bytes, max {READ_MAX_PREVIEW_BYTES} bytes)", + bytes.len() + )) + .to_string()); + } + Ok(bytes) +} + +#[cfg(not(target_os = "macos"))] +fn convert_document_to_html_preview(target: &Path) -> Result, String> { + let soffice = find_soffice_binary().ok_or_else(|| { + "Legacy Word/RTF preview conversion requires LibreOffice; install it or set LIVEAGENT_SOFFICE_PATH to the soffice binary".to_string() + })?; + + let nanos = std::time::SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_nanos()) + .unwrap_or(0); + let out_dir = std::env::temp_dir() + .join("liveagent") + .join("workspace-preview-convert") + .join(format!("{}-{}", std::process::id(), nanos)); + fs::create_dir_all(&out_dir) + .map_err(|e| format!("Failed to prepare document preview workspace: {e}"))?; + + let result = run_soffice_document_conversion(&soffice, target, &out_dir); + let _ = fs::remove_dir_all(&out_dir); + result } fn document_preview_cache_dir() -> PathBuf { diff --git a/crates/agent-gui/src-tauri/src/runtime/shell_runner.rs b/crates/agent-gui/src-tauri/src/runtime/shell_runner.rs index ba2b763f0..29d30edde 100644 --- a/crates/agent-gui/src-tauri/src/runtime/shell_runner.rs +++ b/crates/agent-gui/src-tauri/src/runtime/shell_runner.rs @@ -1,7 +1,5 @@ use serde::Serialize; use std::collections::HashMap; -#[cfg(windows)] -use std::env; use std::fs; use std::io::{self, Read}; use std::path::{Component, Path, PathBuf}; @@ -12,8 +10,6 @@ use std::sync::{ }; use std::time::{Duration, Instant}; -#[cfg(windows)] -use crate::runtime::platform::resolve_program_path_with_current_dir; use crate::runtime::platform::{expand_tilde_path, maybe_augment_macos_path, shell_basename}; use crate::runtime::process::{configure_child_process_group, terminate_child_process_tree}; @@ -314,79 +310,6 @@ fn windows_cmd_command(cmd: &str) -> String { format!("chcp 65001>nul & {cmd}") } -#[cfg(windows)] -fn push_existing_unique_path(paths: &mut Vec, path: PathBuf) { - if !path.is_file() { - return; - } - let normalized = path.to_string_lossy().to_ascii_lowercase(); - if paths - .iter() - .any(|existing| existing.to_string_lossy().to_ascii_lowercase() == normalized) - { - return; - } - paths.push(path); -} - -#[cfg(windows)] -fn push_git_bash_install_root(paths: &mut Vec, root: PathBuf) { - let git_root = root.join("Git"); - push_existing_unique_path(paths, git_root.join("bin").join("bash.exe")); - push_existing_unique_path(paths, git_root.join("usr").join("bin").join("bash.exe")); -} - -#[cfg(windows)] -fn normalize_configured_path(raw: &str) -> Option { - let trimmed = raw.trim().trim_matches('"'); - if trimmed.is_empty() { - return None; - } - Some(expand_tilde_path(trimmed)) -} - -#[cfg(windows)] -fn is_probable_git_bash_path(path: &Path) -> bool { - let normalized = path - .to_string_lossy() - .replace('/', "\\") - .to_ascii_lowercase(); - normalized.ends_with("\\git\\bin\\bash.exe") - || normalized.ends_with("\\git\\usr\\bin\\bash.exe") -} - -#[cfg(windows)] -#[allow(dead_code)] -fn find_windows_git_bash() -> Option { - let mut paths = Vec::new(); - - for var in ["LIVEAGENT_GIT_BASH_PATH", "CLAUDE_CODE_GIT_BASH_PATH"] { - if let Some(raw) = env::var_os(var) { - let raw = raw.to_string_lossy(); - if let Some(path) = normalize_configured_path(&raw) { - push_existing_unique_path(&mut paths, path); - } - } - } - - for var in ["ProgramFiles", "ProgramW6432", "ProgramFiles(x86)"] { - if let Some(root) = env::var_os(var) { - push_git_bash_install_root(&mut paths, PathBuf::from(root)); - } - } - - for root in [r"C:\Program Files", r"C:\Program Files (x86)"] { - push_git_bash_install_root(&mut paths, PathBuf::from(root)); - } - - let path_bash = resolve_program_path_with_current_dir("bash", None); - if is_probable_git_bash_path(&path_bash) { - push_existing_unique_path(&mut paths, path_bash); - } - - paths.into_iter().next() -} - #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) struct ShellExecutionProfile { pub platform: &'static str, diff --git a/crates/agent-gui/src-tauri/src/runtime/terminal/shell.rs b/crates/agent-gui/src-tauri/src/runtime/terminal/shell.rs index e60e70c5e..2c913e2c3 100644 --- a/crates/agent-gui/src-tauri/src/runtime/terminal/shell.rs +++ b/crates/agent-gui/src-tauri/src/runtime/terminal/shell.rs @@ -131,7 +131,7 @@ pub(crate) fn configure_zsh_colored_prompt(cmd: &mut CommandBuilder) { pub(crate) fn create_zsh_prompt_overlay(prompt: &str) -> Option { let base = dirs::cache_dir() .or_else(dirs::home_dir) - .unwrap_or_else(|| PathBuf::from("/tmp")); + .unwrap_or_else(std::env::temp_dir); let zdotdir = base.join("liveagent-zsh"); if fs::create_dir_all(&zdotdir).is_err() { return None; diff --git a/crates/agent-gui/src-tauri/tauri.conf.json b/crates/agent-gui/src-tauri/tauri.conf.json index 3dad5b3f5..e239df925 100644 --- a/crates/agent-gui/src-tauri/tauri.conf.json +++ b/crates/agent-gui/src-tauri/tauri.conf.json @@ -18,9 +18,7 @@ "height": 800, "minWidth": 1200, "minHeight": 720, - "backgroundThrottling": "disabled", - "titleBarStyle": "Overlay", - "trafficLightPosition": { "x": 18, "y": 18 } + "backgroundThrottling": "disabled" } ], "security": { diff --git a/crates/agent-gui/src-tauri/tauri.macos.conf.json b/crates/agent-gui/src-tauri/tauri.macos.conf.json index 55951779d..4e1811d62 100644 --- a/crates/agent-gui/src-tauri/tauri.macos.conf.json +++ b/crates/agent-gui/src-tauri/tauri.macos.conf.json @@ -1,5 +1,19 @@ { "$schema": "https://schema.tauri.app/config/2", + "app": { + "windows": [ + { + "title": "", + "width": 1400, + "height": 800, + "minWidth": 1200, + "minHeight": 720, + "backgroundThrottling": "disabled", + "titleBarStyle": "Overlay", + "trafficLightPosition": { "x": 18, "y": 18 } + } + ] + }, "bundle": { "targets": [ "app", diff --git a/crates/agent-gui/src/lib/tools/shellTools.ts b/crates/agent-gui/src/lib/tools/shellTools.ts index 90dc9ba45..32468419b 100644 --- a/crates/agent-gui/src/lib/tools/shellTools.ts +++ b/crates/agent-gui/src/lib/tools/shellTools.ts @@ -766,7 +766,7 @@ export function createShellTools(params: { const command = typeof toolCall.arguments?.command === "string" ? toolCall.arguments.command.trim() : ""; if (!command) throw new Error('ManagedProcess.command is required for action="start"'); - if (scanShellSyntax(command).background) { + if (runtimePlatform !== "windows" && scanShellSyntax(command).background) { throw new Error( "ManagedProcess.command must be a foreground command. Remove `&`; ManagedProcess starts it in the background and captures logs automatically.", ); diff --git a/crates/agent-gui/test/tools/shell-tools.test.mjs b/crates/agent-gui/test/tools/shell-tools.test.mjs index a95309696..c32828d25 100644 --- a/crates/agent-gui/test/tools/shell-tools.test.mjs +++ b/crates/agent-gui/test/tools/shell-tools.test.mjs @@ -471,6 +471,55 @@ test("ManagedProcess rejects nested shell background operators", async () => { assert.deepEqual(calls, []); }); +test("ManagedProcess does not apply POSIX ampersand background validation on Windows", async () => { + const calls = []; + const loader = createTsModuleLoader({ + mocks: { + "@tauri-apps/api/core": { + async invoke(command, args) { + calls.push({ command, args }); + assert.equal(command, "managed_process_start"); + return { + process: { + id: "proc-win", + label: null, + command: args.command, + cwd: "C:\\repo", + shell: "pwsh", + pid: 456, + log_path: "C:\\Users\\me\\.liveagent\\process-logs\\proc-win.log", + started_at: 10, + finished_at: null, + exit_code: null, + running: true, + }, + }; + }, + }, + }, + }); + + const { createShellTools } = loader.loadModule("src/lib/tools/shellTools.ts"); + const bundle = createShellTools({ + workdir: "/repo", + providerId: "claude_code", + runtimePlatform: "windows", + }); + + const result = await bundle.executeToolCall({ + type: "toolCall", + id: "managed-start-windows", + name: "ManagedProcess", + arguments: { + action: "start", + command: '& "C:/Program Files/App/app.exe"', + }, + }); + + assert.equal(result.isError, false); + assert.equal(calls.length, 1); +}); + test("Bash tool marks stdio-open shell responses as errors", async () => { const loader = createTsModuleLoader({ mocks: {