fix(agents): probe Windows installs without encoded PowerShell

This commit is contained in:
lzt404 committed 2026-09-18 18:17:31 +08:00
1 parent 717e9e6030
commit f4d91b59fc
7 files changed
+1206 -791

No files matched your search

+2
View File
@@ -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",
] }
+14 -11
View File
@@ -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::*;
+173 -487
View File
@@ -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<String> {
@@ -137,8 +134,7 @@ pub(crate) fn read_package_json_version(path: &Path) -> Option<String> {
}
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<bool, String> {
@@ -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<Vec<String>, 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<bool, String>) -> &'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<bool, String>,
) -> &'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<bool, String> {
pub(crate) fn agent_has_connection_evidence(
client: AgentClient,
paths: &[PathBuf],
) -> Result<bool, String> {
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::<toml::Value>(content).map_err(|e| e.to_string())?).map_err(|e| e.to_string())?,
Some("toml") => serde_json::to_value(
toml::from_str::<toml::Value>(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<String> {
}
#[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<PathBuf> {
}
}
#[cfg(target_os = "windows")]
pub(crate) fn find_windows_registered_zcode_executable() -> Option<PathBuf> {
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<PathBuf> {
let candidates: Vec<serde_json::Value> = 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::<i32>().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<String> {
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<DesktopAppTarget> {
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<String> {
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<String> {
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<String> {
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<String> {
normalize_detected_agent_version(package.get("version")?.as_str()?)
}
#[cfg(target_os = "windows")]
pub(crate) fn find_windows_registered_codex_app_installation() -> Option<DesktopAppTarget> {
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::<Vec<_>>();
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<String> {
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<DesktopAppTarget> {
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<String> {
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<String> {
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<String> {
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<String> {
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<String> {
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<PathBuf> {
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)
+90 -233
View File
@@ -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<String> {
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"],
));
}
}
+881
View File
@@ -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<Self> {
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<String> {
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<String> {
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<u16> {
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<String> {
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::<Vec<_>>();
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<String> {
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<PathBuf> {
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<WindowsZcodeRegistration> {
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<PathBuf> {
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::<i32>().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<PathBuf> {
let candidates: Vec<serde_json::Value> = 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<PathBuf> {
collect_windows_zcode_registrations()
.into_iter()
.find_map(|registration| {
parse_windows_zcode_registration(registration.kind, &registration.value)
})
}
pub(crate) fn read_windows_executable_version(path: &Path) -> Option<String> {
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<String> {
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::<u16>(), length as usize) };
let value = from_wide(units);
(!value.is_empty()).then_some(value)
}
fn version_translations(buffer: &[u8]) -> Option<Vec<(u16, u16)>> {
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::<u16>(), (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<String> {
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<String> {
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::<u64>().unwrap_or(0))
.collect::<Vec<_>>()
};
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<WindowsAppxPackage> {
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<WindowsAppxPackage> {
let mut packages = list_appx_packages()
.into_iter()
.filter(|package| package_matches_prefixes(&package.full_name, prefixes))
.collect::<Vec<_>>();
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<String> {
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<String> {
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<String> {
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<PathBuf> {
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<String> {
find_windows_appx_packages(prefixes)
.into_iter()
.find_map(|package| windows_appx_app_id(&package))
}
pub(crate) fn find_windows_claude_app_id() -> Option<String> {
find_windows_appx_app_id(&["Claude", "Anthropic.Claude"])
}
pub(crate) fn read_windows_claude_desktop_store_version() -> Option<String> {
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<String> {
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<String> {
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<DesktopAppTarget> {
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<String> {
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<String> {
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<String> {
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<PathBuf> {
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<DesktopAppTarget> {
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<PathBuf>, Option<PathBuf>), 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<u32> {
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::<PROCESSENTRY32W>() };
entry.dwSize = std::mem::size_of::<PROCESSENTRY32W>() 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::<Vec<_>>()
.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));
}
}
+10
View File
@@ -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() {
+36 -60
View File
@@ -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#"<Package><Applications>
<Application Id="Other" Executable="wrong.exe" />
<Application Id="App" Executable="ChatGPT.exe" />
</Applications></Package>"#,
)
.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();
}