diff --git a/src-tauri/src/agents/discovery.rs b/src-tauri/src/agents/discovery.rs index 60bc846..cbe0a7c 100644 --- a/src-tauri/src/agents/discovery.rs +++ b/src-tauri/src/agents/discovery.rs @@ -938,7 +938,7 @@ pub(crate) fn inspect_agent_config( .as_deref() .and_then(read_opencode_desktop_version), AgentClient::DeepSeekHarness => read_deepseek_harness_profile_version(home), - AgentClient::ZCode => read_zcode_app_version(home), + AgentClient::ZCode => executable.as_deref().and_then(read_zcode_app_version), _ => None, }; let version = cli_version.clone().or_else(|| app_version.clone()); @@ -2017,30 +2017,22 @@ pub(crate) fn read_codex_app_installation_version( } } -pub(crate) fn read_zcode_app_version(home: &Path) -> Option { +pub(crate) fn read_zcode_app_version(executable: &Path) -> Option { #[cfg(target_os = "windows")] { - find_zcode_desktop_executable(home).and_then(|path| { - read_windows_executable_version(&path).or_else(|| read_agent_version(&path, home)) - }) + // Missing version metadata must not cause the desktop app to be launched. + read_windows_executable_version(executable) } #[cfg(target_os = "macos")] { - [ - PathBuf::from("/Applications/ZCode.app"), - home.join("Applications/ZCode.app"), - ] - .into_iter() - .find(|path| path.is_dir()) - .and_then(|path| read_macos_app_version(&path)) - .or_else(|| { - find_named_agent_executable(home, &["zcode"]) - .and_then(|path| read_agent_version(&path, home)) - }) + let application = executable.ancestors().find(|path| { + path.extension().and_then(|value| value.to_str()) == Some("app") + })?; + read_macos_app_version(application) } #[cfg(not(any(target_os = "windows", target_os = "macos")))] { - let _ = home; + let _ = executable; None } } @@ -2050,6 +2042,7 @@ pub(crate) fn find_zcode_desktop_executable(home: &Path) -> Option { { let local = env::var_os("LOCALAPPDATA") .map(PathBuf::from) + .filter(|path| !path.as_os_str().is_empty()) .unwrap_or_else(|| home.join("AppData/Local")); let mut candidates = vec![ local.join("Programs/ZCode/ZCode.exe"), @@ -2067,6 +2060,7 @@ pub(crate) fn find_zcode_desktop_executable(home: &Path) -> Option { .into_iter() .find(|path| path.is_file()) .or_else(|| find_named_agent_executable(home, &["zcode"])) + .or_else(find_windows_registered_zcode_executable) } #[cfg(target_os = "macos")] { @@ -2097,6 +2091,124 @@ pub(crate) fn find_zcode_desktop_executable(home: &Path) -> Option { } } +#[cfg(target_os = "windows")] +pub(crate) fn find_windows_registered_zcode_executable() -> Option { + // Electron/NSIS permits arbitrary installation directories. Its uninstall + // record may omit InstallLocation and point DisplayIcon at an .ico file. + const DISCOVERY_SCRIPT: &str = r#" +$ErrorActionPreference = 'SilentlyContinue' +$ProgressPreference = 'SilentlyContinue' +[Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false) +$registryRoots = @( + 'HKCU:\Software\Microsoft\Windows\CurrentVersion', + 'HKCU:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion', + 'HKLM:\Software\Microsoft\Windows\CurrentVersion', + 'HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion' +) +$candidates = @( + foreach ($root in $registryRoots) { + $appPath = Get-Item -LiteralPath "$root\App Paths\ZCode.exe" + if ($appPath) { + $value = $appPath.GetValue('') + if ($value) { @{ kind = 'executable'; value = [string]$value } } + } + foreach ($key in (Get-ChildItem -LiteralPath "$root\Uninstall")) { + $entry = Get-ItemProperty -LiteralPath $key.PSPath + if ($entry.DisplayName -notmatch '^ZCode(?:$|[\s(])') { continue } + if ($entry.InstallLocation) { + @{ kind = 'directory'; value = [string]$entry.InstallLocation } + } + if ($entry.DisplayIcon) { + @{ kind = 'icon'; value = [string]$entry.DisplayIcon } + } + if ($entry.UninstallString) { + @{ kind = 'uninstaller'; value = [string]$entry.UninstallString } + } + } + } + $shortcutRoots = @( + [Environment]::GetFolderPath('Programs'), + [Environment]::GetFolderPath('CommonPrograms'), + [Environment]::GetFolderPath('DesktopDirectory'), + [Environment]::GetFolderPath('CommonDesktopDirectory') + ) | Where-Object { $_ -and (Test-Path -LiteralPath $_ -PathType Container) } + $shell = New-Object -ComObject WScript.Shell + foreach ($root in $shortcutRoots) { + foreach ($file in (Get-ChildItem -LiteralPath $root -Filter '*ZCode*.lnk' -Recurse)) { + $target = $shell.CreateShortcut($file.FullName).TargetPath + if ($target -and [System.IO.Path]::GetFileName($target) -ieq 'ZCode.exe') { + @{ kind = 'executable'; value = [string]$target } + } + } + } +) +ConvertTo-Json -InputObject $candidates -Compress +"#; + let encoded_command = windows_powershell_encoded_command(DISCOVERY_SCRIPT); + let mut command = Command::new(windows_powershell_executable()); + command.args([ + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-EncodedCommand", + &encoded_command, + ]); + configure_background_command(&mut command); + let output = command_output_with_timeout(&mut command, AGENT_VERSION_PROBE_TIMEOUT).ok()??; + if !output.status.success() { + return None; + } + parse_windows_zcode_discovery_output(&String::from_utf8_lossy(&output.stdout)) +} + +#[cfg(target_os = "windows")] +pub(crate) fn parse_windows_zcode_discovery_output(output: &str) -> Option { + let candidates: Vec = serde_json::from_str(output.trim()).ok()?; + candidates.into_iter().find_map(|candidate| { + let kind = candidate.get("kind")?.as_str()?; + let value = candidate.get("value")?.as_str()?.trim(); + let value = if let Some(quoted) = value.strip_prefix('"') { + quoted.split_once('"')?.0 + } else if kind == "uninstaller" { + // Never execute UninstallString; only use its executable's directory. + let end = value.to_ascii_lowercase().match_indices(".exe").find_map( + |(index, extension)| { + let end = index + extension.len(); + (value[end..].is_empty() || value[end..].starts_with(char::is_whitespace)) + .then_some(end) + }, + )?; + &value[..end] + } else if kind == "icon" { + value + .rsplit_once(',') + .filter(|(_, index)| index.trim().parse::().is_ok()) + .map(|(path, _)| path.trim()) + .unwrap_or(value) + } else { + value + }; + let path = PathBuf::from(value); + if !path.is_absolute() { + return None; + } + let executable = match kind { + "executable" => path, + "directory" => path.join("ZCode.exe"), + "icon" | "uninstaller" => path.parent()?.join("ZCode.exe"), + _ => return None, + }; + (executable + .file_name()? + .to_str()? + .eq_ignore_ascii_case("ZCode.exe") + && executable.is_file()) + .then_some(executable) + }) +} + #[cfg(target_os = "macos")] pub(crate) fn read_macos_app_version(application: &Path) -> Option { let info_plist = application.join("Contents/Info.plist"); diff --git a/src-tauri/src/tests/agent_paths.rs b/src-tauri/src/tests/agent_paths.rs index 094d6b5..c71546d 100644 --- a/src-tauri/src/tests/agent_paths.rs +++ b/src-tauri/src/tests/agent_paths.rs @@ -58,3 +58,117 @@ fn configuration_paths_ignore_inherited_environment() { } fs::remove_dir_all(home).unwrap(); } + +#[test] +fn zcode_installation_does_not_require_version_metadata() { + assert!(agent_installation_detected( + AgentClient::ZCode, + None, + true, + false, + )); + assert!(!agent_installation_detected( + AgentClient::ZCode, + None, + false, + false, + )); + let targets = agent_launch_targets( + AgentClient::ZCode, + Some(Path::new("ZCode.exe")), + None, + false, + ); + assert_eq!(targets.len(), 1); + assert_eq!(targets[0].id, "app"); +} + +#[cfg(target_os = "windows")] +#[test] +fn zcode_windows_finds_custom_installations_from_registered_paths() { + let home = agent_test_home("zcode-custom-installation"); + let directory = home.join("自定义应用 [桌面]/ZCode's directory"); + let executable = directory.join("ZCode.exe"); + fs::create_dir_all(&directory).unwrap(); + fs::write(&executable, []).unwrap(); + let icon = directory.join("uninstallerIcon.ico"); + let uninstaller = directory.join("Uninstall ZCode.exe"); + for (kind, value) in [ + ("executable", path_to_string(&executable)), + ("executable", format!("\"{}\"", executable.display())), + ("directory", path_to_string(&directory)), + ("icon", path_to_string(&icon)), + ("icon", format!("{},0", executable.display())), + ("icon", format!("\"{}\",-123", executable.display())), + ( + "uninstaller", + format!("\"{}\" /currentuser", uninstaller.display()), + ), + ( + "uninstaller", + format!("{} /allusers", uninstaller.display()), + ), + ] { + let output = serde_json::json!([{ "kind": kind, "value": value }]).to_string(); + assert_eq!( + parse_windows_zcode_discovery_output(&output), + Some(executable.clone()), + "{output}", + ); + } + fs::remove_dir_all(home).unwrap(); +} + +#[cfg(target_os = "windows")] +#[test] +fn zcode_windows_skips_stale_and_unrelated_registration_entries() { + let home = agent_test_home("zcode-stale-registration"); + let unrelated = home.join("Uninstall ZCode.exe"); + fs::write(&unrelated, []).unwrap(); + let executable = home.join("valid/ZCode.exe"); + fs::create_dir_all(executable.parent().unwrap()).unwrap(); + fs::write(&executable, []).unwrap(); + let mut candidates = vec![ + serde_json::json!({ "kind": "directory", "value": home }), + serde_json::json!({ "kind": "executable", "value": unrelated }), + serde_json::json!({ "kind": "executable", "value": home.join("missing/ZCode.exe") }), + serde_json::json!({ "kind": "executable", "value": "ZCode.exe" }), + serde_json::json!({ "kind": "executable", "value": "\"unterminated" }), + serde_json::json!({ "kind": "unknown", "value": executable }), + serde_json::json!({ "kind": "executable", "value": null }), + ]; + assert_eq!( + parse_windows_zcode_discovery_output(&serde_json::to_string(&candidates).unwrap()), + None, + ); + candidates.push(serde_json::json!({ "kind": "executable", "value": executable })); + assert_eq!( + parse_windows_zcode_discovery_output(&serde_json::to_string(&candidates).unwrap()), + Some(executable), + ); + assert_eq!(parse_windows_zcode_discovery_output("[]"), None); + assert_eq!(parse_windows_zcode_discovery_output("invalid JSON"), None); + fs::remove_dir_all(home).unwrap(); +} + +#[cfg(target_os = "windows")] +#[test] +fn zcode_windows_version_discovery_does_not_launch_the_application() { + let home = agent_test_home("zcode-version-no-launch"); + let executable = home.join("zcode.cmd"); + let marker = home.join("launched.txt"); + fs::write( + &executable, + format!( + "@echo off\r\necho launched > \"{}\"\r\necho 1.0.0\r\n", + marker.display() + ), + ) + .unwrap(); + assert_eq!(read_zcode_app_version(&executable), None); + assert!( + !marker.exists(), + "version discovery launched the application" + ); + fs::remove_dir_all(home).unwrap(); +}