diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index a203508..23912d3 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -55,6 +55,8 @@ windows-sys = { version = "0.59", features = [ "Win32_Security", "Win32_Storage_FileSystem", "Win32_System_Diagnostics_ToolHelp", + "Win32_System_Environment", "Win32_System_JobObjects", + "Win32_System_Registry", "Win32_System_Threading", ] } diff --git a/src-tauri/src/agents.rs b/src-tauri/src/agents.rs index 6991800..553ef7a 100644 --- a/src-tauri/src/agents.rs +++ b/src-tauri/src/agents.rs @@ -1,23 +1,26 @@ - use super::*; +mod backups; mod commands; mod configuration; -mod native_oauth; -mod discovery; mod deepseek_harness; -mod transactions; -mod backups; -mod templates; +mod discovery; mod launch; +mod native_oauth; mod state; +mod templates; +mod transactions; +#[cfg(target_os = "windows")] +mod windows_probe; +pub(crate) use backups::*; pub(crate) use commands::*; pub(crate) use configuration::*; -pub(crate) use native_oauth::*; -pub(crate) use discovery::*; pub(crate) use deepseek_harness::*; -pub(crate) use transactions::*; -pub(crate) use backups::*; -pub(crate) use templates::*; +pub(crate) use discovery::*; pub(crate) use launch::*; +pub(crate) use native_oauth::*; pub(crate) use state::*; +pub(crate) use templates::*; +pub(crate) use transactions::*; +#[cfg(target_os = "windows")] +pub(crate) use windows_probe::*; diff --git a/src-tauri/src/agents/discovery.rs b/src-tauri/src/agents/discovery.rs index 10a39b8..5bd2ab8 100644 --- a/src-tauri/src/agents/discovery.rs +++ b/src-tauri/src/agents/discovery.rs @@ -101,18 +101,15 @@ pub(crate) fn opencode_config_path_from_environment( } pub(crate) fn kimi_code_home(home: &Path) -> PathBuf { - agent_configuration_environment("KIMI_CODE_HOME") - .unwrap_or_else(|| home.join(".kimi-code")) + agent_configuration_environment("KIMI_CODE_HOME").unwrap_or_else(|| home.join(".kimi-code")) } pub(crate) fn grok_build_home(home: &Path) -> PathBuf { - agent_configuration_environment("GROK_HOME") - .unwrap_or_else(|| home.join(".grok")) + agent_configuration_environment("GROK_HOME").unwrap_or_else(|| home.join(".grok")) } pub(crate) fn deepseek_harness_home(home: &Path) -> PathBuf { - agent_configuration_environment("DSH_HOME") - .unwrap_or_else(|| home.join(".dsh")) + agent_configuration_environment("DSH_HOME").unwrap_or_else(|| home.join(".dsh")) } pub(crate) fn read_deepseek_harness_profile_version(home: &Path) -> Option { @@ -137,8 +134,7 @@ pub(crate) fn read_package_json_version(path: &Path) -> Option { } pub(crate) fn pi_agent_directory(home: &Path) -> PathBuf { - agent_configuration_environment("PI_CODING_AGENT_DIR") - .unwrap_or_else(|| home.join(".pi/agent")) + agent_configuration_environment("PI_CODING_AGENT_DIR").unwrap_or_else(|| home.join(".pi/agent")) } pub(crate) fn pi_provider_config_path(home: &Path) -> PathBuf { @@ -429,7 +425,12 @@ pub(crate) fn inspect_pi_provider_status( config_valid, configured, configuration_synchronized: configured, - connection_state: agent_connection_state(configured, config_valid, Ok(config_path.is_file() || default_provider_matches)).into(), + connection_state: agent_connection_state( + configured, + config_valid, + Ok(config_path.is_file() || default_provider_matches), + ) + .into(), current_model: current_model.clone(), oauth_configuration: false, codex_native_oauth: false, @@ -461,7 +462,9 @@ pub(crate) fn install_pi_provider_inner( let settings_path = pi_provider_settings_path(home); let mut changed_files = Vec::new(); if !pi_provider_package_installed(home)? { - config_package_operation(home, "plugin-install", || install_pi_package(executable, home, proxy_url))?; + config_package_operation(home, "plugin-install", || { + install_pi_package(executable, home, proxy_url) + })?; changed_files.push(path_to_string(&settings_path)); } @@ -485,7 +488,9 @@ pub(crate) fn repair_pi_provider_inner( } let config_path = pi_provider_config_path(home); let settings_path = pi_provider_settings_path(home); - let _guard = AGENT_CONFIG_FILE_LOCK.lock().map_err(|_| "配置文件锁已损坏")?; + let _guard = AGENT_CONFIG_FILE_LOCK + .lock() + .map_err(|_| "配置文件锁已损坏")?; let before = config_images(&config_paths(PI_AGENT_ID, home)?)?; validate_config_images(&before)?; if !pi_provider_package_installed(home)? { @@ -510,10 +515,24 @@ pub(crate) fn repair_pi_provider_inner( ) })?; let rendered_settings = build_pi_provider_settings(&settings, default_model)?; - config_updates(PI_AGENT_ID, home, &before, &[ - AgentFileUpdate { path: config_path, after: rendered }, - AgentFileUpdate { path: settings_path, after: rendered_settings }, - ], "update", Some(default_model.to_string()), None) + config_updates( + PI_AGENT_ID, + home, + &before, + &[ + AgentFileUpdate { + path: config_path, + after: rendered, + }, + AgentFileUpdate { + path: settings_path, + after: rendered_settings, + }, + ], + "update", + Some(default_model.to_string()), + None, + ) } pub(crate) fn update_pi_provider_inner( @@ -527,7 +546,9 @@ pub(crate) fn update_pi_provider_inner( if !pi_provider_package_installed(home)? { return Err("Pi CLIProxyAPI provider 插件尚未安装".to_string()); } - config_package_operation(home, "plugin-update", || update_pi_package(executable, home, proxy_url))?; + config_package_operation(home, "plugin-update", || { + update_pi_package(executable, home, proxy_url) + })?; let mut result = repair_pi_provider_inner(home, port, api_key, default_model)?; result.outcome = "updated".to_string(); Ok(result) @@ -546,7 +567,9 @@ pub(crate) fn uninstall_pi_provider_inner( Vec::new(), )); } - config_package_operation(home, "plugin-remove", || remove_pi_package(executable, home))?; + config_package_operation(home, "plugin-remove", || { + remove_pi_package(executable, home) + })?; Ok(action_result( "removed", false, @@ -689,8 +712,7 @@ pub(crate) fn remove_pi_package(executable: &Path, home: &Path) -> Result<(), St } pub(crate) fn codex_configuration_directory(home: &Path) -> PathBuf { - agent_configuration_environment("CODEX_HOME") - .unwrap_or_else(|| home.join(".codex")) + agent_configuration_environment("CODEX_HOME").unwrap_or_else(|| home.join(".codex")) } pub(crate) fn current_codex_oauth_configuration(home: &Path) -> Result { @@ -746,7 +768,8 @@ pub(crate) fn codex_auth_file_has_api_key(path: &Path, api_key: &str) -> bool { } pub(crate) fn validate_codex_oauth_login(home: &Path) -> Result<(), String> { - if validate_codex_oauth_login_at(&codex_configuration_directory(home).join("auth.json")).is_ok() { + if validate_codex_oauth_login_at(&codex_configuration_directory(home).join("auth.json")).is_ok() + { return Ok(()); } available_codex_oauth_auth(home).map(|_| ()) @@ -761,12 +784,16 @@ pub(crate) fn validate_codex_oauth_login_at(auth_path: &Path) -> Result<(), Stri } pub(crate) fn clear_codex_config_files(home: &Path) -> Result, String> { - let mut paths = config_paths("codex",home)?; + let mut paths = config_paths("codex", home)?; paths.push(codex_configuration_directory(home).join(CODEX_NATIVE_OAUTH_STATE_FILE)); let before = config_images(&paths)?; let mut after = before.clone(); for (path, bytes) in &mut after { - if path.file_name().and_then(|v| v.to_str()).is_some_and(|v| v == "auth.json" || v == "config.toml" || v == CODEX_NATIVE_OAUTH_STATE_FILE) { *bytes = None; } + if path.file_name().and_then(|v| v.to_str()).is_some_and(|v| { + v == "auth.json" || v == "config.toml" || v == CODEX_NATIVE_OAUTH_STATE_FILE + }) { + *bytes = None; + } } Ok(commit_config("codex", &paths, &before, &after, "clear", None)?.changed_files) } @@ -891,13 +918,16 @@ pub(crate) fn inspect_agent_config( let result = config_paths(client.id(), home) .and_then(|mut paths| { if client == AgentClient::Codex && codex_native_oauth_enabled(home)? { - paths.retain(|path| path.file_name().and_then(|name| name.to_str()) != Some(CODEX_MODEL_CATALOG_FILE)); + paths.retain(|path| { + path.file_name().and_then(|name| name.to_str()) + != Some(CODEX_MODEL_CATALOG_FILE) + }); } config_images(&paths) }) .and_then(|images| validate_config_images(&images)) - .and_then(|_| inspect_agent_managed_config(client, &paths, port, api_key)).and_then( - |(configured, model, oauth_configuration)| { + .and_then(|_| inspect_agent_managed_config(client, &paths, port, api_key)) + .and_then(|(configured, model, oauth_configuration)| { if client == AgentClient::Codex && configured { let model = model .as_deref() @@ -905,13 +935,21 @@ pub(crate) fn inspect_agent_config( validate_codex_catalog_file(&paths[0], model)?; } Ok((configured, model, oauth_configuration)) - }, - ); + }); let (configured, current_model, oauth_configuration, config_valid, error) = match result { Ok((configured, model, oauth_configuration)) => { (configured, model, oauth_configuration, true, None) } - Err(_) => (false, None, false, false, Some("配置读取或解析失败,请检查文件权限,或使用手动备份恢复、基础配置模板修复".to_string())), + Err(_) => ( + false, + None, + false, + false, + Some( + "配置读取或解析失败,请检查文件权限,或使用手动备份恢复、基础配置模板修复" + .to_string(), + ), + ), }; let executable = find_agent_executable(client, home); let cli_version = if client == AgentClient::ZCode { @@ -986,18 +1024,33 @@ pub(crate) fn inspect_agent_config( config_valid, configured, configuration_synchronized, - connection_state: agent_connection_state(configured, config_valid, agent_has_connection_evidence(client, &paths)).into(), + connection_state: agent_connection_state( + configured, + config_valid, + agent_has_connection_evidence(client, &paths), + ) + .into(), current_model: current_model.clone(), oauth_configuration, - codex_native_oauth: client == AgentClient::Codex && codex_native_oauth_enabled(home).unwrap_or(false), + codex_native_oauth: client == AgentClient::Codex + && codex_native_oauth_enabled(home).unwrap_or(false), modification_enabled: configured, - modification_state: if !config_valid { "invalid" } else if configured { "applied" } else { "unconfigured" }.into(), + modification_state: if !config_valid { + "invalid" + } else if configured { + "applied" + } else { + "unconfigured" + } + .into(), backup_available: false, applied_model: current_model, claude_code_model_mappings: (client == AgentClient::ClaudeCode) .then(|| inspect_claude_code_model_mappings(&paths[0]).ok().flatten()) .flatten(), - claude_desktop_model_mappings: (client == AgentClient::ClaudeDesktop).then(|| current_desktop_mappings(home)).flatten(), + claude_desktop_model_mappings: (client == AgentClient::ClaudeDesktop) + .then(|| current_desktop_mappings(home)) + .flatten(), warnings, error, } @@ -1216,33 +1269,84 @@ pub(crate) fn inspect_agent_managed_config( } } -pub(crate) fn agent_connection_state(configured: bool, valid: bool, managed: Result) -> &'static str { - if !valid || managed.is_err() { "invalid" } - else if configured { "configured" } - else if managed == Ok(true) { "needs-update" } - else { "not-configured" } +pub(crate) fn agent_connection_state( + configured: bool, + valid: bool, + managed: Result, +) -> &'static str { + if !valid || managed.is_err() { + "invalid" + } else if configured { + "configured" + } else if managed == Ok(true) { + "needs-update" + } else { + "not-configured" + } } -pub(crate) fn agent_has_connection_evidence(client: AgentClient, paths: &[PathBuf]) -> Result { +pub(crate) fn agent_has_connection_evidence( + client: AgentClient, + paths: &[PathBuf], +) -> Result { let active_marker = agent_has_managed_marker(client, paths)?; - if active_marker { return Ok(true); } + if active_marker { + return Ok(true); + } for path in paths { - let Some(bytes) = read_agent_bytes(path)? else { continue; }; + let Some(bytes) = read_agent_bytes(path)? else { + continue; + }; let content = std::str::from_utf8(&bytes).map_err(|e| e.to_string())?; let value: serde_json::Value = match path.extension().and_then(|v| v.to_str()) { - Some("toml") => serde_json::to_value(toml::from_str::(content).map_err(|e| e.to_string())?).map_err(|e| e.to_string())?, + Some("toml") => serde_json::to_value( + toml::from_str::(content).map_err(|e| e.to_string())?, + ) + .map_err(|e| e.to_string())?, Some("yaml" | "yml") => serde_yaml::from_str(content).map_err(|e| e.to_string())?, _ => json5::from_str(content).map_err(|e| e.to_string())?, }; - let provider_present = ["/provider/cpa-gui", "/model_providers/cpa-gui", "/providers/cpa-gui", "/models/providers/cpa-gui"] - .iter().any(|pointer| value.pointer(pointer).is_some()); - let selected = ["/model_provider", "/model", "/model/main", "/model/provider", "/default_model", "/models/default", "/agents/defaults/model/primary"] - .iter().filter_map(|pointer| value.pointer(pointer).and_then(serde_json::Value::as_str)) - .any(|model| model == MANAGED_AGENT_PROVIDER_ID || model.starts_with(&format!("{MANAGED_AGENT_PROVIDER_ID}/"))); - let catalog = client == AgentClient::Codex && value.get("model_catalog_json").and_then(serde_json::Value::as_str) == Some(CODEX_MODEL_CATALOG_FILE); - let hermes_provider = client == AgentClient::Hermes && value.get("custom_providers").and_then(serde_json::Value::as_array) - .is_some_and(|items| items.iter().any(|item| item.get("name").and_then(serde_json::Value::as_str) == Some(MANAGED_AGENT_PROVIDER_ID))); - if provider_present || selected || catalog || hermes_provider { return Ok(true); } + let provider_present = [ + "/provider/cpa-gui", + "/model_providers/cpa-gui", + "/providers/cpa-gui", + "/models/providers/cpa-gui", + ] + .iter() + .any(|pointer| value.pointer(pointer).is_some()); + let selected = [ + "/model_provider", + "/model", + "/model/main", + "/model/provider", + "/default_model", + "/models/default", + "/agents/defaults/model/primary", + ] + .iter() + .filter_map(|pointer| value.pointer(pointer).and_then(serde_json::Value::as_str)) + .any(|model| { + model == MANAGED_AGENT_PROVIDER_ID + || model.starts_with(&format!("{MANAGED_AGENT_PROVIDER_ID}/")) + }); + let catalog = client == AgentClient::Codex + && value + .get("model_catalog_json") + .and_then(serde_json::Value::as_str) + == Some(CODEX_MODEL_CATALOG_FILE); + let hermes_provider = client == AgentClient::Hermes + && value + .get("custom_providers") + .and_then(serde_json::Value::as_array) + .is_some_and(|items| { + items.iter().any(|item| { + item.get("name").and_then(serde_json::Value::as_str) + == Some(MANAGED_AGENT_PROVIDER_ID) + }) + }); + if provider_present || selected || catalog || hermes_provider { + return Ok(true); + } } Ok(false) } @@ -2024,9 +2128,9 @@ pub(crate) fn read_zcode_app_version(executable: &Path) -> Option { } #[cfg(target_os = "macos")] { - let application = executable.ancestors().find(|path| { - path.extension().and_then(|value| value.to_str()) == Some("app") - })?; + 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")))] @@ -2090,121 +2194,6 @@ pub(crate) fn find_zcode_desktop_executable(home: &Path) -> Option { } } -#[cfg(target_os = "windows")] -pub(crate) fn find_windows_registered_zcode_executable() -> Option { - 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" { - 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"); @@ -2255,16 +2244,6 @@ pub(crate) fn windows_explorer_executable() -> PathBuf { } } -#[cfg(target_os = "windows")] -pub(crate) fn windows_registry_executable() -> PathBuf { - let executable = windows_system_root().join("System32/reg.exe"); - if executable.is_file() { - executable - } else { - PathBuf::from("reg.exe") - } -} - #[cfg(target_os = "windows")] pub(crate) fn windows_command_processor() -> PathBuf { env::var_os("ComSpec") @@ -2275,96 +2254,16 @@ pub(crate) fn windows_command_processor() -> PathBuf { #[cfg(target_os = "windows")] pub(crate) fn find_windows_codex_app_installation(home: &Path) -> Option { - find_windows_registered_codex_app_installation() + find_windows_codex_app_executable(home) + .map(DesktopAppTarget::Application) + .or_else(find_windows_registered_codex_app_installation) .or_else(|| find_windows_codex_app_id_via_registry().map(DesktopAppTarget::WindowsAppId)) - .or_else(|| find_windows_codex_app_executable(home).map(DesktopAppTarget::Application)) -} - -#[cfg(target_os = "windows")] -pub(crate) fn read_windows_claude_desktop_store_version() -> Option { - const VERSION_SCRIPT: &str = r#" -$ErrorActionPreference = 'SilentlyContinue' -$package = @(Get-AppxPackage) | - Where-Object { - $_.Name -eq 'Claude' -or - $_.Name -like 'Anthropic.Claude*' -or - $_.PackageFamilyName -match '^(Claude|Anthropic\.Claude)_' - } | - Select-Object -First 1 -if ($package -and $package.Version) { - Write-Output "VERSION:$($package.Version)" -} -"#; - - let encoded_command = windows_powershell_encoded_command(VERSION_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_claude_desktop_version_output(&String::from_utf8_lossy(&output.stdout)) } #[cfg(target_os = "windows")] pub(crate) fn read_windows_codex_store_version(app_id: &str) -> Option { - let script = windows_codex_store_executable_script(app_id)?; - let encoded_command = windows_powershell_encoded_command(&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; - } - match parse_windows_codex_app_discovery_output(&String::from_utf8_lossy(&output.stdout))? { - DesktopAppTarget::Application(path) => read_windows_codex_desktop_version(&path), - DesktopAppTarget::WindowsAppId(_) => None, - } -} - -#[cfg(target_os = "windows")] -pub(crate) fn windows_codex_store_executable_script(app_id: &str) -> Option { - let (family, application) = app_id.split_once('!')?; - if family.is_empty() || application.is_empty() { - return None; - } - let family = windows_powershell_single_quoted_literal(family); - let application = windows_powershell_single_quoted_literal(application); - Some(format!( - r#" -$ErrorActionPreference = 'SilentlyContinue' -$ProgressPreference = 'SilentlyContinue' -$package = Get-AppxPackage | Where-Object {{ $_.PackageFamilyName -eq {family} }} | - Sort-Object {{ [version]$_.Version }} -Descending | Select-Object -First 1 -if ($package) {{ - $manifest = Get-AppxPackageManifest -Package $package.PackageFullName - $application = @($manifest.Package.Applications.Application) | - Where-Object {{ $_.Id -eq {application} }} | Select-Object -First 1 - if ($application.Executable -and $package.InstallLocation) {{ - $path = Join-Path $package.InstallLocation $application.Executable - if (Test-Path -LiteralPath $path -PathType Leaf) {{ Write-Output "EXE:$path" }} - }} -}} -"# - )) + find_windows_codex_store_executable(app_id) + .and_then(|path| read_windows_codex_desktop_version(&path)) } #[cfg(target_os = "windows")] @@ -2447,233 +2346,11 @@ pub(crate) fn read_codex_asar_version(path: &Path) -> Option { normalize_detected_agent_version(package.get("version")?.as_str()?) } -#[cfg(target_os = "windows")] -pub(crate) fn find_windows_registered_codex_app_installation() -> Option { - const DISCOVERY_SCRIPT: &str = r#" -$ErrorActionPreference = 'SilentlyContinue' -$ProgressPreference = 'SilentlyContinue' -$startApps = @(Get-StartApps) -$appId = $startApps | - Where-Object { - $_.AppID -like 'OpenAI.Codex_*!*' -or - $_.AppID -like 'OpenAI.CodexBeta_*!*' -or - $_.AppID -like 'OpenAI.ChatGPT_*!*' - } | - Select-Object -First 1 -ExpandProperty AppID -if ($appId) { - Write-Output "APPID:$appId" - exit 0 -} - -$packages = @(Get-AppxPackage) | - Where-Object { - $_.Name -in @('OpenAI.Codex', 'OpenAI.CodexBeta', 'OpenAI.ChatGPT') -or - $_.PackageFamilyName -match '^OpenAI\.(Codex|CodexBeta|ChatGPT)_' - } -foreach ($package in $packages) { - $manifest = Get-AppxPackageManifest -Package $package.PackageFullName - $application = @($manifest.Package.Applications.Application) | - Where-Object { $_.Id } | - Select-Object -First 1 - if ($application -and $package.PackageFamilyName) { - Write-Output "APPID:$($package.PackageFamilyName)!$($application.Id)" - exit 0 - } -} - -$appPathKeys = @( - 'HKCU:\Software\Microsoft\Windows\CurrentVersion\App Paths\ChatGPT.exe', - 'HKLM:\Software\Microsoft\Windows\CurrentVersion\App Paths\ChatGPT.exe', - 'HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\App Paths\ChatGPT.exe' -) -foreach ($key in $appPathKeys) { - if (-not (Test-Path -LiteralPath $key)) { continue } - $candidate = (Get-Item -LiteralPath $key).GetValue('') - if ($candidate -and (Test-Path -LiteralPath $candidate -PathType Leaf)) { - Write-Output "EXE:$candidate" - exit 0 - } -} - -$shortcutRoots = @( - "$env:APPDATA\Microsoft\Windows\Start Menu\Programs", - "$env:ProgramData\Microsoft\Windows\Start Menu\Programs" -) -$shell = New-Object -ComObject WScript.Shell -foreach ($shortcutFile in (Get-ChildItem -LiteralPath $shortcutRoots -Filter '*.lnk' -Recurse)) { - $shortcut = $shell.CreateShortcut($shortcutFile.FullName) - $target = $shortcut.TargetPath - if (-not $target -or -not (Test-Path -LiteralPath $target -PathType Leaf)) { continue } - $fileName = [System.IO.Path]::GetFileName($target) - if ($fileName -ieq 'Codex.exe' -and $target -match '(?i)\\(bin|node_modules|\.vscode\\extensions)\\') { - continue - } - if ($fileName -ieq 'ChatGPT.exe' -or $fileName -ieq 'Codex.exe') { - Write-Output "EXE:$target" - exit 0 - } -} -"#; - - 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_codex_app_discovery_output(&String::from_utf8_lossy(&output.stdout)).and_then( - |target| match &target { - DesktopAppTarget::Application(path) if !path.is_file() => None, - _ => Some(target), - }, - ) -} - -#[cfg(target_os = "windows")] -pub(crate) fn windows_powershell_encoded_command(script: &str) -> String { - use base64::{engine::general_purpose::STANDARD, Engine as _}; - - let bytes = script - .encode_utf16() - .flat_map(|unit| unit.to_le_bytes()) - .collect::>(); - STANDARD.encode(bytes) -} - #[cfg(target_os = "windows")] pub(crate) fn windows_powershell_single_quoted_literal(value: &str) -> String { format!("'{}'", value.replace('\'', "''")) } -#[cfg(target_os = "windows")] -pub(crate) fn read_windows_executable_version(path: &Path) -> Option { - let path = windows_powershell_single_quoted_literal(&path_to_string(path)); - let script = format!( - r#" -$ErrorActionPreference = 'SilentlyContinue' -$item = Get-Item -LiteralPath {path} -$version = $item.VersionInfo.ProductVersion -if ($version) {{ - Write-Output "VERSION:$version" -}} -"# - ); - let encoded_command = windows_powershell_encoded_command(&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_codex_version_output(&String::from_utf8_lossy(&output.stdout)) -} - -#[cfg(target_os = "windows")] -pub(crate) fn parse_windows_codex_app_discovery_output(output: &str) -> Option { - output.lines().find_map(|line| { - let line = line.trim(); - if let Some(app_id) = line.strip_prefix("APPID:").map(str::trim) { - return (!app_id.is_empty()).then(|| DesktopAppTarget::WindowsAppId(app_id.to_string())); - } - line.strip_prefix("EXE:") - .map(str::trim) - .filter(|path| !path.is_empty()) - .map(|path| DesktopAppTarget::Application(PathBuf::from(path))) - }) -} - -#[cfg(target_os = "windows")] -pub(crate) fn parse_windows_claude_desktop_version_output(output: &str) -> Option { - output.lines().find_map(|line| { - line.trim() - .strip_prefix("VERSION:") - .and_then(normalize_detected_agent_version) - }) -} - -#[cfg(target_os = "windows")] -pub(crate) fn parse_windows_codex_version_output(output: &str) -> Option { - output.lines().find_map(|line| { - line.trim() - .strip_prefix("VERSION:") - .and_then(normalize_detected_agent_version) - }) -} - -#[cfg(target_os = "windows")] -pub(crate) fn find_windows_codex_app_id_via_registry() -> Option { - const PACKAGES_KEY: &str = r"HKCU\Software\Classes\Local Settings\Software\Microsoft\Windows\CurrentVersion\AppModel\Repository\Packages"; - for package_name in ["OpenAI.Codex_", "OpenAI.CodexBeta_", "OpenAI.ChatGPT_"] { - let mut command = Command::new(windows_registry_executable()); - command.args(["query", PACKAGES_KEY, "/f", package_name, "/k", "/s"]); - configure_background_command(&mut command); - let Ok(Some(output)) = - command_output_with_timeout(&mut command, AGENT_VERSION_PROBE_TIMEOUT) - else { - continue; - }; - if !output.status.success() { - continue; - } - if let Some(app_id) = - parse_windows_codex_app_id_from_registry(&String::from_utf8_lossy(&output.stdout)) - { - return Some(app_id); - } - } - None -} - -#[cfg(target_os = "windows")] -pub(crate) fn parse_windows_codex_app_id_from_registry(output: &str) -> Option { - const PACKAGE_MARKER: &str = "\\AppModel\\Repository\\Packages\\"; - output.lines().find_map(|line| { - let line = line.trim(); - let (_, package_full_name) = line.split_once(PACKAGE_MARKER)?; - if package_full_name.contains('\\') { - return None; - } - windows_codex_app_id_from_package_full_name(package_full_name) - }) -} - -#[cfg(target_os = "windows")] -pub(crate) fn windows_codex_app_id_from_package_full_name( - package_full_name: &str, -) -> Option { - let package_name = package_full_name.split('_').next()?.trim(); - if !matches!( - package_name, - "OpenAI.Codex" | "OpenAI.CodexBeta" | "OpenAI.ChatGPT" - ) { - return None; - } - let publisher_id = package_full_name.rsplit('_').next()?.trim(); - if publisher_id.is_empty() || publisher_id == package_name { - return None; - } - Some(format!("{package_name}_{publisher_id}!App")) -} - #[cfg(target_os = "windows")] pub(crate) fn find_windows_codex_app_executable(home: &Path) -> Option { let local = env::var_os("LOCALAPPDATA") @@ -3835,7 +3512,11 @@ pub(crate) fn inspect_deepseek_harness_config( .map(str::to_string) }) .flatten(); - let expected_base = if provider.and_then(|p| yaml_mapping_value(p, "api")).and_then(serde_norway::Value::as_str) == Some("anthropic-messages") { + let expected_base = if provider + .and_then(|p| yaml_mapping_value(p, "api")) + .and_then(serde_norway::Value::as_str) + == Some("anthropic-messages") + { managed_core_loopback_origin(port) } else { format!("{}/v1", managed_core_loopback_origin(port)) @@ -3866,7 +3547,12 @@ pub(crate) fn inspect_deepseek_harness_config( && provider .and_then(|provider| yaml_mapping_value(provider, "api")) .and_then(serde_norway::Value::as_str) - .is_some_and(|api| matches!(api, "openai-completions" | "openai-responses" | "anthropic-messages")) + .is_some_and(|api| { + matches!( + api, + "openai-completions" | "openai-responses" | "anthropic-messages" + ) + }) && provider .and_then(|provider| yaml_mapping_value(provider, "baseURL")) .and_then(serde_norway::Value::as_str) diff --git a/src-tauri/src/agents/launch.rs b/src-tauri/src/agents/launch.rs index 1cbf1a9..f01511f 100644 --- a/src-tauri/src/agents/launch.rs +++ b/src-tauri/src/agents/launch.rs @@ -745,58 +745,10 @@ fn find_desktop_restart_target( target.ok_or_else(|| format!("未检测到 {} 桌面应用,请重新检测", client.name())) } -#[cfg(target_os = "windows")] -fn find_windows_claude_app_id() -> Option { - let script = "@(Get-StartApps) | Where-Object { $_.AppID -like 'Claude_*!*' -or $_.AppID -like 'Anthropic.Claude_*!*' } | Select-Object -First 1 -ExpandProperty AppID"; - let mut command = Command::new(windows_powershell_executable()); - command.args([ - "-NoLogo", - "-NoProfile", - "-NonInteractive", - "-EncodedCommand", - &windows_powershell_encoded_command(script), - ]); - configure_background_command(&mut command); - let output = command_output_with_timeout(&mut command, Duration::from_secs(5)).ok()??; - let id = String::from_utf8_lossy(&output.stdout).trim().to_string(); - (output.status.success() && !id.is_empty()).then_some(id) -} - #[cfg(target_os = "windows")] fn stop_other_desktop(target: &DesktopAppTarget, label: &str) -> Result<(), String> { - run_windows_desktop_stop_script(&windows_desktop_stop_script(target), label) -} - -#[cfg(target_os = "windows")] -fn windows_desktop_stop_script(target: &DesktopAppTarget) -> String { - let selector = match target { - DesktopAppTarget::Application(path) => format!("$targetExecutable = {}\n$targetRoot = $null", windows_powershell_single_quoted_literal(&path_to_string(path))), - DesktopAppTarget::WindowsAppId(app_id) => format!( - "$targetExecutable = $null\n$packageFamily = {}\n$package = @(Get-AppxPackage | Where-Object {{ $_.PackageFamilyName -eq $packageFamily }}) | Select-Object -First 1\n$targetRoot = if ($package) {{ $package.InstallLocation }} else {{ $null }}\nif (-not $targetRoot) {{ throw 'Application package directory was not found' }}", - windows_powershell_single_quoted_literal(app_id.split('!').next().unwrap_or(app_id))), - }; - format!( - r#"$ErrorActionPreference = 'Stop' -{selector} -function Get-TargetProcesses {{ - @(Get-CimInstance Win32_Process | Where-Object {{ - $_.ExecutablePath -and - (($targetExecutable -and [string]::Equals($_.ExecutablePath, $targetExecutable, [System.StringComparison]::OrdinalIgnoreCase)) -or - ($targetRoot -and $_.ExecutablePath.StartsWith(($targetRoot.TrimEnd('\') + '\'), [System.StringComparison]::OrdinalIgnoreCase))) - }}) -}} -foreach ($process in @(Get-TargetProcesses)) {{ - Stop-Process -Id $process.ProcessId -Force -ErrorAction SilentlyContinue -}} -$deadline = [DateTime]::UtcNow.AddSeconds(5) -do {{ - $remaining = @(Get-TargetProcesses) - if ($remaining.Count -eq 0) {{ exit 0 }} - Start-Sleep -Milliseconds 100 -}} while ([DateTime]::UtcNow -lt $deadline) -throw "Application did not exit; remaining process IDs: $($remaining.ProcessId -join ', ')" -"# - ) + let (executable, install_root) = windows_desktop_stop_target(target)?; + stop_windows_matching_processes(executable.as_deref(), install_root.as_deref(), &[], label) } #[cfg(target_os = "macos")] @@ -849,122 +801,22 @@ fn launch_codex_target(target: &DesktopAppTarget) -> Result<(), String> { #[cfg(target_os = "windows")] fn stop_codex_desktop(target: &DesktopAppTarget) -> Result<(), String> { - let script = windows_codex_stop_script(target); - run_windows_desktop_stop_script(&script, "Codex App") -} - -#[cfg(target_os = "windows")] -fn stop_opencode_desktop(application: &Path) -> Result<(), String> { - let script = windows_opencode_stop_script(application); - run_windows_desktop_stop_script(&script, "OpenCode Desktop") -} - -#[cfg(target_os = "windows")] -fn run_windows_desktop_stop_script(script: &str, label: &str) -> Result<(), String> { - let encoded = windows_powershell_encoded_command(script); - let mut command = Command::new(windows_powershell_executable()); - command.args([ - "-NoLogo", - "-NoProfile", - "-NonInteractive", - "-ExecutionPolicy", - "Bypass", - "-EncodedCommand", - &encoded, - ]); - configure_background_command(&mut command); - let output = command - .output() - .map_err(|error| format!("关闭 {label} 失败: {error}"))?; - if output.status.success() { - return Ok(()); - } - let detail = String::from_utf8_lossy(if output.stderr.is_empty() { - &output.stdout - } else { - &output.stderr - }) - .trim() - .to_string(); - Err(if detail.is_empty() { - format!("{label} 未能完全关闭") - } else { - format!("关闭 {label} 失败: {detail}") - }) -} - -#[cfg(target_os = "windows")] -fn windows_codex_stop_script(target: &DesktopAppTarget) -> String { - let selector = match target { - DesktopAppTarget::Application(path) => format!( - "$targetExecutable = {}\n$targetRoot = $null", - windows_powershell_single_quoted_literal(&path_to_string(path)) - ), - DesktopAppTarget::WindowsAppId(app_id) => { - let package_family = app_id.split('!').next().unwrap_or(app_id); - format!( - concat!( - "$targetExecutable = $null\n", - "$packageFamily = {}\n", - "$package = @(Get-AppxPackage | Where-Object {{ $_.PackageFamilyName -eq $packageFamily }}) | Select-Object -First 1\n", - "$targetRoot = if ($package) {{ $package.InstallLocation }} else {{ $null }}\n", - "if (-not $targetRoot) {{ throw 'Codex App package directory was not found' }}" - ), - windows_powershell_single_quoted_literal(package_family) - ) - } - }; - format!( - r#"$ErrorActionPreference = 'Stop' -{selector} -function Get-CodexAppProcesses {{ - @(Get-CimInstance Win32_Process | Where-Object {{ - $_.Name -in @('ChatGPT.exe', 'Codex.exe') -and - $_.ExecutablePath -and - (($targetExecutable -and [string]::Equals($_.ExecutablePath, $targetExecutable, [System.StringComparison]::OrdinalIgnoreCase)) -or - ($targetRoot -and $_.ExecutablePath.StartsWith(($targetRoot.TrimEnd('\') + '\'), [System.StringComparison]::OrdinalIgnoreCase))) - }}) -}} -$processes = @(Get-CodexAppProcesses) -foreach ($process in $processes) {{ - Stop-Process -Id $process.ProcessId -Force -ErrorAction SilentlyContinue -}} -$deadline = [DateTime]::UtcNow.AddSeconds(5) -do {{ - $remaining = @(Get-CodexAppProcesses) - if ($remaining.Count -eq 0) {{ exit 0 }} - Start-Sleep -Milliseconds 100 -}} while ([DateTime]::UtcNow -lt $deadline) -throw "Codex App did not exit; remaining process IDs: $($remaining.ProcessId -join ', ')" -"# + let (executable, install_root) = windows_desktop_stop_target(target)?; + stop_windows_matching_processes( + executable.as_deref(), + install_root.as_deref(), + &["ChatGPT.exe", "Codex.exe"], + "Codex App", ) } #[cfg(target_os = "windows")] -fn windows_opencode_stop_script(application: &Path) -> String { - let target_executable = windows_powershell_single_quoted_literal(&path_to_string(application)); - format!( - r#"$ErrorActionPreference = 'Stop' -$targetExecutable = {target_executable} -function Get-OpenCodeDesktopProcesses {{ - @(Get-CimInstance Win32_Process | Where-Object {{ - $_.Name -eq 'OpenCode.exe' -and - $_.ExecutablePath -and - [string]::Equals($_.ExecutablePath, $targetExecutable, [System.StringComparison]::OrdinalIgnoreCase) - }}) -}} -$processes = @(Get-OpenCodeDesktopProcesses) -foreach ($process in $processes) {{ - Stop-Process -Id $process.ProcessId -Force -ErrorAction SilentlyContinue -}} -$deadline = [DateTime]::UtcNow.AddSeconds(5) -do {{ - $remaining = @(Get-OpenCodeDesktopProcesses) - if ($remaining.Count -eq 0) {{ exit 0 }} - Start-Sleep -Milliseconds 100 -}} while ([DateTime]::UtcNow -lt $deadline) -throw "OpenCode Desktop did not exit; remaining process IDs: $($remaining.ProcessId -join ', ')" -"# +fn stop_opencode_desktop(application: &Path) -> Result<(), String> { + stop_windows_matching_processes( + Some(application), + None, + &["OpenCode.exe"], + "OpenCode Desktop", ) } @@ -1283,42 +1135,9 @@ fn launch_windows_store_app(app_id: &str, label: &str) -> Result<(), String> { #[cfg(target_os = "windows")] fn launch_windows_claude_store_app() -> Result<(), String> { - const SCRIPT: &str = r#" -$ErrorActionPreference = 'Stop' -$appId = @(Get-StartApps) | - Where-Object { - $_.AppID -like 'Claude_*!*' -or - $_.AppID -like 'Anthropic.Claude_*!*' - } | - Select-Object -First 1 -ExpandProperty AppID -if (-not $appId) { throw 'Claude Desktop app entry was not found' } -Start-Process "shell:AppsFolder\$appId" -"#; - let encoded = windows_powershell_encoded_command(SCRIPT); - let mut command = Command::new(windows_powershell_executable()); - command.args([ - "-NoLogo", - "-NoProfile", - "-NonInteractive", - "-ExecutionPolicy", - "Bypass", - "-EncodedCommand", - &encoded, - ]); - configure_background_command(&mut command); - let output = command - .output() - .map_err(|error| format!("启动 Claude Desktop 失败: {error}"))?; - if output.status.success() { - Ok(()) - } else { - let detail = String::from_utf8_lossy(&output.stderr).trim().to_string(); - Err(if detail.is_empty() { - "未找到可启动的 Claude Desktop 应用".to_string() - } else { - format!("启动 Claude Desktop 失败: {detail}") - }) - } + let app_id = find_windows_claude_app_id() + .ok_or_else(|| "未找到可启动的 Claude Desktop 应用".to_string())?; + launch_windows_store_app(&app_id, "Claude Desktop") } #[cfg(target_os = "macos")] @@ -1569,25 +1388,40 @@ mod tests { #[cfg(windows)] #[test] - fn desktop_restart_script_handles_paths_and_store_packages_without_name_wide_kills() { - for path in [ - r"C:\Apps\Claude's Desktop\Claude.exe", - r"C:\Apps\ZCode\ZCode.exe", - ] { - let script = - windows_desktop_stop_script(&DesktopAppTarget::Application(PathBuf::from(path))); - assert!(script.contains(&windows_powershell_single_quoted_literal(path))); - assert!(script.contains("[string]::Equals($_.ExecutablePath, $targetExecutable")); - assert!(script.contains("$remaining.Count -eq 0")); - assert!(!script.contains("taskkill")); - assert!(!script.contains("Stop-Process -Name")); - } - let script = windows_desktop_stop_script(&DesktopAppTarget::WindowsAppId( - "Anthropic.Claude_family!App".to_string(), + fn desktop_restart_matching_is_scoped_to_the_exact_installation() { + let claude = PathBuf::from(r"C:\Apps\Claude's Desktop\Claude.exe"); + assert!(windows_process_matches_stop_target( + &claude, + Some(&claude), + None, + &[], + )); + assert!(!windows_process_matches_stop_target( + Path::new(r"C:\Apps\Claude's Desktop.old\Claude.exe"), + Some(&claude), + None, + &[], + )); + let zcode = PathBuf::from(r"C:\Apps\ZCode\ZCode.exe"); + assert!(windows_process_matches_stop_target( + &zcode, + Some(&zcode), + None, + &[] + )); + let store_root = PathBuf::from(r"C:\Program Files\WindowsApps\Anthropic.Claude_family"); + assert!(windows_process_matches_stop_target( + &store_root.join("Claude.exe"), + None, + Some(&store_root), + &[], + )); + assert!(!windows_process_matches_stop_target( + Path::new(r"C:\Program Files\WindowsApps\Other\Claude.exe"), + None, + Some(&store_root), + &[], )); - assert!(script.contains("$packageFamily = 'Anthropic.Claude_family'")); - assert!(script.contains("$_.PackageFamilyName -eq $packageFamily")); - assert!(script.contains("$targetRoot.TrimEnd('\\') + '\\'")); } #[cfg(windows)] @@ -2063,29 +1897,52 @@ mod tests { #[cfg(target_os = "windows")] #[test] - fn codex_restart_script_is_scoped_to_the_detected_installation() { - let executable = DesktopAppTarget::Application(PathBuf::from(r"C:\Apps\Codex\Codex.exe")); - let executable_script = windows_codex_stop_script(&executable); - assert!(executable_script.contains(r"C:\Apps\Codex\Codex.exe")); - assert!(executable_script.contains("Get-CimInstance Win32_Process")); - assert!(!executable_script.contains("taskkill")); - - let store = DesktopAppTarget::WindowsAppId("OpenAI.Codex_123!App".to_string()); - let store_script = windows_codex_stop_script(&store); - assert!(store_script.contains("OpenAI.Codex_123")); - assert!(store_script.contains("PackageFamilyName")); + fn codex_restart_matching_is_scoped_to_the_detected_installation() { + let executable = PathBuf::from(r"C:\Apps\Codex\Codex.exe"); + assert!(windows_process_matches_stop_target( + &executable, + Some(&executable), + None, + &["ChatGPT.exe", "Codex.exe"], + )); + assert!(!windows_process_matches_stop_target( + Path::new(r"C:\Apps\Codex\helper.exe"), + Some(&executable), + None, + &["ChatGPT.exe", "Codex.exe"], + )); + let store_root = PathBuf::from(r"C:\Program Files\WindowsApps\OpenAI.Codex_123"); + assert!(windows_process_matches_stop_target( + &store_root.join("ChatGPT.exe"), + None, + Some(&store_root), + &["ChatGPT.exe", "Codex.exe"], + )); + assert!(!windows_process_matches_stop_target( + Path::new(r"C:\Program Files\WindowsApps\Other\ChatGPT.exe"), + None, + Some(&store_root), + &["ChatGPT.exe", "Codex.exe"], + )); } #[cfg(target_os = "windows")] #[test] - fn opencode_restart_script_is_scoped_to_the_detected_installation() { + fn opencode_restart_matching_is_scoped_to_the_detected_installation() { let application = PathBuf::from( r"C:\Users\tester\AppData\Local\Programs\@opencode-aidesktop\OpenCode.exe", ); - let script = windows_opencode_stop_script(&application); - assert!(script.contains(application.to_string_lossy().as_ref())); - assert!(script.contains("$_.Name -eq 'OpenCode.exe'")); - assert!(script.contains("Get-CimInstance Win32_Process")); - assert!(!script.contains("taskkill")); + assert!(windows_process_matches_stop_target( + &application, + Some(&application), + None, + &["OpenCode.exe"], + )); + assert!(!windows_process_matches_stop_target( + Path::new(r"C:\Users\tester\AppData\Local\Programs\other\OpenCode.exe"), + Some(&application), + None, + &["OpenCode.exe"], + )); } } diff --git a/src-tauri/src/agents/windows_probe.rs b/src-tauri/src/agents/windows_probe.rs new file mode 100644 index 0000000..fc8eb39 --- /dev/null +++ b/src-tauri/src/agents/windows_probe.rs @@ -0,0 +1,881 @@ +use super::*; +use windows_sys::Win32::{ + Foundation::{ERROR_MORE_DATA, ERROR_NO_MORE_ITEMS, ERROR_SUCCESS, INVALID_HANDLE_VALUE}, + Storage::FileSystem::{GetFileVersionInfoSizeW, GetFileVersionInfoW, VerQueryValueW}, + System::{ + Environment::ExpandEnvironmentStringsW, + Registry::{ + RegCloseKey, RegEnumKeyExW, RegOpenKeyExW, RegQueryValueExW, HKEY, HKEY_CURRENT_USER, + HKEY_LOCAL_MACHINE, KEY_READ, REG_EXPAND_SZ, REG_SZ, + }, + }, +}; + +const CURRENT_VERSION: &str = r"Software\Microsoft\Windows\CurrentVersion"; +const CURRENT_VERSION_WOW: &str = r"Software\WOW6432Node\Microsoft\Windows\CurrentVersion"; +const APPX_PACKAGES: &str = r"Software\Classes\Local Settings\Software\Microsoft\Windows\CurrentVersion\AppModel\Repository\Packages"; + +#[derive(Clone, Copy)] +struct RegistryRoot { + hive: HKEY, + prefix: &'static str, +} + +const HKCU_CURRENT_VERSION: RegistryRoot = RegistryRoot { + hive: HKEY_CURRENT_USER, + prefix: CURRENT_VERSION, +}; +const HKCU_CURRENT_VERSION_WOW: RegistryRoot = RegistryRoot { + hive: HKEY_CURRENT_USER, + prefix: CURRENT_VERSION_WOW, +}; +const HKLM_CURRENT_VERSION: RegistryRoot = RegistryRoot { + hive: HKEY_LOCAL_MACHINE, + prefix: CURRENT_VERSION, +}; +const HKLM_CURRENT_VERSION_WOW: RegistryRoot = RegistryRoot { + hive: HKEY_LOCAL_MACHINE, + prefix: CURRENT_VERSION_WOW, +}; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct WindowsZcodeRegistration { + pub(crate) kind: &'static str, + pub(crate) value: String, +} + +#[derive(Clone, Debug)] +pub(crate) struct WindowsAppxPackage { + pub(crate) full_name: String, + pub(crate) family_name: String, + pub(crate) version: String, + pub(crate) install_location: PathBuf, +} + +struct RegistryKey { + handle: HKEY, +} + +impl RegistryKey { + fn open(hive: HKEY, path: &str) -> Option { + let mut handle = std::ptr::null_mut(); + let status = + unsafe { RegOpenKeyExW(hive, to_wide(path).as_ptr(), 0, KEY_READ, &mut handle) }; + (status == ERROR_SUCCESS && !handle.is_null()).then_some(Self { handle }) + } + + fn string(&self, name: &str) -> Option { + let name = to_wide(name); + let mut value_type = 0; + let mut size = 0; + let status = unsafe { + RegQueryValueExW( + self.handle, + name.as_ptr(), + std::ptr::null_mut(), + &mut value_type, + std::ptr::null_mut(), + &mut size, + ) + }; + if status != ERROR_SUCCESS || size == 0 { + return None; + } + let mut buffer = vec![0_u8; size as usize]; + let status = unsafe { + RegQueryValueExW( + self.handle, + name.as_ptr(), + std::ptr::null_mut(), + &mut value_type, + buffer.as_mut_ptr(), + &mut size, + ) + }; + if status != ERROR_SUCCESS { + return None; + } + buffer.truncate(size as usize); + decode_registry_string(value_type, &buffer) + } + + fn subkeys(&self) -> Vec { + let mut names = Vec::new(); + for index in 0.. { + let mut name = vec![0_u16; 256]; + let mut name_len = name.len() as u32; + let status = unsafe { + RegEnumKeyExW( + self.handle, + index, + name.as_mut_ptr(), + &mut name_len, + std::ptr::null_mut(), + std::ptr::null_mut(), + std::ptr::null_mut(), + std::ptr::null_mut(), + ) + }; + match status { + ERROR_SUCCESS => { + name.truncate(name_len as usize); + names.push(from_wide(&name)); + } + ERROR_MORE_DATA => { + name.resize(name_len.max(1) as usize, 0); + let mut retry_len = name.len() as u32; + let status = unsafe { + RegEnumKeyExW( + self.handle, + index, + name.as_mut_ptr(), + &mut retry_len, + std::ptr::null_mut(), + std::ptr::null_mut(), + std::ptr::null_mut(), + std::ptr::null_mut(), + ) + }; + if status != ERROR_SUCCESS { + break; + } + name.truncate(retry_len as usize); + names.push(from_wide(&name)); + } + ERROR_NO_MORE_ITEMS => break, + _ => break, + } + } + names + } +} + +impl Drop for RegistryKey { + fn drop(&mut self) { + unsafe { + RegCloseKey(self.handle); + } + } +} + +fn to_wide(value: &str) -> Vec { + value.encode_utf16().chain([0]).collect() +} + +fn from_wide(value: &[u16]) -> String { + let end = value + .iter() + .position(|unit| *unit == 0) + .unwrap_or(value.len()); + String::from_utf16_lossy(&value[..end]) +} + +fn decode_registry_string(value_type: u32, buffer: &[u8]) -> Option { + if !matches!(value_type, REG_SZ | REG_EXPAND_SZ) { + return None; + } + if buffer.len() < 2 { + return None; + } + let units = buffer + .chunks_exact(2) + .map(|chunk| u16::from_le_bytes([chunk[0], chunk[1]])) + .collect::>(); + let value = from_wide(&units); + if value.is_empty() { + return None; + } + Some(if value_type == REG_EXPAND_SZ { + expand_environment_strings(&value).unwrap_or(value) + } else { + value + }) +} + +fn expand_environment_strings(value: &str) -> Option { + let source = to_wide(value); + let mut size = unsafe { ExpandEnvironmentStringsW(source.as_ptr(), std::ptr::null_mut(), 0) }; + if size == 0 { + return None; + } + let mut buffer = vec![0_u16; size as usize]; + size = unsafe { ExpandEnvironmentStringsW(source.as_ptr(), buffer.as_mut_ptr(), size) }; + (size > 0).then(|| from_wide(&buffer[..size as usize])) +} + +fn current_version_roots() -> [RegistryRoot; 4] { + [ + HKCU_CURRENT_VERSION, + HKCU_CURRENT_VERSION_WOW, + HKLM_CURRENT_VERSION, + HKLM_CURRENT_VERSION_WOW, + ] +} + +fn join_registry_path(prefix: &str, suffix: &str) -> String { + format!("{prefix}\\{suffix}") +} + +pub(crate) fn windows_app_path_executable(file_name: &str) -> Option { + for root in current_version_roots() { + let Some(key) = RegistryKey::open( + root.hive, + &join_registry_path(root.prefix, &format!(r"App Paths\{file_name}")), + ) else { + continue; + }; + if let Some(path) = key.string("") { + let path = PathBuf::from(path.trim().trim_matches('"')); + if path.is_absolute() && path.is_file() { + return Some(path); + } + } + } + None +} + +pub(crate) fn collect_windows_zcode_registrations() -> Vec { + let mut registrations = Vec::new(); + for root in current_version_roots() { + if let Some(path) = RegistryKey::open( + root.hive, + &join_registry_path(root.prefix, r"App Paths\ZCode.exe"), + ) + .and_then(|key| key.string("")) + { + registrations.push(WindowsZcodeRegistration { + kind: "executable", + value: path, + }); + } + let Some(uninstall) = + RegistryKey::open(root.hive, &join_registry_path(root.prefix, "Uninstall")) + else { + continue; + }; + for subkey in uninstall.subkeys() { + let Some(entry) = RegistryKey::open( + root.hive, + &join_registry_path(root.prefix, &format!("Uninstall\\{subkey}")), + ) else { + continue; + }; + let Some(display_name) = entry.string("DisplayName") else { + continue; + }; + if !windows_display_name_matches_zcode(&display_name) { + continue; + } + if let Some(value) = entry.string("InstallLocation") { + registrations.push(WindowsZcodeRegistration { + kind: "directory", + value, + }); + } + if let Some(value) = entry.string("DisplayIcon") { + registrations.push(WindowsZcodeRegistration { + kind: "icon", + value, + }); + } + if let Some(value) = entry.string("UninstallString") { + registrations.push(WindowsZcodeRegistration { + kind: "uninstaller", + value, + }); + } + } + } + registrations +} + +pub(crate) fn windows_display_name_matches_zcode(name: &str) -> bool { + let Some(rest) = name.strip_prefix("ZCode") else { + return false; + }; + rest.is_empty() || rest.starts_with([' ', '(']) +} + +pub(crate) fn parse_windows_zcode_registration(kind: &str, value: &str) -> Option { + let value = value.trim(); + let value = + if let Some(quoted) = value.strip_prefix('"') { + quoted.split_once('"')?.0 + } else if kind == "uninstaller" { + 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) +} + +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| { + parse_windows_zcode_registration( + candidate.get("kind")?.as_str()?, + candidate.get("value")?.as_str()?, + ) + }) +} + +pub(crate) fn find_windows_registered_zcode_executable() -> Option { + collect_windows_zcode_registrations() + .into_iter() + .find_map(|registration| { + parse_windows_zcode_registration(registration.kind, ®istration.value) + }) +} + +pub(crate) fn read_windows_executable_version(path: &Path) -> Option { + let wide = to_wide(&path_to_string(path)); + let mut handle = 0; + let size = unsafe { GetFileVersionInfoSizeW(wide.as_ptr(), &mut handle) }; + if size == 0 { + return None; + } + let mut buffer = vec![0_u8; size as usize]; + if unsafe { GetFileVersionInfoW(wide.as_ptr(), 0, size, buffer.as_mut_ptr().cast()) } == 0 { + return None; + } + read_version_string(&buffer, r"\StringFileInfo\040904B0\ProductVersion") + .or_else(|| read_version_string(&buffer, r"\StringFileInfo\000004B0\ProductVersion")) + .or_else(|| { + let translations = version_translations(&buffer)?; + translations.into_iter().find_map(|(language, codepage)| { + read_version_string( + &buffer, + &format!(r"\StringFileInfo\{language:04X}{codepage:04X}\ProductVersion"), + ) + }) + }) + .and_then(|value| normalize_detected_agent_version(&value)) +} + +fn read_version_string(buffer: &[u8], path: &str) -> Option { + let mut value = std::ptr::null_mut(); + let mut length = 0; + let query = to_wide(path); + let found = unsafe { + VerQueryValueW( + buffer.as_ptr().cast(), + query.as_ptr(), + &mut value, + &mut length, + ) + }; + if found == 0 || value.is_null() || length == 0 { + return None; + } + let units = unsafe { std::slice::from_raw_parts(value.cast::(), length as usize) }; + let value = from_wide(units); + (!value.is_empty()).then_some(value) +} + +fn version_translations(buffer: &[u8]) -> Option> { + let mut value = std::ptr::null_mut(); + let mut length = 0; + let query = to_wide(r"\VarFileInfo\Translation"); + let found = unsafe { + VerQueryValueW( + buffer.as_ptr().cast(), + query.as_ptr(), + &mut value, + &mut length, + ) + }; + if found == 0 || value.is_null() || length < 4 { + return None; + } + let units = unsafe { std::slice::from_raw_parts(value.cast::(), (length as usize) / 2) }; + Some( + units + .chunks_exact(2) + .map(|chunk| (chunk[0], chunk[1])) + .collect(), + ) +} + +fn package_name_from_full_name(full_name: &str) -> Option<&str> { + full_name + .split('_') + .next() + .map(str::trim) + .filter(|name| !name.is_empty()) +} + +fn publisher_id_from_full_name(full_name: &str) -> Option<&str> { + full_name + .rsplit('_') + .next() + .map(str::trim) + .filter(|value| !value.is_empty()) +} + +fn family_name_from_full_name(full_name: &str) -> Option { + let package_name = package_name_from_full_name(full_name)?; + let publisher_id = publisher_id_from_full_name(full_name)?; + if publisher_id == package_name { + return None; + } + Some(format!("{package_name}_{publisher_id}")) +} + +fn version_from_package_full_name(full_name: &str) -> Option { + let mut parts = full_name.split('_'); + let _name = parts.next()?; + let version = parts.next()?.trim(); + (!version.is_empty()).then(|| version.to_string()) +} + +fn compare_dotted_versions(left: &str, right: &str) -> std::cmp::Ordering { + let parse = |value: &str| { + value + .split('.') + .map(|part| part.parse::().unwrap_or(0)) + .collect::>() + }; + let left = parse(left); + let right = parse(right); + left.into_iter() + .zip(right.iter().copied().chain(std::iter::repeat(0))) + .find_map(|(left, right)| (left != right).then(|| left.cmp(&right))) + .unwrap_or(std::cmp::Ordering::Equal) +} + +fn list_appx_packages() -> Vec { + let Some(key) = RegistryKey::open(HKEY_CURRENT_USER, APPX_PACKAGES) else { + return Vec::new(); + }; + let mut packages = Vec::new(); + for full_name in key.subkeys() { + let Some(family_name) = family_name_from_full_name(&full_name) else { + continue; + }; + let Some(package) = + RegistryKey::open(HKEY_CURRENT_USER, &format!("{APPX_PACKAGES}\\{full_name}")) + else { + continue; + }; + let install_location = package + .string("PackageRootFolder") + .or_else(|| package.string("InstallLocation")) + .map(PathBuf::from) + .filter(|path| path.is_absolute()); + let Some(install_location) = install_location else { + continue; + }; + packages.push(WindowsAppxPackage { + version: version_from_package_full_name(&full_name).unwrap_or_default(), + full_name, + family_name, + install_location, + }); + } + packages +} + +fn package_matches_prefixes(full_name: &str, prefixes: &[&str]) -> bool { + prefixes.iter().any(|prefix| { + full_name == *prefix + || full_name.starts_with(&format!("{prefix}_")) + || family_name_from_full_name(full_name).is_some_and(|family| { + family == *prefix || family.starts_with(&format!("{prefix}_")) + }) + }) +} + +pub(crate) fn find_windows_appx_packages(prefixes: &[&str]) -> Vec { + let mut packages = list_appx_packages() + .into_iter() + .filter(|package| package_matches_prefixes(&package.full_name, prefixes)) + .collect::>(); + packages.sort_by(|left, right| compare_dotted_versions(&right.version, &left.version)); + packages +} + +fn appx_manifest_path(package: &WindowsAppxPackage) -> PathBuf { + package.install_location.join("AppxManifest.xml") +} + +fn xml_attr<'a>(tag: &'a str, name: &str) -> Option<&'a str> { + let needle = format!("{name}=\""); + let start = tag.find(&needle)? + needle.len(); + let rest = &tag[start..]; + let end = rest.find('"')?; + Some(&rest[..end]) +} + +fn first_xml_tag<'a>(content: &'a str, local_name: &str) -> Option<&'a str> { + let mut search = content; + while let Some(start) = search.find('<') { + let rest = &search[start + 1..]; + if rest.starts_with('/') || rest.starts_with('!') || rest.starts_with('?') { + search = rest; + continue; + } + let name_end = rest + .find(|character: char| { + character.is_whitespace() || character == '>' || character == '/' + }) + .unwrap_or(rest.len()); + let tag_name = &rest[..name_end]; + let local = tag_name + .rsplit_once(':') + .map(|(_, name)| name) + .unwrap_or(tag_name); + if local.eq_ignore_ascii_case(local_name) { + let close = rest.find('>')?; + return Some(&rest[..close]); + } + search = rest; + } + None +} + +fn appx_application_id_from_manifest(content: &str) -> Option { + xml_attr(first_xml_tag(content, "Application")?, "Id") + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) +} + +fn appx_executable_from_manifest(content: &str, application_id: Option<&str>) -> Option { + let mut search = content; + while let Some(start) = search.find('<') { + let rest = &search[start + 1..]; + if rest.starts_with('/') || rest.starts_with('!') || rest.starts_with('?') { + search = rest; + continue; + } + let name_end = rest + .find(|character: char| { + character.is_whitespace() || character == '>' || character == '/' + }) + .unwrap_or(rest.len()); + let tag_name = &rest[..name_end]; + let local = tag_name + .rsplit_once(':') + .map(|(_, name)| name) + .unwrap_or(tag_name); + if local.eq_ignore_ascii_case("Application") { + let close = rest.find('>')?; + let tag = &rest[..close]; + let id = xml_attr(tag, "Id")?; + if application_id.is_none_or(|expected| expected == id) { + return xml_attr(tag, "Executable") + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string); + } + search = &rest[close..]; + continue; + } + search = rest; + } + None +} + +pub(crate) fn windows_appx_app_id(package: &WindowsAppxPackage) -> Option { + let content = fs::read_to_string(appx_manifest_path(package)).ok(); + let application_id = content + .as_deref() + .and_then(appx_application_id_from_manifest) + .unwrap_or_else(|| "App".to_string()); + (!application_id.is_empty()).then(|| format!("{}!{application_id}", package.family_name)) +} + +pub(crate) fn windows_appx_executable( + package: &WindowsAppxPackage, + application_id: Option<&str>, +) -> Option { + let content = fs::read_to_string(appx_manifest_path(package)).ok()?; + let relative = appx_executable_from_manifest(&content, application_id)?; + let path = package.install_location.join(relative.replace('/', "\\")); + path.is_file().then_some(path) +} + +pub(crate) fn find_windows_appx_app_id(prefixes: &[&str]) -> Option { + find_windows_appx_packages(prefixes) + .into_iter() + .find_map(|package| windows_appx_app_id(&package)) +} + +pub(crate) fn find_windows_claude_app_id() -> Option { + find_windows_appx_app_id(&["Claude", "Anthropic.Claude"]) +} + +pub(crate) fn read_windows_claude_desktop_store_version() -> Option { + find_windows_appx_packages(&["Claude", "Anthropic.Claude"]) + .into_iter() + .find_map(|package| normalize_detected_agent_version(&package.version)) +} + +pub(crate) fn parse_windows_claude_desktop_version_output(output: &str) -> Option { + output.lines().find_map(|line| { + line.trim() + .strip_prefix("VERSION:") + .and_then(normalize_detected_agent_version) + }) +} + +pub(crate) fn parse_windows_codex_version_output(output: &str) -> Option { + output.lines().find_map(|line| { + line.trim() + .strip_prefix("VERSION:") + .and_then(normalize_detected_agent_version) + }) +} + +pub(crate) fn parse_windows_codex_app_discovery_output(output: &str) -> Option { + output.lines().find_map(|line| { + let line = line.trim(); + if let Some(app_id) = line.strip_prefix("APPID:").map(str::trim) { + return (!app_id.is_empty()) + .then(|| DesktopAppTarget::WindowsAppId(app_id.to_string())); + } + line.strip_prefix("EXE:") + .map(str::trim) + .filter(|path| !path.is_empty()) + .map(|path| DesktopAppTarget::Application(PathBuf::from(path))) + }) +} + +pub(crate) fn parse_windows_codex_app_id_from_registry(output: &str) -> Option { + const PACKAGE_MARKER: &str = "\\AppModel\\Repository\\Packages\\"; + output.lines().find_map(|line| { + let line = line.trim(); + let (_, package_full_name) = line.split_once(PACKAGE_MARKER)?; + if package_full_name.contains('\\') { + return None; + } + windows_codex_app_id_from_package_full_name(package_full_name) + }) +} + +pub(crate) fn windows_codex_app_id_from_package_full_name( + package_full_name: &str, +) -> Option { + let package_name = package_name_from_full_name(package_full_name)?; + if !matches!( + package_name, + "OpenAI.Codex" | "OpenAI.CodexBeta" | "OpenAI.ChatGPT" + ) { + return None; + } + let publisher_id = publisher_id_from_full_name(package_full_name)?; + if publisher_id == package_name { + return None; + } + Some(format!("{package_name}_{publisher_id}!App")) +} + +pub(crate) fn find_windows_codex_app_id_via_registry() -> Option { + find_windows_appx_app_id(&["OpenAI.Codex", "OpenAI.CodexBeta", "OpenAI.ChatGPT"]) +} + +pub(crate) fn windows_codex_store_app_id_parts(app_id: &str) -> Option<(&str, &str)> { + let (family, application) = app_id.split_once('!')?; + (!family.is_empty() && !application.is_empty()).then_some((family, application)) +} + +pub(crate) fn find_windows_codex_store_executable(app_id: &str) -> Option { + let (family, application) = windows_codex_store_app_id_parts(app_id)?; + find_windows_appx_packages(&["OpenAI.Codex", "OpenAI.CodexBeta", "OpenAI.ChatGPT"]) + .into_iter() + .find(|package| package.family_name.eq_ignore_ascii_case(family)) + .and_then(|package| windows_appx_executable(&package, Some(application))) +} + +pub(crate) fn find_windows_registered_codex_app_installation() -> Option { + if let Some(app_id) = find_windows_codex_app_id_via_registry() { + return Some(DesktopAppTarget::WindowsAppId(app_id)); + } + for name in ["ChatGPT.exe", "Codex.exe"] { + if let Some(path) = windows_app_path_executable(name) { + let lowered = path_to_string(&path).to_ascii_lowercase(); + if name.eq_ignore_ascii_case("Codex.exe") + && ["\\bin\\", "\\node_modules\\", "\\.vscode\\extensions\\"] + .iter() + .any(|marker| lowered.contains(marker)) + { + continue; + } + return Some(DesktopAppTarget::Application(path)); + } + } + None +} + +pub(crate) fn windows_path_key(path: &Path) -> String { + path_to_string(path) + .trim_end_matches(['\\', '/']) + .replace('/', "\\") + .to_ascii_lowercase() +} + +pub(crate) fn windows_process_matches_stop_target( + process_path: &Path, + executable: Option<&Path>, + install_root: Option<&Path>, + image_names: &[&str], +) -> bool { + if !image_names.is_empty() { + let file_name = process_path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or_default(); + if !image_names + .iter() + .any(|name| file_name.eq_ignore_ascii_case(name)) + { + return false; + } + } + let process_key = windows_path_key(process_path); + if let Some(executable) = executable { + if process_key == windows_path_key(executable) { + return true; + } + } + if let Some(root) = install_root { + let root_key = windows_path_key(root); + if !root_key.is_empty() + && (process_key == root_key || process_key.starts_with(&format!("{root_key}\\"))) + { + return true; + } + } + false +} + +pub(crate) fn windows_desktop_stop_target( + target: &DesktopAppTarget, +) -> Result<(Option, Option), String> { + match target { + DesktopAppTarget::Application(path) => Ok((Some(path.clone()), None)), + DesktopAppTarget::WindowsAppId(app_id) => { + let family = app_id.split('!').next().unwrap_or(app_id); + find_windows_appx_packages(&[family]) + .into_iter() + .find(|package| package.family_name.eq_ignore_ascii_case(family)) + .map(|package| (None, Some(package.install_location))) + .ok_or_else(|| "Application package directory was not found".to_string()) + } + } +} + +pub(crate) fn find_windows_matching_process_ids( + executable: Option<&Path>, + install_root: Option<&Path>, + image_names: &[&str], +) -> Vec { + find_windows_process_snapshot() + .into_iter() + .filter(|(_, path)| { + windows_process_matches_stop_target(path, executable, install_root, image_names) + }) + .map(|(process_id, _)| process_id) + .collect() +} + +fn find_windows_process_snapshot() -> Vec<(u32, PathBuf)> { + use windows_sys::Win32::System::Diagnostics::ToolHelp::{ + CreateToolhelp32Snapshot, Process32FirstW, Process32NextW, PROCESSENTRY32W, + TH32CS_SNAPPROCESS, + }; + + let snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) }; + if snapshot == INVALID_HANDLE_VALUE { + return Vec::new(); + } + let mut entry = unsafe { std::mem::zeroed::() }; + entry.dwSize = std::mem::size_of::() as u32; + let mut processes = Vec::new(); + if unsafe { Process32FirstW(snapshot, &mut entry) } != 0 { + loop { + if entry.th32ProcessID != 0 { + if let Some(path) = process_executable_path(entry.th32ProcessID) { + processes.push((entry.th32ProcessID, path)); + } + } + if unsafe { Process32NextW(snapshot, &mut entry) } == 0 { + break; + } + } + } + unsafe { + windows_sys::Win32::Foundation::CloseHandle(snapshot); + } + processes +} + +fn terminate_windows_process(process_id: u32) { + use windows_sys::Win32::System::Threading::{OpenProcess, TerminateProcess, PROCESS_TERMINATE}; + + let handle = unsafe { OpenProcess(PROCESS_TERMINATE, 0, process_id) }; + if handle.is_null() { + return; + } + unsafe { + TerminateProcess(handle, 1); + windows_sys::Win32::Foundation::CloseHandle(handle); + } +} + +pub(crate) fn stop_windows_matching_processes( + executable: Option<&Path>, + install_root: Option<&Path>, + image_names: &[&str], + label: &str, +) -> Result<(), String> { + let deadline = Instant::now() + Duration::from_secs(5); + loop { + let remaining = find_windows_matching_process_ids(executable, install_root, image_names); + if remaining.is_empty() { + return Ok(()); + } + if Instant::now() >= deadline { + let ids = remaining + .iter() + .map(ToString::to_string) + .collect::>() + .join(", "); + return Err(format!( + "{label} 未能完全关闭; remaining process IDs: {ids}" + )); + } + for process_id in remaining { + terminate_windows_process(process_id); + } + thread::sleep(Duration::from_millis(100)); + } +} diff --git a/src-tauri/src/tests/agent_paths.rs b/src-tauri/src/tests/agent_paths.rs index c71546d..b7d9186 100644 --- a/src-tauri/src/tests/agent_paths.rs +++ b/src-tauri/src/tests/agent_paths.rs @@ -119,6 +119,16 @@ fn zcode_windows_finds_custom_installations_from_registered_paths() { fs::remove_dir_all(home).unwrap(); } +#[cfg(target_os = "windows")] +#[test] +fn zcode_windows_display_name_matches_installer_variants() { + assert!(windows_display_name_matches_zcode("ZCode")); + assert!(windows_display_name_matches_zcode("ZCode (64-bit)")); + assert!(windows_display_name_matches_zcode("ZCode Desktop")); + assert!(!windows_display_name_matches_zcode("ZCodeHelper")); + assert!(!windows_display_name_matches_zcode("MyZCode")); +} + #[cfg(target_os = "windows")] #[test] fn zcode_windows_skips_stale_and_unrelated_registration_entries() { diff --git a/src-tauri/src/tests/platform.rs b/src-tauri/src/tests/platform.rs index 2e1e85e..1a991b6 100644 --- a/src-tauri/src/tests/platform.rs +++ b/src-tauri/src/tests/platform.rs @@ -87,6 +87,14 @@ fn windows_chatgpt_discovery_parser_accepts_registered_app_and_executable() { } assert!(parse_windows_codex_app_discovery_output("MSEdgePWA:ChatGPT\r\n").is_none()); + assert_eq!( + parse_windows_claude_desktop_version_output("VERSION:1.2.3\r\n").as_deref(), + Some("1.2.3") + ); + assert_eq!( + parse_windows_codex_version_output("VERSION:26.901.51231\r\n").as_deref(), + Some("26.901.51231") + ); let registry_output = r"HKEY_CURRENT_USER\Software\Classes\Local Settings\Software\Microsoft\Windows\CurrentVersion\AppModel\Repository\Packages\OpenAI.Codex_26.715.4045.0_x64__2p2nqsd0c76g0"; assert_eq!( @@ -231,67 +239,35 @@ fn codex_store_version_resolves_only_the_selected_family_and_application() { let home = agent_test_home("codex-store-version-target"); let executable = home.join("ChatGPT.exe"); fs::write(&executable, b"test").unwrap(); - let install_location = windows_powershell_single_quoted_literal(&path_to_string(&home)); - let mocks = format!( - r#" -function Get-AppxPackage {{ - [pscustomobject]@{{ PackageFamilyName='OpenAI.ChatGPT_publisher'; Version='99.0.0.0'; PackageFullName='wrong-chatgpt' }} - [pscustomobject]@{{ PackageFamilyName='OpenAI.CodexBeta_publisher'; Version='98.0.0.0'; PackageFullName='wrong-beta' }} - [pscustomobject]@{{ PackageFamilyName='OpenAI.Codex_publisher'; Version='26.800.1.0'; PackageFullName='old-codex' }} - [pscustomobject]@{{ PackageFamilyName='OpenAI.Codex_publisher'; Version='26.901.6511.0'; PackageFullName='selected-codex'; InstallLocation={install_location} }} -}} -function Get-AppxPackageManifest($Package) {{ - if ($Package -ne 'selected-codex') {{ throw 'wrong package' }} - [pscustomobject]@{{ Package=@{{ Applications=@{{ Application=@( - [pscustomobject]@{{ Id='Other'; Executable='wrong.exe' }}, - [pscustomobject]@{{ Id='App'; Executable='ChatGPT.exe' }} - ) }} }} }} -}} -"# + fs::write( + home.join("AppxManifest.xml"), + r#" + + + "#, + ) + .unwrap(); + let package = WindowsAppxPackage { + full_name: "OpenAI.Codex_26.901.6511.0_x64__publisher".to_string(), + family_name: "OpenAI.Codex_publisher".to_string(), + version: "26.901.6511.0".to_string(), + install_location: home.clone(), + }; + assert_eq!( + windows_appx_executable(&package, Some("App")).as_deref(), + Some(executable.as_path()) ); - for (app_id, expected) in [ - ("OpenAI.Codex_publisher!App", true), - ("OpenAI.Codex_publisher!Missing", false), - ("OpenAI.Codex_missing!App", false), - ] { - let script = format!( - "{mocks}\n{}", - windows_codex_store_executable_script(app_id).unwrap() - ); - let mut command = Command::new(windows_powershell_executable()); - command.args([ - "-NoLogo", - "-NoProfile", - "-NonInteractive", - "-EncodedCommand", - &windows_powershell_encoded_command(&script), - ]); - configure_background_command(&mut command); - let output = command_output_with_timeout(&mut command, Duration::from_secs(10)) - .unwrap() - .unwrap(); - assert!( - output.status.success(), - "{}", - String::from_utf8_lossy(&output.stderr) - ); - let target = - parse_windows_codex_app_discovery_output(&String::from_utf8_lossy(&output.stdout)); - if expected { - assert!( - matches!(target, Some(DesktopAppTarget::Application(path)) if path == executable) - ); - } else { - assert!(target.is_none()); - } - } - assert!(windows_codex_store_executable_script("invalid").is_none()); - assert!(windows_codex_store_executable_script("!App").is_none()); - assert!(windows_codex_store_executable_script("OpenAI.Codex_publisher!").is_none()); - assert!( - windows_codex_store_executable_script("OpenAI.Codex_o'brien!App") - .unwrap() - .contains("'OpenAI.Codex_o''brien'") + assert!(windows_appx_executable(&package, Some("Missing")).is_none()); + assert_eq!( + windows_appx_app_id(&package).as_deref(), + Some("OpenAI.Codex_publisher!Other") + ); + assert!(windows_codex_store_app_id_parts("invalid").is_none()); + assert!(windows_codex_store_app_id_parts("!App").is_none()); + assert!(windows_codex_store_app_id_parts("OpenAI.Codex_publisher!").is_none()); + assert_eq!( + windows_codex_store_app_id_parts("OpenAI.Codex_o'brien!App"), + Some(("OpenAI.Codex_o'brien", "App")) ); fs::remove_dir_all(home).unwrap(); }