* chore(release): stop publishing legacy Windows update packages * feat(agents): support OpenCode Desktop across platforms Detect CLI and desktop installations on Windows, macOS, and Linux, and add separate launch and restart controls. Closes #156
839 lines
29 KiB
Rust
839 lines
29 KiB
Rust
use super::*;
|
|
|
|
#[tauri::command]
|
|
pub(crate) fn launch_agent(
|
|
app: tauri::AppHandle,
|
|
gui_config_state: tauri::State<'_, GuiConfigState>,
|
|
client: String,
|
|
target: Option<String>,
|
|
working_directory: Option<String>,
|
|
) -> Result<(), String> {
|
|
let home = app
|
|
.path()
|
|
.home_dir()
|
|
.map_err(|error| format!("无法获取用户目录: {error}"))?;
|
|
let config = gui_config_state.snapshot()?;
|
|
let requested_target = target
|
|
.as_deref()
|
|
.map(str::trim)
|
|
.filter(|value| !value.is_empty());
|
|
|
|
if client.trim().eq_ignore_ascii_case(PI_AGENT_ID) {
|
|
if requested_target.is_some_and(|value| value != "cli") {
|
|
return Err("Pi 只支持 CLI 启动方式".to_string());
|
|
}
|
|
let status =
|
|
inspect_pi_provider_status(&home, config.port, effective_agent_api_key(&config));
|
|
if !status.installed {
|
|
return Err("未检测到 Pi CLI,请先安装 Pi 并重新检测".to_string());
|
|
}
|
|
let executable =
|
|
find_pi_executable(&home).ok_or_else(|| "未找到 Pi CLI 可执行文件".to_string())?;
|
|
let launch_directory = resolve_launch_directory(working_directory.as_deref(), &home)?;
|
|
return launch_cli_agent(&executable, PI_AGENT_NAME, &launch_directory, &[]);
|
|
}
|
|
|
|
let client = AgentClient::parse(&client)?;
|
|
if !client.supported_platform() {
|
|
return Err(format!("当前平台不支持启动 {}", client.name()));
|
|
}
|
|
let status = inspect_agent_config(client, &home, config.port, effective_agent_api_key(&config));
|
|
if !status.installed {
|
|
return Err(format!("未检测到 {},请先安装并重新检测", client.name()));
|
|
}
|
|
let default_target = status
|
|
.launch_targets
|
|
.first()
|
|
.map(|target| target.id.as_str())
|
|
.unwrap_or("cli");
|
|
let requested_target = requested_target.unwrap_or(default_target);
|
|
match (client, requested_target) {
|
|
(AgentClient::ClaudeDesktop, "app") => launch_claude_desktop(&home),
|
|
(AgentClient::ZCode, "app") => {
|
|
let executable = find_zcode_desktop_executable(&home)
|
|
.ok_or_else(|| "未找到 ZCode 应用程序".to_string())?;
|
|
launch_desktop_agent(&executable, client.name())
|
|
}
|
|
(AgentClient::Codex, "app") => launch_codex_desktop(&home),
|
|
(AgentClient::OpenCode, "app") => launch_opencode_desktop(&home),
|
|
(AgentClient::ClaudeDesktop | AgentClient::ZCode, "cli") => {
|
|
Err(format!("{} 不支持 CLI 启动方式", client.name()))
|
|
}
|
|
(_, "cli") => {
|
|
let executable = find_agent_executable(client, &home)
|
|
.ok_or_else(|| format!("未找到 {} 的可执行文件", client.name()))?;
|
|
let launch_directory = resolve_launch_directory(working_directory.as_deref(), &home)?;
|
|
let environment_to_remove = if client == AgentClient::ClaudeCode {
|
|
&["ANTHROPIC_API_KEY"][..]
|
|
} else {
|
|
&[]
|
|
};
|
|
launch_cli_agent(
|
|
&executable,
|
|
client.name(),
|
|
&launch_directory,
|
|
environment_to_remove,
|
|
)
|
|
}
|
|
(_, "app") => Err(format!("{} 不支持桌面 App 启动方式", client.name())),
|
|
_ => Err("不支持的智能体启动方式".to_string()),
|
|
}
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub(crate) async fn restart_codex_app(app: tauri::AppHandle) -> Result<(), String> {
|
|
let home = app
|
|
.path()
|
|
.home_dir()
|
|
.map_err(|error| format!("无法获取用户目录: {error}"))?;
|
|
let target = find_codex_app_installation(&home)
|
|
.ok_or_else(|| "未检测到 Codex 桌面应用,请重新检测或改用 Codex CLI".to_string())?;
|
|
|
|
tauri::async_runtime::spawn_blocking(move || {
|
|
stop_codex_desktop(&target)?;
|
|
launch_codex_target(&target)
|
|
})
|
|
.await
|
|
.map_err(|error| format!("重启 Codex App 任务失败: {error}"))?
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub(crate) async fn restart_opencode_app(app: tauri::AppHandle) -> Result<(), String> {
|
|
let home = app
|
|
.path()
|
|
.home_dir()
|
|
.map_err(|error| format!("无法获取用户目录: {error}"))?;
|
|
let application = find_opencode_desktop_application(&home).ok_or_else(|| {
|
|
"未检测到 OpenCode Desktop 应用,请重新检测或改用 OpenCode CLI".to_string()
|
|
})?;
|
|
|
|
tauri::async_runtime::spawn_blocking(move || {
|
|
stop_opencode_desktop(&application)?;
|
|
launch_desktop_agent(&application, "OpenCode Desktop")
|
|
})
|
|
.await
|
|
.map_err(|error| format!("重启 OpenCode Desktop 任务失败: {error}"))?
|
|
}
|
|
|
|
fn resolve_launch_directory(value: Option<&str>, fallback: &Path) -> Result<PathBuf, String> {
|
|
let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) else {
|
|
return Ok(fallback.to_path_buf());
|
|
};
|
|
if value.chars().any(char::is_control) {
|
|
return Err("工作目录包含无效字符".to_string());
|
|
}
|
|
let path = PathBuf::from(value);
|
|
if !path.is_absolute() {
|
|
return Err("工作目录必须是绝对路径".to_string());
|
|
}
|
|
if !path.is_dir() {
|
|
return Err(format!("工作目录不存在: {}", path_to_string(&path)));
|
|
}
|
|
Ok(path)
|
|
}
|
|
|
|
fn launch_codex_desktop(home: &Path) -> Result<(), String> {
|
|
let target = find_codex_app_installation(home)
|
|
.ok_or_else(|| "未检测到 Codex 桌面应用,请重新检测或改用 Codex CLI".to_string())?;
|
|
launch_codex_target(&target)
|
|
}
|
|
|
|
fn launch_codex_target(target: &CodexAppTarget) -> Result<(), String> {
|
|
match target {
|
|
#[cfg(target_os = "windows")]
|
|
CodexAppTarget::WindowsAppId(app_id) => launch_windows_store_app(app_id, "Codex App"),
|
|
CodexAppTarget::Application(path) => launch_desktop_agent(path, "Codex App"),
|
|
}
|
|
}
|
|
|
|
#[cfg(target_os = "windows")]
|
|
fn stop_codex_desktop(target: &CodexAppTarget) -> 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: &CodexAppTarget) -> String {
|
|
let selector = match target {
|
|
CodexAppTarget::Application(path) => format!(
|
|
"$targetExecutable = {}\n$targetRoot = $null",
|
|
windows_powershell_single_quoted_literal(&path_to_string(path))
|
|
),
|
|
CodexAppTarget::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 ', ')"
|
|
"#
|
|
)
|
|
}
|
|
|
|
#[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 ', ')"
|
|
"#
|
|
)
|
|
}
|
|
|
|
#[cfg(target_os = "macos")]
|
|
fn stop_codex_desktop(target: &CodexAppTarget) -> Result<(), String> {
|
|
let CodexAppTarget::Application(application) = target;
|
|
stop_macos_desktop_application(application, "Codex App")
|
|
}
|
|
|
|
#[cfg(target_os = "macos")]
|
|
fn stop_opencode_desktop(application: &Path) -> Result<(), String> {
|
|
stop_macos_desktop_application(application, "OpenCode Desktop")
|
|
}
|
|
|
|
#[cfg(target_os = "macos")]
|
|
fn stop_macos_desktop_application(application: &Path, label: &str) -> Result<(), String> {
|
|
let application_bundle = application
|
|
.ancestors()
|
|
.find(|path| path.extension().and_then(|value| value.to_str()) == Some("app"))
|
|
.unwrap_or(application);
|
|
let process_name = application_bundle
|
|
.file_stem()
|
|
.and_then(|name| name.to_str())
|
|
.filter(|name| !name.is_empty())
|
|
.ok_or_else(|| format!("无法识别 {label} 进程名称"))?;
|
|
let application_name = application_bundle
|
|
.file_stem()
|
|
.and_then(|name| name.to_str())
|
|
.unwrap_or(process_name);
|
|
let escaped_name = application_name.replace('\\', "\\\\").replace('"', "\\\"");
|
|
let _ = Command::new("osascript")
|
|
.args([
|
|
"-e",
|
|
&format!("tell application \"{escaped_name}\" to quit"),
|
|
])
|
|
.status();
|
|
wait_for_unix_process_exit(process_name, label)
|
|
}
|
|
|
|
#[cfg(target_os = "macos")]
|
|
fn wait_for_unix_process_exit(process_name: &str, label: &str) -> Result<(), String> {
|
|
for _ in 0..50 {
|
|
let status = Command::new("pgrep").args(["-x", process_name]).status();
|
|
if status.is_ok_and(|status| !status.success()) {
|
|
return Ok(());
|
|
}
|
|
thread::sleep(Duration::from_millis(100));
|
|
}
|
|
let _ = Command::new("pkill")
|
|
.args(["-KILL", "-x", process_name])
|
|
.status();
|
|
thread::sleep(Duration::from_millis(100));
|
|
let status = Command::new("pgrep")
|
|
.args(["-x", process_name])
|
|
.status()
|
|
.map_err(|error| format!("检查 {label} 进程失败: {error}"))?;
|
|
if status.success() {
|
|
Err(format!("{label} 未能完全关闭"))
|
|
} else {
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
fn stop_opencode_desktop(application: &Path) -> Result<(), String> {
|
|
let application = fs::canonicalize(application).unwrap_or_else(|_| application.to_path_buf());
|
|
let processes = linux_processes_for_application(&application);
|
|
let mut signal_error = None;
|
|
for process_id in processes {
|
|
if let Err(error) = signal_linux_process(process_id, libc::SIGTERM) {
|
|
signal_error.get_or_insert(error);
|
|
}
|
|
}
|
|
|
|
for _ in 0..50 {
|
|
if linux_processes_for_application(&application).is_empty() {
|
|
return Ok(());
|
|
}
|
|
thread::sleep(Duration::from_millis(100));
|
|
}
|
|
|
|
let remaining = linux_processes_for_application(&application);
|
|
for process_id in &remaining {
|
|
if let Err(error) = signal_linux_process(*process_id, libc::SIGKILL) {
|
|
signal_error.get_or_insert(error);
|
|
}
|
|
}
|
|
for _ in 0..10 {
|
|
if linux_processes_for_application(&application).is_empty() {
|
|
return Ok(());
|
|
}
|
|
thread::sleep(Duration::from_millis(100));
|
|
}
|
|
|
|
let remaining = linux_processes_for_application(&application);
|
|
if remaining.is_empty() {
|
|
Ok(())
|
|
} else if let Some(error) = signal_error {
|
|
Err(error)
|
|
} else {
|
|
Err(format!(
|
|
"OpenCode Desktop 未能完全关闭;剩余进程 ID: {}",
|
|
remaining
|
|
.iter()
|
|
.map(i32::to_string)
|
|
.collect::<Vec<_>>()
|
|
.join(", ")
|
|
))
|
|
}
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
fn linux_processes_for_application(application: &Path) -> Vec<i32> {
|
|
let current_process = std::process::id() as i32;
|
|
let Ok(entries) = fs::read_dir("/proc") else {
|
|
return Vec::new();
|
|
};
|
|
let mut process_ids = entries
|
|
.flatten()
|
|
.filter_map(|entry| entry.file_name().to_str()?.parse::<i32>().ok())
|
|
.filter(|process_id| *process_id != current_process)
|
|
.filter(|process_id| linux_process_matches_application(*process_id, application))
|
|
.collect::<Vec<_>>();
|
|
process_ids.sort_unstable();
|
|
process_ids
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
fn linux_process_matches_application(process_id: i32, application: &Path) -> bool {
|
|
use std::ffi::OsStr;
|
|
use std::os::unix::ffi::OsStrExt;
|
|
|
|
let process_directory = PathBuf::from(format!("/proc/{process_id}"));
|
|
if fs::read_link(process_directory.join("exe"))
|
|
.ok()
|
|
.is_some_and(|path| linux_process_path_matches(&path, application))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
if let Ok(command_line) = fs::read(process_directory.join("cmdline")) {
|
|
let installation_root = linux_opencode_nix_installation_root(application);
|
|
if command_line
|
|
.split(|byte| *byte == 0)
|
|
.filter(|argument| !argument.is_empty())
|
|
.map(|argument| Path::new(OsStr::from_bytes(argument)))
|
|
.any(|argument| {
|
|
linux_process_path_matches(argument, application)
|
|
|| installation_root.is_some_and(|root| {
|
|
fs::canonicalize(argument)
|
|
.ok()
|
|
.is_some_and(|argument| argument.starts_with(root))
|
|
})
|
|
})
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
|
|
fs::read(process_directory.join("environ"))
|
|
.ok()
|
|
.is_some_and(|environment| {
|
|
environment
|
|
.split(|byte| *byte == 0)
|
|
.filter_map(|entry| entry.strip_prefix(b"APPIMAGE="))
|
|
.any(|path| {
|
|
!path.is_empty()
|
|
&& linux_process_path_matches(
|
|
Path::new(OsStr::from_bytes(path)),
|
|
application,
|
|
)
|
|
})
|
|
})
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
fn linux_process_path_matches(candidate: &Path, application: &Path) -> bool {
|
|
candidate == application
|
|
|| fs::canonicalize(candidate)
|
|
.ok()
|
|
.is_some_and(|candidate| candidate == application)
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
fn linux_opencode_nix_installation_root(application: &Path) -> Option<&Path> {
|
|
application.ancestors().find(|ancestor| {
|
|
ancestor.parent() == Some(Path::new("/nix/store"))
|
|
&& ancestor
|
|
.file_name()
|
|
.and_then(|name| name.to_str())
|
|
.is_some_and(|name| name.to_ascii_lowercase().contains("opencode"))
|
|
})
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
fn signal_linux_process(process_id: i32, signal: i32) -> Result<(), String> {
|
|
if unsafe { libc::kill(process_id, signal) } == 0 {
|
|
return Ok(());
|
|
}
|
|
let error = io::Error::last_os_error();
|
|
if error.raw_os_error() == Some(libc::ESRCH) {
|
|
Ok(())
|
|
} else {
|
|
Err(format!(
|
|
"关闭 OpenCode Desktop 进程 {process_id} 失败: {error}"
|
|
))
|
|
}
|
|
}
|
|
|
|
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
|
|
fn stop_codex_desktop(_target: &CodexAppTarget) -> Result<(), String> {
|
|
Err("当前平台不支持重启 Codex App".to_string())
|
|
}
|
|
|
|
#[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))]
|
|
fn stop_opencode_desktop(_application: &Path) -> Result<(), String> {
|
|
Err("当前平台不支持重启 OpenCode Desktop".to_string())
|
|
}
|
|
|
|
fn launch_claude_desktop(home: &Path) -> Result<(), String> {
|
|
if let Some(executable) = find_claude_desktop_executable(home) {
|
|
return launch_desktop_agent(&executable, "Claude Desktop");
|
|
}
|
|
#[cfg(target_os = "windows")]
|
|
{
|
|
launch_windows_claude_store_app()
|
|
}
|
|
#[cfg(not(target_os = "windows"))]
|
|
Err("未检测到 Claude Desktop 应用,请先安装或重新检测".to_string())
|
|
}
|
|
|
|
fn launch_opencode_desktop(home: &Path) -> Result<(), String> {
|
|
let application = find_opencode_desktop_application(home)
|
|
.ok_or_else(|| "未检测到 OpenCode Desktop 应用,请先安装或重新检测".to_string())?;
|
|
launch_desktop_agent(&application, "OpenCode Desktop")
|
|
}
|
|
|
|
fn launch_desktop_agent(executable: &Path, label: &str) -> Result<(), String> {
|
|
#[cfg(target_os = "macos")]
|
|
let mut command = {
|
|
let application = executable
|
|
.ancestors()
|
|
.find(|path| path.extension().and_then(|value| value.to_str()) == Some("app"))
|
|
.unwrap_or(executable);
|
|
let mut command = Command::new("open");
|
|
command.arg(application);
|
|
command
|
|
};
|
|
|
|
#[cfg(any(target_os = "windows", target_os = "linux"))]
|
|
let mut command = Command::new(executable);
|
|
|
|
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
|
|
{
|
|
let _ = (executable, label);
|
|
return Err("当前平台不支持桌面智能体".to_string());
|
|
}
|
|
|
|
#[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))]
|
|
{
|
|
command
|
|
.stdin(Stdio::null())
|
|
.stdout(Stdio::null())
|
|
.stderr(Stdio::null());
|
|
configure_background_command(&mut command);
|
|
command
|
|
.spawn()
|
|
.map(|_| ())
|
|
.map_err(|error| format!("启动 {label} 失败: {error}"))
|
|
}
|
|
}
|
|
|
|
#[cfg(target_os = "windows")]
|
|
fn launch_windows_store_app(app_id: &str, label: &str) -> Result<(), String> {
|
|
let mut command = Command::new(windows_explorer_executable());
|
|
command
|
|
.arg(format!("shell:AppsFolder\\{app_id}"))
|
|
.stdin(Stdio::null())
|
|
.stdout(Stdio::null())
|
|
.stderr(Stdio::null());
|
|
configure_background_command(&mut command);
|
|
command
|
|
.spawn()
|
|
.map(|_| ())
|
|
.map_err(|error| format!("启动 {label} 失败: {error}"))
|
|
}
|
|
|
|
#[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}")
|
|
})
|
|
}
|
|
}
|
|
|
|
#[cfg(target_os = "macos")]
|
|
fn shell_single_quote(value: &str) -> String {
|
|
format!("'{}'", value.replace('\'', "'\"'\"'"))
|
|
}
|
|
|
|
#[cfg(target_os = "macos")]
|
|
fn launch_cli_agent(
|
|
executable: &Path,
|
|
label: &str,
|
|
working_directory: &Path,
|
|
environment_to_remove: &[&str],
|
|
) -> Result<(), String> {
|
|
let removals = environment_to_remove
|
|
.iter()
|
|
.map(|key| format!("-u {}", shell_single_quote(key)))
|
|
.collect::<Vec<_>>()
|
|
.join(" ");
|
|
let command_line = format!(
|
|
"cd {} && exec env {} {}",
|
|
shell_single_quote(&path_to_string(working_directory)),
|
|
removals,
|
|
shell_single_quote(&path_to_string(executable)),
|
|
);
|
|
let script = format!(
|
|
"tell application \"Terminal\"\nactivate\ndo script \"{}\"\nend tell",
|
|
command_line.replace('\\', "\\\\").replace('"', "\\\"")
|
|
);
|
|
let output = Command::new("osascript")
|
|
.arg("-e")
|
|
.arg(script)
|
|
.output()
|
|
.map_err(|error| format!("启动 {label} 终端失败: {error}"))?;
|
|
if output.status.success() {
|
|
Ok(())
|
|
} else {
|
|
let detail = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
|
Err(format!("启动 {label} 终端失败: {detail}"))
|
|
}
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
fn launch_cli_agent(
|
|
executable: &Path,
|
|
label: &str,
|
|
working_directory: &Path,
|
|
environment_to_remove: &[&str],
|
|
) -> Result<(), String> {
|
|
let terminals: &[(&str, &[&str])] = &[
|
|
("x-terminal-emulator", &["-e"]),
|
|
("gnome-terminal", &["--"]),
|
|
("konsole", &["-e"]),
|
|
("xfce4-terminal", &["-e"]),
|
|
("mate-terminal", &["--"]),
|
|
("kitty", &["-e"]),
|
|
("alacritty", &["-e"]),
|
|
("ghostty", &["-e"]),
|
|
("xterm", &["-e"]),
|
|
];
|
|
let path = env::var_os("PATH").unwrap_or_default();
|
|
let mut last_error = None;
|
|
for (terminal, arguments) in terminals {
|
|
if !env::split_paths(&path).any(|directory| directory.join(terminal).is_file()) {
|
|
continue;
|
|
}
|
|
let mut command = Command::new(terminal);
|
|
command
|
|
.args(*arguments)
|
|
.arg(executable)
|
|
.current_dir(working_directory)
|
|
.stdin(Stdio::null())
|
|
.stdout(Stdio::null())
|
|
.stderr(Stdio::null());
|
|
for key in environment_to_remove {
|
|
command.env_remove(key);
|
|
}
|
|
match command.spawn() {
|
|
Ok(_) => return Ok(()),
|
|
Err(error) => last_error = Some(error.to_string()),
|
|
}
|
|
}
|
|
Err(match last_error {
|
|
Some(error) => format!("启动 {label} 失败: {error}"),
|
|
None => format!("启动 {label} 失败:未找到可用的终端程序"),
|
|
})
|
|
}
|
|
|
|
#[cfg(target_os = "windows")]
|
|
fn launch_cli_agent(
|
|
executable: &Path,
|
|
label: &str,
|
|
working_directory: &Path,
|
|
environment_to_remove: &[&str],
|
|
) -> Result<(), String> {
|
|
use std::os::windows::process::CommandExt;
|
|
|
|
const CREATE_NEW_CONSOLE: u32 = 0x0000_0010;
|
|
let mut command = windows_command_for_executable(executable, true);
|
|
command
|
|
.current_dir(working_directory)
|
|
.creation_flags(CREATE_NEW_CONSOLE);
|
|
for key in environment_to_remove {
|
|
command.env_remove(key);
|
|
}
|
|
command
|
|
.spawn()
|
|
.map(|_| ())
|
|
.map_err(|error| format!("启动 {label} 失败: {error}"))
|
|
}
|
|
|
|
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
|
|
fn launch_cli_agent(
|
|
_executable: &Path,
|
|
label: &str,
|
|
_working_directory: &Path,
|
|
_environment_to_remove: &[&str],
|
|
) -> Result<(), String> {
|
|
Err(format!("当前平台不支持启动 {label}"))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn relative_launch_directory_is_rejected() {
|
|
let error = resolve_launch_directory(Some("relative/project"), Path::new("/fallback"))
|
|
.expect_err("relative path should be rejected");
|
|
assert!(error.contains("绝对路径"));
|
|
}
|
|
|
|
#[test]
|
|
fn omitted_launch_directory_uses_fallback() {
|
|
let fallback = if cfg!(target_os = "windows") {
|
|
Path::new(r"C:\Users\tester")
|
|
} else {
|
|
Path::new("/home/tester")
|
|
};
|
|
assert_eq!(resolve_launch_directory(None, fallback).unwrap(), fallback);
|
|
}
|
|
|
|
#[test]
|
|
fn codex_exposes_independent_app_and_cli_targets() {
|
|
let executable = Path::new("codex");
|
|
let targets =
|
|
agent_launch_targets(AgentClient::Codex, Some(executable), Some("1.0.0"), true);
|
|
assert_eq!(
|
|
targets
|
|
.iter()
|
|
.map(|target| target.id.as_str())
|
|
.collect::<Vec<_>>(),
|
|
["app", "cli"]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn opencode_desktop_is_available_without_a_cli() {
|
|
let targets = agent_launch_targets(AgentClient::OpenCode, None, None, true);
|
|
assert_eq!(
|
|
targets
|
|
.iter()
|
|
.map(|target| target.id.as_str())
|
|
.collect::<Vec<_>>(),
|
|
["app"]
|
|
);
|
|
assert_eq!(targets[0].label, "OpenCode Desktop");
|
|
}
|
|
|
|
#[test]
|
|
fn opencode_keeps_the_cli_as_the_default_when_both_are_installed() {
|
|
let executable = Path::new("opencode");
|
|
let targets = agent_launch_targets(AgentClient::OpenCode, Some(executable), None, true);
|
|
assert_eq!(
|
|
targets
|
|
.iter()
|
|
.map(|target| target.id.as_str())
|
|
.collect::<Vec<_>>(),
|
|
["cli", "app"]
|
|
);
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
#[test]
|
|
fn opencode_linux_restart_matches_the_exact_appimage_environment() {
|
|
let directory = env::temp_dir().join(format!(
|
|
"cpa-opencode-linux-restart-{}-{}",
|
|
std::process::id(),
|
|
SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.unwrap()
|
|
.as_nanos()
|
|
));
|
|
let application = directory.join("opencode-desktop-linux-amd64.AppImage");
|
|
fs::create_dir_all(&directory).unwrap();
|
|
fs::write(&application, []).unwrap();
|
|
let mut child = Command::new("sleep")
|
|
.arg("30")
|
|
.env("APPIMAGE", &application)
|
|
.spawn()
|
|
.unwrap();
|
|
thread::sleep(Duration::from_millis(100));
|
|
|
|
stop_opencode_desktop(&application).unwrap();
|
|
child.wait().unwrap();
|
|
assert!(child.try_wait().unwrap().is_some());
|
|
fs::remove_dir_all(directory).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn desktop_and_cli_clients_get_the_correct_target_kind() {
|
|
let executable = Path::new("agent");
|
|
let zcode =
|
|
agent_launch_targets(AgentClient::ZCode, Some(executable), Some("1.0.0"), false);
|
|
let kimi = agent_launch_targets(AgentClient::KimiCode, Some(executable), None, false);
|
|
assert_eq!(zcode[0].id, "app");
|
|
assert_eq!(kimi[0].id, "cli");
|
|
}
|
|
|
|
#[cfg(target_os = "windows")]
|
|
#[test]
|
|
fn codex_restart_script_is_scoped_to_the_detected_installation() {
|
|
let executable = CodexAppTarget::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 = CodexAppTarget::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"));
|
|
}
|
|
|
|
#[cfg(target_os = "windows")]
|
|
#[test]
|
|
fn opencode_restart_script_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"));
|
|
}
|
|
}
|