fix(agents): probe Windows installs without encoded PowerShell
This commit is contained in:
1 parent
717e9e6030
commit
f4d91b59fc
7 files changed
+1206
-791
No files matched your search
@@ -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
@@ -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
@@ -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
@@ -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"],
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -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, ®istration.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));
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
Reference in new issue
Block a user