Dev (#160)
* 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:
1 parent
e60b47fd7c
commit
93fe5baa54
6 files changed
+952
-34
No files matched your search
@@ -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
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user