* 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
This commit is contained in:
lzt404 authored and GitHub committed 2026-08-22 23:58:05 +08:00
1 parent e60b47fd7c
commit 93fe5baa54
6 files changed
+952 -34

No files matched your search

+509 -5
View File
@@ -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<CodexAppTarget>
}
}
pub(crate) fn find_opencode_desktop_application(home: &Path) -> Option<PathBuf> {
#[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::<Vec<_>>();
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::<Vec<_>>())
.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<PathBuf> {
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<PathBuf> {
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::<Vec<_>>();
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<PathBuf> {
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::<Vec<_>>();
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<String> {
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<String> {
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<PathBuf> {
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<String> {
#[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<String> {
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<String> {
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<String> {
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<String> {
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<String> {
#[cfg(target_os = "windows")]
{
+314 -21
View File
@@ -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<PathBuf, String> {
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::<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");
@@ -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::<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");
@@ -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"));
}
}
+1
View File
@@ -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,
+118
View File
@@ -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");
+8 -6
View File
@@ -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"
>
<div className={`agent-status-grid ${selected === 'codex' ? 'codex-status-grid' : isPiClient ? 'pi-status-grid' : ''}`}>
<div className={`agent-status-grid ${hasIndependentCliAndApp ? 'dual-install-status-grid' : isPiClient ? 'pi-status-grid' : ''}`}>
<div>
<span><BadgeCheck size={14} />{t('agents.installStatus')}</span>
<strong>{activeStatus?.installed ? t('agents.clientDetected') : t('agents.clientNotDetected')}</strong>
</div>
{selected === 'codex' ? (
{hasIndependentCliAndApp ? (
<>
<div>
<span>{t('agents.cliVersion')}</span>
@@ -1908,7 +1910,7 @@ export function AgentsPage() {
<button
type="button"
className="secondary-button agent-launch-button"
onClick={() => void restartCodexApp()}
onClick={() => void restartDesktopApp()}
disabled={busy || !canLaunchTarget(appLaunchTarget)}
title={appLaunchTarget?.detail ?? t('agents.launch.unavailable')}
>
+2 -2
View File
@@ -5717,7 +5717,7 @@ textarea:focus-visible {
min-height: 76px;
}
.agent-status-grid.codex-status-grid {
.agent-status-grid.dual-install-status-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
@@ -7668,7 +7668,7 @@ textarea:focus-visible {
min-height: 0;
}
.agent-status-grid.codex-status-grid {
.agent-status-grid.dual-install-status-grid {
grid-template-columns: 1fr;
}