diff --git a/src-tauri/src/agents/discovery.rs b/src-tauri/src/agents/discovery.rs index 45a59f0..9871954 100644 --- a/src-tauri/src/agents/discovery.rs +++ b/src-tauri/src/agents/discovery.rs @@ -890,25 +890,34 @@ pub(crate) fn inspect_agent_config( let codex_app_installation = (client == AgentClient::Codex) .then(|| find_codex_app_installation(home)) .flatten(); + let opencode_desktop_application = (client == AgentClient::OpenCode) + .then(|| find_opencode_desktop_application(home)) + .flatten(); let app_version = match client { AgentClient::ClaudeDesktop => read_claude_desktop_version(home), AgentClient::Codex => codex_app_installation .as_ref() .and_then(read_codex_app_installation_version), + AgentClient::OpenCode => opencode_desktop_application + .as_deref() + .and_then(read_opencode_desktop_version), AgentClient::DeepSeekHarness => read_deepseek_harness_profile_version(home), AgentClient::ZCode => read_zcode_app_version(home), _ => None, }; let version = cli_version.clone().or_else(|| app_version.clone()); - let installed = version.is_some() - || (matches!(client, AgentClient::ClaudeDesktop | AgentClient::ZCode) - && executable.is_some()) - || codex_app_installation.is_some(); + let app_installed = codex_app_installation.is_some() || opencode_desktop_application.is_some(); + let installed = agent_installation_detected( + client, + version.as_deref(), + executable.is_some(), + app_installed, + ); let launch_targets = agent_launch_targets( client, executable.as_deref(), app_version.as_deref(), - codex_app_installation.is_some(), + app_installed, ); let mut warnings = Vec::new(); if !client.supported_platform() { @@ -954,6 +963,20 @@ pub(crate) fn inspect_agent_config( } } +pub(crate) fn agent_installation_detected( + client: AgentClient, + version: Option<&str>, + executable_found: bool, + app_installed: bool, +) -> bool { + version.is_some() + || (matches!( + client, + AgentClient::ClaudeDesktop | AgentClient::OpenCode | AgentClient::ZCode + ) && executable_found) + || app_installed +} + pub(crate) fn should_probe_primary_agent_executable_version(client: AgentClient) -> bool { !matches!(client, AgentClient::ClaudeDesktop | AgentClient::ZCode) } @@ -993,6 +1016,22 @@ pub(crate) fn agent_launch_targets( }); } } + AgentClient::OpenCode => { + if let Some(executable) = executable { + targets.push(AgentLaunchTarget { + id: "cli".to_string(), + label: "OpenCode CLI".to_string(), + detail: path_to_string(executable), + }); + } + if app_installed { + targets.push(AgentLaunchTarget { + id: "app".to_string(), + label: "OpenCode Desktop".to_string(), + detail: "OpenCode Desktop".to_string(), + }); + } + } AgentClient::ZCode => { if let Some(executable) = executable { targets.push(AgentLaunchTarget { @@ -1380,6 +1419,471 @@ pub(crate) fn find_codex_app_installation(home: &Path) -> Option } } +pub(crate) fn find_opencode_desktop_application(home: &Path) -> Option { + #[cfg(target_os = "macos")] + { + [ + PathBuf::from("/Applications/OpenCode.app"), + home.join("Applications/OpenCode.app"), + ] + .into_iter() + .find(|path| path.is_dir()) + } + + #[cfg(target_os = "windows")] + { + let local = env::var_os("LOCALAPPDATA") + .map(PathBuf::from) + .filter(|path| !path.as_os_str().is_empty()) + .unwrap_or_else(|| home.join("AppData/Local")); + let program_files_roots = ["ProgramFiles", "ProgramFiles(x86)"] + .into_iter() + .filter_map(env::var_os) + .map(PathBuf::from) + .filter(|path| !path.as_os_str().is_empty()) + .collect::>(); + find_windows_opencode_desktop_application_in_roots(&local, &program_files_roots) + } + + #[cfg(target_os = "linux")] + { + let mut executable_directories = agent_executable_directories(home); + for directory in ["/opt/OpenCode", "/opt/opencode-desktop"] { + let directory = PathBuf::from(directory); + if !executable_directories.contains(&directory) { + executable_directories.push(directory); + } + } + + let data_home = env::var_os("XDG_DATA_HOME") + .map(PathBuf::from) + .filter(|path| path.is_absolute()) + .unwrap_or_else(|| home.join(".local/share")); + let mut data_directories = vec![data_home]; + let system_data_directories = env::var_os("XDG_DATA_DIRS") + .map(|value| env::split_paths(&value).collect::>()) + .filter(|directories| !directories.is_empty()) + .unwrap_or_else(|| { + vec![ + PathBuf::from("/usr/local/share"), + PathBuf::from("/usr/share"), + ] + }); + for directory in system_data_directories { + if directory.is_absolute() && !data_directories.contains(&directory) { + data_directories.push(directory); + } + } + + let appimage_directories = [ + home.join("Applications"), + home.join(".local/Applications"), + home.join("Downloads"), + home.join("Desktop"), + ]; + find_linux_opencode_desktop_application_in_roots( + home, + &executable_directories, + &data_directories, + &appimage_directories, + ) + } + + #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))] + { + let _ = home; + None + } +} + +#[cfg(target_os = "windows")] +pub(crate) fn find_windows_opencode_desktop_application_in_roots( + local_app_data: &Path, + program_files_roots: &[PathBuf], +) -> Option { + let mut candidates = vec![ + local_app_data.join("Programs/@opencode-aidesktop/OpenCode.exe"), + local_app_data.join("Programs/OpenCode/OpenCode.exe"), + local_app_data.join("Programs/opencode/OpenCode.exe"), + local_app_data.join("OpenCode/OpenCode.exe"), + local_app_data.join("opencode/OpenCode.exe"), + ]; + for root in program_files_roots { + candidates.extend([ + root.join("@opencode-aidesktop/OpenCode.exe"), + root.join("OpenCode/OpenCode.exe"), + root.join("opencode/OpenCode.exe"), + ]); + } + candidates.into_iter().find(|path| path.is_file()) +} + +#[cfg(any(target_os = "linux", test))] +pub(crate) fn find_linux_opencode_desktop_application_in_roots( + home: &Path, + executable_directories: &[PathBuf], + data_directories: &[PathBuf], + appimage_directories: &[PathBuf], +) -> Option { + const EXECUTABLE_NAMES: &[&str] = &[ + "ai.opencode.desktop", + "ai.opencode.desktop.beta", + "ai.opencode.desktop.dev", + "opencode-desktop", + "@opencode-aidesktop", + "OpenCode", + "OpenCode Desktop", + ]; + + for directory in executable_directories { + for name in EXECUTABLE_NAMES { + let candidate = directory.join(name); + if candidate.is_file() { + return Some(candidate); + } + } + } + + find_linux_opencode_application_from_desktop_entries( + home, + executable_directories, + data_directories, + ) + .or_else(|| { + appimage_directories.iter().find_map(|directory| { + let mut candidates = fs::read_dir(directory) + .ok()? + .flatten() + .map(|entry| entry.path()) + .filter(|path| path.is_file() && is_opencode_desktop_appimage(path)) + .collect::>(); + candidates.sort(); + candidates.into_iter().next() + }) + }) +} + +#[cfg(any(target_os = "linux", test))] +fn find_linux_opencode_application_from_desktop_entries( + home: &Path, + executable_directories: &[PathBuf], + data_directories: &[PathBuf], +) -> Option { + for data_directory in data_directories { + let applications = data_directory.join("applications"); + let mut entries = fs::read_dir(applications) + .ok() + .into_iter() + .flatten() + .flatten() + .map(|entry| entry.path()) + .filter(|path| { + path.extension() + .and_then(|extension| extension.to_str()) + .is_some_and(|extension| extension.eq_ignore_ascii_case("desktop")) + }) + .collect::>(); + entries.sort(); + + for entry in entries { + let Ok(content) = fs::read_to_string(&entry) else { + continue; + }; + if !is_opencode_desktop_entry(&entry, &content) { + continue; + } + let Some(command) = linux_desktop_entry_command(&content) else { + continue; + }; + if let Some(application) = + resolve_linux_desktop_command(home, executable_directories, command.as_str()) + { + return Some(application); + } + } + } + None +} + +#[cfg(any(target_os = "linux", test))] +fn is_opencode_desktop_entry(path: &Path, content: &str) -> bool { + let file_name = path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or_default() + .to_ascii_lowercase(); + if file_name.contains("opencode") { + return true; + } + + let mut in_desktop_entry = false; + content.lines().any(|line| { + let line = line.trim(); + if line.starts_with('[') && line.ends_with(']') { + in_desktop_entry = line.eq_ignore_ascii_case("[Desktop Entry]"); + return false; + } + if !in_desktop_entry { + return false; + } + let Some((key, value)) = line.split_once('=') else { + return false; + }; + let value = value.trim().to_ascii_lowercase(); + match key.trim() { + "Name" => { + value == "opencode" + || value.starts_with("opencode beta") + || value.starts_with("opencode dev") + } + "Icon" | "StartupWMClass" => { + value.starts_with("ai.opencode.desktop") + || value == "@opencode-aidesktop" + || value == "opencode" + } + _ => false, + } + }) +} + +#[cfg(any(target_os = "linux", test))] +fn linux_desktop_entry_command(content: &str) -> Option { + let mut in_desktop_entry = false; + for line in content.lines() { + let line = line.trim(); + if line.starts_with('[') && line.ends_with(']') { + in_desktop_entry = line.eq_ignore_ascii_case("[Desktop Entry]"); + continue; + } + if !in_desktop_entry { + continue; + } + let Some((key, value)) = line.split_once('=') else { + continue; + }; + if !key.trim().eq_ignore_ascii_case("Exec") { + continue; + } + let tokens = tokenize_linux_desktop_exec(value.trim()); + let mut index = 0; + if tokens.first().is_some_and(|token| { + Path::new(token) + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name == "env") + }) { + index += 1; + while tokens.get(index).is_some_and(|token| token.contains('=')) { + index += 1; + } + } + let command = tokens.get(index)?.trim(); + if command.is_empty() + || command.starts_with('%') + || ["flatpak", "snap", "gtk-launch", "sh", "bash"] + .iter() + .any(|wrapper| { + Path::new(command) + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name == *wrapper) + }) + { + return None; + } + return Some(command.to_string()); + } + None +} + +#[cfg(any(target_os = "linux", test))] +fn tokenize_linux_desktop_exec(value: &str) -> Vec { + let mut tokens = Vec::new(); + let mut current = String::new(); + let mut quote = None; + let mut escaped = false; + + for character in value.chars() { + if escaped { + current.push(character); + escaped = false; + continue; + } + if character == '\\' { + escaped = true; + continue; + } + if let Some(delimiter) = quote { + if character == delimiter { + quote = None; + } else { + current.push(character); + } + continue; + } + if character == '"' || character == '\'' { + quote = Some(character); + } else if character.is_whitespace() { + if !current.is_empty() { + tokens.push(std::mem::take(&mut current)); + } + } else { + current.push(character); + } + } + if escaped { + current.push('\\'); + } + if !current.is_empty() { + tokens.push(current); + } + tokens +} + +#[cfg(any(target_os = "linux", test))] +fn resolve_linux_desktop_command( + home: &Path, + executable_directories: &[PathBuf], + command: &str, +) -> Option { + let candidate = if let Some(relative) = command.strip_prefix("~/") { + home.join(relative) + } else { + PathBuf::from(command) + }; + if candidate.is_absolute() || command.contains('/') { + return candidate.is_file().then_some(candidate); + } + executable_directories + .iter() + .map(|directory| directory.join(&candidate)) + .find(|path| path.is_file()) +} + +#[cfg(any(target_os = "linux", test))] +fn is_opencode_desktop_appimage(path: &Path) -> bool { + let Some(file_name) = path.file_name().and_then(|name| name.to_str()) else { + return false; + }; + let file_name = file_name.to_ascii_lowercase(); + file_name.ends_with(".appimage") + && (file_name.starts_with("opencode-desktop") || file_name.starts_with("opencode_")) +} + +pub(crate) fn read_opencode_desktop_version(application: &Path) -> Option { + #[cfg(target_os = "windows")] + { + read_windows_executable_version(application) + } + + #[cfg(target_os = "macos")] + { + read_macos_app_version(application) + } + + #[cfg(target_os = "linux")] + { + read_linux_opencode_desktop_version(application) + } + + #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))] + { + let _ = application; + None + } +} + +#[cfg(target_os = "linux")] +fn read_linux_opencode_desktop_version(application: &Path) -> Option { + let path_version = linux_opencode_desktop_version_from_path(application); + if application + .extension() + .and_then(|extension| extension.to_str()) + .is_some_and(|extension| extension.eq_ignore_ascii_case("appimage")) + { + path_version + } else { + path_version.or_else(read_linux_opencode_package_version) + } +} + +#[cfg(any(target_os = "linux", test))] +pub(crate) fn linux_opencode_desktop_version_from_path(application: &Path) -> Option { + application + .components() + .rev() + .filter_map(|component| component.as_os_str().to_str()) + .find_map(|component| { + let lower = component.to_ascii_lowercase(); + if !lower.contains("opencode") { + return None; + } + let component = lower + .strip_suffix(".appimage") + .map(|_| &component[..component.len() - ".appimage".len()]) + .unwrap_or(component); + component.split(['-', '_']).find_map(|token| { + let version = token.strip_prefix('v').unwrap_or(token); + (version.starts_with(|character: char| character.is_ascii_digit()) + && version.contains('.') + && version.chars().all(|character| { + character.is_ascii_alphanumeric() || matches!(character, '.' | '+' | '~') + })) + .then(|| version.to_string()) + }) + }) +} + +#[cfg(target_os = "linux")] +fn read_linux_opencode_package_version() -> Option { + const PACKAGE_NAMES: &[&str] = &[ + "opencode", + "opencode-desktop", + "opencode-ai-desktop", + "opencode-aidesktop", + ]; + + let mut dpkg = Command::new("dpkg-query"); + dpkg.args(["--show", "--showformat=${Package}\\t${Version}\\n"]) + .args(PACKAGE_NAMES); + if let Some(version) = linux_package_version_from_output(&mut dpkg, '\t') { + return Some(version); + } + + let mut rpm = Command::new("rpm"); + rpm.args(["--query", "--queryformat", "%{NAME}\\t%{VERSION}\\n"]) + .args(PACKAGE_NAMES); + if let Some(version) = linux_package_version_from_output(&mut rpm, '\t') { + return Some(version); + } + + let mut pacman = Command::new("pacman"); + pacman.arg("--query").args(PACKAGE_NAMES); + linux_package_version_from_output(&mut pacman, ' ') +} + +#[cfg(target_os = "linux")] +fn linux_package_version_from_output(command: &mut Command, separator: char) -> Option { + configure_background_command(command); + let output = command_output_with_timeout(command, AGENT_VERSION_PROBE_TIMEOUT).ok()??; + String::from_utf8_lossy(&output.stdout) + .lines() + .find_map(|line| { + let (package, version) = line.trim().split_once(separator)?; + let package = package.trim(); + let version = version.trim(); + ([ + "opencode", + "opencode-desktop", + "opencode-ai-desktop", + "opencode-aidesktop", + ] + .contains(&package)) + .then(|| normalize_detected_agent_version(version)) + .flatten() + }) +} + pub(crate) fn read_claude_desktop_version(home: &Path) -> Option { #[cfg(target_os = "windows")] { diff --git a/src-tauri/src/agents/launch.rs b/src-tauri/src/agents/launch.rs index 13c089f..8bdb87e 100644 --- a/src-tauri/src/agents/launch.rs +++ b/src-tauri/src/agents/launch.rs @@ -41,11 +41,11 @@ pub(crate) fn launch_agent( if !status.installed { return Err(format!("未检测到 {},请先安装并重新检测", client.name())); } - let default_target = match client { - AgentClient::ClaudeDesktop | AgentClient::ZCode => "app", - AgentClient::Codex if find_codex_app_installation(&home).is_some() => "app", - _ => "cli", - }; + 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), @@ -55,6 +55,7 @@ pub(crate) fn launch_agent( 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())) } @@ -96,6 +97,24 @@ pub(crate) async fn restart_codex_app(app: tauri::AppHandle) -> Result<(), Strin .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 { let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) else { return Ok(fallback.to_path_buf()); @@ -130,7 +149,18 @@ fn launch_codex_target(target: &CodexAppTarget) -> Result<(), String> { #[cfg(target_os = "windows")] fn stop_codex_desktop(target: &CodexAppTarget) -> Result<(), String> { let script = windows_codex_stop_script(target); - let encoded = windows_powershell_encoded_command(&script); + 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", @@ -144,7 +174,7 @@ fn stop_codex_desktop(target: &CodexAppTarget) -> Result<(), String> { configure_background_command(&mut command); let output = command .output() - .map_err(|error| format!("关闭 Codex App 失败: {error}"))?; + .map_err(|error| format!("关闭 {label} 失败: {error}"))?; if output.status.success() { return Ok(()); } @@ -156,9 +186,9 @@ fn stop_codex_desktop(target: &CodexAppTarget) -> Result<(), String> { .trim() .to_string(); Err(if detail.is_empty() { - "Codex App 未能完全关闭".to_string() + format!("{label} 未能完全关闭") } else { - format!("关闭 Codex App 失败: {detail}") + format!("关闭 {label} 失败: {detail}") }) } @@ -209,18 +239,58 @@ throw "Codex App did not exit; remaining process IDs: $($remaining.ProcessId -jo ) } +#[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(executable) = target; - let process_name = executable - .file_name() - .and_then(|name| name.to_str()) - .filter(|name| !name.is_empty()) - .ok_or_else(|| "无法识别 Codex App 进程名称".to_string())?; - let application_name = executable + 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")) - .and_then(Path::file_stem) + .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('"', "\\\""); @@ -230,11 +300,11 @@ fn stop_codex_desktop(target: &CodexAppTarget) -> Result<(), String> { &format!("tell application \"{escaped_name}\" to quit"), ]) .status(); - wait_for_unix_process_exit(process_name) + wait_for_unix_process_exit(process_name, label) } #[cfg(target_os = "macos")] -fn wait_for_unix_process_exit(process_name: &str) -> Result<(), String> { +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()) { @@ -249,19 +319,170 @@ fn wait_for_unix_process_exit(process_name: &str) -> Result<(), String> { let status = Command::new("pgrep") .args(["-x", process_name]) .status() - .map_err(|error| format!("检查 Codex App 进程失败: {error}"))?; + .map_err(|error| format!("检查 {label} 进程失败: {error}"))?; if status.success() { - Err("Codex App 未能完全关闭".to_string()) + 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::>() + .join(", ") + )) + } +} + +#[cfg(target_os = "linux")] +fn linux_processes_for_application(application: &Path) -> Vec { + 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::().ok()) + .filter(|process_id| *process_id != current_process) + .filter(|process_id| linux_process_matches_application(*process_id, application)) + .collect::>(); + 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"); @@ -274,6 +495,12 @@ fn launch_claude_desktop(home: &Path) -> Result<(), String> { 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 = { @@ -518,6 +745,59 @@ mod tests { ); } + #[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::>(), + ["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::>(), + ["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"); @@ -542,4 +822,17 @@ mod tests { 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")); + } } diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 8767923..b963155 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -1990,6 +1990,7 @@ fn main() { update_agent_config, launch_agent, restart_codex_app, + restart_opencode_app, get_lan_ipv4, save_gui_settings, save_network_routing_settings, diff --git a/src-tauri/src/tests/agent_configuration.rs b/src-tauri/src/tests/agent_configuration.rs index a7b1bcb..2b125e5 100644 --- a/src-tauri/src/tests/agent_configuration.rs +++ b/src-tauri/src/tests/agent_configuration.rs @@ -667,6 +667,124 @@ fn opencode_config_path_supports_jsonc_and_custom_config() { fs::remove_dir_all(home).unwrap(); } +#[test] +fn opencode_cli_or_desktop_is_detected_as_installed() { + assert!(agent_installation_detected( + AgentClient::OpenCode, + None, + true, + false, + )); + assert!(agent_installation_detected( + AgentClient::OpenCode, + None, + false, + true, + )); + assert!(!agent_installation_detected( + AgentClient::OpenCode, + None, + false, + false, + )); +} + +#[cfg(target_os = "windows")] +#[test] +fn opencode_windows_desktop_is_found_in_the_electron_installer_directory() { + let home = agent_test_home("opencode-windows-desktop"); + let local_app_data = home.join("AppData/Local"); + let application = local_app_data.join("Programs/@opencode-aidesktop/OpenCode.exe"); + fs::create_dir_all(application.parent().unwrap()).unwrap(); + fs::write(&application, []).unwrap(); + + assert_eq!( + find_windows_opencode_desktop_application_in_roots(&local_app_data, &[]), + Some(application) + ); + fs::remove_dir_all(home).unwrap(); +} + +#[test] +fn opencode_linux_desktop_finds_the_current_package_binary() { + let home = agent_test_home("opencode-linux-package"); + let executable_directory = home.join("opt/OpenCode"); + let application = executable_directory.join("ai.opencode.desktop"); + fs::create_dir_all(&executable_directory).unwrap(); + fs::write(&application, []).unwrap(); + + assert_eq!( + find_linux_opencode_desktop_application_in_roots(&home, &[executable_directory], &[], &[],), + Some(application) + ); + fs::remove_dir_all(home).unwrap(); +} + +#[test] +fn opencode_linux_desktop_resolves_an_appimage_desktop_entry() { + let home = agent_test_home("opencode-linux-appimage-entry"); + let data_directory = home.join("share"); + let applications = data_directory.join("applications"); + let application = home.join("Applications/opencode-desktop-linux-amd64.AppImage"); + fs::create_dir_all(&applications).unwrap(); + fs::create_dir_all(application.parent().unwrap()).unwrap(); + fs::write(&application, []).unwrap(); + let application = PathBuf::from(path_to_string(&application).replace('\\', "/")); + fs::write( + applications.join("ai.opencode.desktop.desktop"), + format!( + "[Desktop Entry]\nName=OpenCode\nExec=\"{}\" %U\nType=Application\n", + path_to_string(&application) + ), + ) + .unwrap(); + + assert_eq!( + find_linux_opencode_desktop_application_in_roots(&home, &[], &[data_directory], &[],), + Some(application) + ); + fs::remove_dir_all(home).unwrap(); +} + +#[test] +fn opencode_linux_desktop_finds_an_unregistered_appimage() { + let home = agent_test_home("opencode-linux-appimage-directory"); + let applications = home.join("Applications"); + let application = applications.join("opencode-desktop-linux-aarch64.AppImage"); + fs::create_dir_all(&applications).unwrap(); + fs::write(&application, []).unwrap(); + + assert_eq!( + find_linux_opencode_desktop_application_in_roots(&home, &[], &[], &[applications],), + Some(application) + ); + fs::remove_dir_all(home).unwrap(); +} + +#[test] +fn opencode_linux_desktop_reads_versions_from_appimage_and_nix_paths() { + assert_eq!( + linux_opencode_desktop_version_from_path(Path::new( + "/home/tester/Applications/opencode-desktop-1.18.16-linux-amd64.AppImage" + )) + .as_deref(), + Some("1.18.16") + ); + assert_eq!( + linux_opencode_desktop_version_from_path(Path::new( + "/nix/store/hash-opencode-desktop-1.18.16/bin/opencode-desktop" + )) + .as_deref(), + Some("1.18.16") + ); + assert_eq!( + linux_opencode_desktop_version_from_path(Path::new( + "/home/tester.2026/Downloads/opencode-desktop-linux-amd64.AppImage" + )), + None + ); +} + #[test] fn opencode_agent_config_accepts_jsonc_and_preserves_comments() { let home = agent_test_home("opencode-jsonc"); diff --git a/src/pages/AgentsPage.tsx b/src/pages/AgentsPage.tsx index a62709a..ed25e9d 100644 --- a/src/pages/AgentsPage.tsx +++ b/src/pages/AgentsPage.tsx @@ -813,6 +813,7 @@ export function AgentsPage() { const selectedModelOption = findAgentModel(models, savedSelectedModel); const selectedModel = selectedModelOption?.name ?? ''; const isPiClient = selected === 'pi'; + const hasIndependentCliAndApp = selected === 'codex' || selected === 'opencode'; const isClaudeModelMappingClient = selected === 'claude-code' || selected === 'claude-desktop'; const claudeModelMappingsDraft = isClaudeModelMappingClient ? claudeModelMappingsDraftByClient[selected] @@ -1346,7 +1347,7 @@ export function AgentsPage() { target: AgentLaunchTarget, workingDirectory: string | null = null, ) => { - const action = selected === 'codex' + const action = hasIndependentCliAndApp ? target.id === 'cli' ? 'launch-cli' : 'launch-app' : 'launch'; setBusyAction(action); @@ -1382,11 +1383,12 @@ export function AgentsPage() { await invokeAgentLaunch(target); }; - const restartCodexApp = async () => { + const restartDesktopApp = async () => { + if (!hasIndependentCliAndApp) return; setBusyAction('restart-app'); setLaunchError(''); try { - await invoke('restart_codex_app'); + await invoke(selected === 'codex' ? 'restart_codex_app' : 'restart_opencode_app'); } catch (requestError) { if (!handleOAuthLoginError(requestError, 'launch')) { setLaunchError(String(requestError)); @@ -1518,12 +1520,12 @@ export function AgentsPage() { role="tabpanel" aria-labelledby="agent-subpage-tab-core" > -
+
{t('agents.installStatus')} {activeStatus?.installed ? t('agents.clientDetected') : t('agents.clientNotDetected')}
- {selected === 'codex' ? ( + {hasIndependentCliAndApp ? ( <>
{t('agents.cliVersion')} @@ -1908,7 +1910,7 @@ export function AgentsPage() {