diff --git a/src-tauri/src/agents/antigravity.rs b/src-tauri/src/agents/antigravity.rs index 0fb4262..7f39f28 100644 --- a/src-tauri/src/agents/antigravity.rs +++ b/src-tauri/src/agents/antigravity.rs @@ -28,13 +28,45 @@ pub(crate) fn antigravity_config_paths(client: AgentClient, home: &Path) -> Vec< let directory = agent_configuration_environment("XDG_CONFIG_HOME") .filter(|path| path.is_absolute()) .unwrap_or_else(|| home.join(".config")); + vec![antigravity_ide_settings_path( + &directory, + find_antigravity_ide(home).as_deref(), + )] +} + +fn antigravity_ide_settings_path(directory: &Path, executable: Option<&Path>) -> PathBuf { + if let Some(executable) = executable { + let product = antigravity_resources(executable) + .and_then(|resources| fs::read_to_string(resources.join("product.json")).ok()) + .and_then(|text| serde_json::from_str::(&text).ok()); + let name = product + .as_ref() + .and_then(|value| value.get("nameShort")) + .and_then(Value::as_str) + .filter(|name| matches!(*name, "Antigravity IDE" | "Antigravity")) + .or_else(|| { + executable + .ancestors() + .find_map(|path| match path.file_name()?.to_str()? { + "Antigravity IDE.exe" | "Antigravity IDE.app" | "antigravity-ide" => { + Some("Antigravity IDE") + } + "Antigravity.exe" | "Antigravity.app" | "antigravity" => { + Some("Antigravity") + } + _ => None, + }) + }) + .unwrap_or("Antigravity IDE"); + return directory.join(name).join("User/settings.json"); + } let current = directory.join("Antigravity IDE/User/settings.json"); let legacy = directory.join("Antigravity/User/settings.json"); - vec![if !current.exists() && legacy.exists() { + if !current.exists() && legacy.exists() { legacy } else { current - }] + } } pub(crate) fn find_antigravity_cli(home: &Path) -> Option { diff --git a/src-tauri/src/agents/antigravity/tests.rs b/src-tauri/src/agents/antigravity/tests.rs index 5f9d9e4..916297d 100644 --- a/src-tauri/src/agents/antigravity/tests.rs +++ b/src-tauri/src/agents/antigravity/tests.rs @@ -87,6 +87,63 @@ fn antigravity_clients_have_independent_paths_and_launch_targets() { )); } +#[test] +fn antigravity_ide_settings_follow_the_installed_product() { + for (name, other) in [ + ("Antigravity IDE", "Antigravity"), + ("Antigravity", "Antigravity IDE"), + ] { + let home = Home::new(); + let directory = home.0.join("configuration"); + let executable = home.0.join("installation/Contents/MacOS/Electron"); + let resources = antigravity_resources(&executable).unwrap(); + home.save(&resources.join("product.json"), &json!({"nameShort":name})); + let other_settings = directory.join(other).join("User/settings.json"); + home.save(&other_settings, &json!({"editor.fontSize":17})); + let selected = directory.join(name).join("User/settings.json"); + assert!(!selected.exists()); + assert_eq!( + antigravity_ide_settings_path(&directory, Some(&executable)), + selected + ); + home.save(&selected, &json!({})); + assert_eq!( + antigravity_ide_settings_path(&directory, Some(&executable)), + selected + ); + assert_eq!( + fs::read_to_string(other_settings).unwrap(), + r#"{"editor.fontSize":17}"# + ); + } +} + +#[test] +fn antigravity_ide_settings_use_installation_name_without_product_metadata() { + let home = Home::new(); + let directory = home.0.join("configuration"); + for (binary, name) in [ + ("Antigravity IDE.exe", "Antigravity IDE"), + ("Antigravity.exe", "Antigravity"), + ( + "Antigravity IDE.app/Contents/MacOS/Electron", + "Antigravity IDE", + ), + ("Antigravity.app/Contents/MacOS/Electron", "Antigravity"), + ("antigravity-ide", "Antigravity IDE"), + ("antigravity", "Antigravity"), + ] { + let executable = home.0.join("installation").join(binary); + assert_eq!( + antigravity_ide_settings_path(&directory, Some(&executable)), + directory.join(name).join("User/settings.json") + ); + } + let legacy = directory.join("Antigravity/User/settings.json"); + home.save(&legacy, &json!({})); + assert_eq!(antigravity_ide_settings_path(&directory, None), legacy); +} + #[test] fn antigravity_updates_and_disable_preserve_external_settings() { for client in [AgentClient::AntigravityIde, AgentClient::AntigravityCli] { @@ -270,7 +327,18 @@ fn antigravity_ide_discovery_and_template_use_native_server() { fs::write(&executable, []).unwrap(); home.save( &resources.join("product.json"), - &json!({"ideVersion":"2.5.5"}), + &json!({"ideVersion":"2.5.5", "nameShort":"Antigravity IDE"}), + ); + let legacy = home + .0 + .join("AppData/Roaming/Antigravity/User/settings.json"); + home.save(&legacy, &json!({"editor.fontSize":17})); + let expected = home + .0 + .join("AppData/Roaming/Antigravity IDE/User/settings.json"); + assert_eq!( + antigravity_config_paths(AgentClient::AntigravityIde, &home.0), + vec![expected.clone()] ); assert_eq!(find_antigravity_ide(&home.0), Some(executable.clone())); assert_eq!( @@ -295,6 +363,7 @@ fn antigravity_ide_discovery_and_template_use_native_server() { }) .unwrap(); assert_eq!(updates.len(), 1); + assert_eq!(updates[0].path, expected); let value = parse(&updates[0].path, Some(&updates[0].after)).unwrap(); assert_eq!( Path::new(value[IDE_ENV_KEY][IDE_SERVER_ENV].as_str().unwrap()), diff --git a/src-tauri/src/agents/launch.rs b/src-tauri/src/agents/launch.rs index 99623dc..00ba731 100644 --- a/src-tauri/src/agents/launch.rs +++ b/src-tauri/src/agents/launch.rs @@ -1284,6 +1284,63 @@ fn launch_cli_agent( }) } +#[cfg(target_os = "windows")] +fn windows_powershell_cli_script( + executable: &Path, + working_directory: &Path, + arguments: &[String], +) -> String { + let directory = windows_powershell_single_quoted_literal(&path_to_string(working_directory)); + let executable = windows_powershell_single_quoted_literal(&path_to_string(executable)); + if arguments + .first() + .is_some_and(|arg| arg == "--cpa-antigravity-cli") + { + let arguments = arguments + .iter() + .map(|argument| { + let mut quoted = String::from("\""); + let mut backslashes = 0; + for ch in argument.chars() { + if ch == '\\' { + backslashes += 1; + continue; + } + quoted.push_str(&"\\".repeat(if ch == '"' { + backslashes * 2 + 1 + } else { + backslashes + })); + backslashes = 0; + quoted.push(ch); + } + quoted.push_str(&"\\".repeat(backslashes * 2)); + quoted.push('"'); + quoted + }) + .collect::>() + .join(" "); + return format!( + "Set-Location -LiteralPath {directory}; \ + $cpaLaunchInfo = New-Object System.Diagnostics.ProcessStartInfo; \ + $cpaLaunchInfo.FileName = {executable}; \ + $cpaLaunchInfo.WorkingDirectory = {directory}; \ + $cpaLaunchInfo.UseShellExecute = $false; \ + $cpaLaunchInfo.Arguments = {}; \ + $cpaLaunchProcess = [System.Diagnostics.Process]::Start($cpaLaunchInfo); \ + try {{ $cpaLaunchProcess.WaitForExit(); $global:LASTEXITCODE = $cpaLaunchProcess.ExitCode }} \ + finally {{ $cpaLaunchProcess.Dispose() }}", + windows_powershell_single_quoted_literal(&arguments), + ); + } + let arguments = arguments + .iter() + .map(|argument| windows_powershell_single_quoted_literal(argument)) + .collect::>() + .join(" "); + format!("Set-Location -LiteralPath {directory}; & {executable} {arguments}") +} + #[cfg(target_os = "windows")] fn launch_cli_agent( executable: &Path, @@ -1331,17 +1388,7 @@ fn launch_cli_agent( } "powershell" => { let mut command = Command::new(windows_powershell_executable()); - let arguments = arguments - .iter() - .map(|argument| windows_powershell_single_quoted_literal(argument)) - .collect::>() - .join(" "); - let script = format!( - "Set-Location -LiteralPath {}; & {} {}", - windows_powershell_single_quoted_literal(&path_to_string(working_directory)), - windows_powershell_single_quoted_literal(&path_to_string(executable)), - arguments, - ); + let script = windows_powershell_cli_script(executable, working_directory, arguments); command.args(["-NoLogo", "-NoProfile", "-NoExit", "-Command", &script]); command.creation_flags(CREATE_NEW_CONSOLE); command @@ -1411,6 +1458,84 @@ mod tests { assert_eq!(normalize_agent_terminal(" auto "), "auto"); } + #[cfg(windows)] + #[test] + fn antigravity_powershell_waits_for_gui_helper_and_preserves_arguments() { + let directory = env::temp_dir() + .join(format!( + "cpa-powershell-{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(), + )) + .join("用户's [workspace] $value"); + fs::create_dir_all(&directory).unwrap(); + let executable = directory.join("GUI helper.exe"); + let compiled = directory.parent().unwrap().join("helper.exe"); + let source = r#" +using System; +using System.IO; +using System.Threading; +class GuiHelper { + static void Main(string[] args) { + Thread.Sleep(350); + File.WriteAllLines(Path.Combine(Environment.CurrentDirectory, "arguments.txt"), args); + File.WriteAllText(Path.Combine(Environment.CurrentDirectory, "finished.txt"), "done"); + } +} +"#; + let script = format!( + "Add-Type -TypeDefinition {} -OutputAssembly {} -OutputType WindowsApplication -ErrorAction Stop", + windows_powershell_single_quoted_literal(source), + windows_powershell_single_quoted_literal(&path_to_string(&compiled)), + ); + let run = |script: &str| { + let mut command = Command::new(windows_powershell_executable()); + command.args([ + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-Command", + script, + ]); + configure_background_command(&mut command); + let result = command_output_with_timeout(&mut command, Duration::from_secs(20)) + .unwrap() + .expect("PowerShell timed out"); + assert!( + result.status.success(), + "{}", + String::from_utf8_lossy(&result.stderr) + ); + }; + run(&script); + fs::rename(compiled, &executable).unwrap(); + let arguments = vec![ + "--cpa-antigravity-cli".into(), + path_to_string(&directory), + "trailing slash \\".into(), + "quote \" and \\\"".into(), + "literal $value & [x] 'quoted'".into(), + String::new(), + ]; + let script = format!( + "{}; if (![IO.File]::Exists({})) {{ throw 'GUI helper is still running' }}", + windows_powershell_cli_script(&executable, &directory, &arguments), + windows_powershell_single_quoted_literal(&path_to_string( + &directory.join("finished.txt") + )), + ); + run(&script); + let received = fs::read_to_string(directory.join("arguments.txt")).unwrap(); + assert_eq!( + received.lines().collect::>(), + arguments.iter().map(String::as_str).collect::>() + ); + fs::remove_dir_all(directory).unwrap(); + } + #[test] fn desktop_process_matching_is_scoped_to_the_exact_bundle() { let application = Path::new("/Applications/Claude Desktop.app/Contents/MacOS/Claude");